refactor: 将 WebUI 源码纳入仓库,移除打包产物
- 新增 webui/ 前端源码目录(React+Vite+TypeScript+Tailwind) - 删除 api/webui/ 中旧的打包静态资源 - 更新 .gitignore 忽略 api/webui/ 构建输出和 webui/node_modules - 更新 README 增加前端构建说明
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FolderOpen, RefreshCw } from 'lucide-react'
|
||||
import { dataApi } from '@/lib/api'
|
||||
import { FileCard } from './FileCard'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
// 从文件名提取类别
|
||||
function extractCategory(filename: string): string {
|
||||
// 文件名格式: search_comments_xxx, search_creators_xxx, search_videos_xxx 等
|
||||
const match = filename.match(/^(search_\w+?)_/)
|
||||
if (match) {
|
||||
return match[1]
|
||||
}
|
||||
// 其他格式尝试提取前缀
|
||||
const parts = filename.split('_')
|
||||
if (parts.length >= 2) {
|
||||
return `${parts[0]}_${parts[1]}`
|
||||
}
|
||||
return 'other'
|
||||
}
|
||||
|
||||
// 类别显示名称
|
||||
function getCategoryLabel(category: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
'search_comments': 'Comments',
|
||||
'search_creators': 'Creators',
|
||||
'search_videos': 'Videos',
|
||||
'search_contents': 'Contents',
|
||||
'search_notes': 'Notes',
|
||||
'other': 'Other',
|
||||
}
|
||||
return labels[category] || category.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
}
|
||||
|
||||
export function DataExplorer() {
|
||||
const { t } = useTranslation('data')
|
||||
const [activeTab, setActiveTab] = useState<string>('all')
|
||||
|
||||
const { data, isLoading, refetch, isRefetching } = useQuery({
|
||||
queryKey: ['dataFiles'],
|
||||
queryFn: async () => {
|
||||
const { data } = await dataApi.getFiles()
|
||||
return data.files
|
||||
},
|
||||
})
|
||||
|
||||
const files = data || []
|
||||
|
||||
// 按类别分组文件
|
||||
const { categories, groupedFiles } = useMemo(() => {
|
||||
const grouped: Record<string, typeof files> = {}
|
||||
|
||||
files.forEach(file => {
|
||||
const category = extractCategory(file.name)
|
||||
if (!grouped[category]) {
|
||||
grouped[category] = []
|
||||
}
|
||||
grouped[category].push(file)
|
||||
})
|
||||
|
||||
// 按文件数量排序类别
|
||||
const sortedCategories = Object.keys(grouped).sort((a, b) =>
|
||||
grouped[b].length - grouped[a].length
|
||||
)
|
||||
|
||||
return { categories: sortedCategories, groupedFiles: grouped }
|
||||
}, [files])
|
||||
|
||||
// 当前显示的文件
|
||||
const displayFiles = activeTab === 'all' ? files : (groupedFiles[activeTab] || [])
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-mono font-bold text-cyber-neon-cyan glow-text-cyan tracking-wider">
|
||||
{t('explorer.title')}
|
||||
</h2>
|
||||
<Badge variant="default" className="font-mono">
|
||||
{t('explorer.records', { count: files.length })}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isRefetching}
|
||||
className="font-mono"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isRefetching ? 'animate-spin' : ''}`} />
|
||||
{t('explorer.rescan')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Category Tabs */}
|
||||
{files.length > 0 && categories.length > 1 && (
|
||||
<div className="flex items-center gap-2 mb-4 flex-wrap">
|
||||
<button
|
||||
onClick={() => setActiveTab('all')}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-mono transition-all ${
|
||||
activeTab === 'all'
|
||||
? 'bg-cyber-neon-cyan text-black font-bold'
|
||||
: 'bg-cyber-bg-tertiary text-cyber-text-secondary hover:text-cyber-text-primary border border-cyber-border-subtle hover:border-cyber-neon-cyan/50'
|
||||
}`}
|
||||
>
|
||||
{t('explorer.allCategories')} ({files.length})
|
||||
</button>
|
||||
{categories.map(category => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => setActiveTab(category)}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-mono transition-all ${
|
||||
activeTab === category
|
||||
? 'bg-cyber-neon-cyan text-black font-bold'
|
||||
: 'bg-cyber-bg-tertiary text-cyber-text-secondary hover:text-cyber-text-primary border border-cyber-border-subtle hover:border-cyber-neon-cyan/50'
|
||||
}`}
|
||||
>
|
||||
{getCategoryLabel(category)} ({groupedFiles[category].length})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-cyber-text-muted font-mono animate-pulse">
|
||||
{t('explorer.loading')}
|
||||
</div>
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center">
|
||||
<div className="relative">
|
||||
<FolderOpen className="w-16 h-16 text-cyber-neon-cyan/30 mb-4" />
|
||||
<div className="absolute inset-0 blur-xl bg-cyber-neon-cyan/10" />
|
||||
</div>
|
||||
<h3 className="text-lg font-mono font-medium text-cyber-neon-cyan mb-2">
|
||||
{t('explorer.noData')}
|
||||
</h3>
|
||||
<p className="text-sm text-cyber-text-muted max-w-md font-mono">
|
||||
{t('explorer.noDataHint')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{displayFiles.map((file) => (
|
||||
<FileCard key={file.path} file={file} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Database } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { DataExplorer } from './DataExplorer'
|
||||
|
||||
export function DataExplorerDialog() {
|
||||
const { t } = useTranslation('data')
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="font-mono text-xs text-[#c9d1d9] border-[#30363d] bg-transparent hover:bg-[#21262d] hover:text-[#00ffff] hover:border-[#00ffff]/50"
|
||||
>
|
||||
<Database className="w-3.5 h-3.5" />
|
||||
{t('dialog.button')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-5xl max-h-[85vh] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('dialog.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="overflow-auto max-h-[calc(85vh-100px)] pr-2">
|
||||
<DataExplorer />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FileJson, FileSpreadsheet, FileText, Download, Eye } from 'lucide-react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { dataApi } from '@/lib/api'
|
||||
import { formatFileSize, formatDateTime } from '@/lib/utils'
|
||||
import { DataPreviewDialog } from './preview/DataPreviewDialog'
|
||||
import type { DataFile } from '@/types/crawler'
|
||||
|
||||
interface FileCardProps {
|
||||
file: DataFile
|
||||
}
|
||||
|
||||
const fileIcons: Record<string, typeof FileJson> = {
|
||||
json: FileJson,
|
||||
csv: FileSpreadsheet,
|
||||
xlsx: FileSpreadsheet,
|
||||
xls: FileSpreadsheet,
|
||||
}
|
||||
|
||||
const fileStyles: Record<string, { icon: string; border: string; badge: string }> = {
|
||||
json: {
|
||||
icon: 'text-cyber-neon-yellow',
|
||||
border: 'hover:border-cyber-neon-yellow/50',
|
||||
badge: 'border-cyber-neon-yellow/30 bg-cyber-neon-yellow/10 text-cyber-neon-yellow'
|
||||
},
|
||||
csv: {
|
||||
icon: 'text-cyber-neon-green',
|
||||
border: 'hover:border-cyber-neon-green/50',
|
||||
badge: 'border-cyber-neon-green/30 bg-cyber-neon-green/10 text-cyber-neon-green'
|
||||
},
|
||||
xlsx: {
|
||||
icon: 'text-cyber-neon-cyan',
|
||||
border: 'hover:border-cyber-neon-cyan/50',
|
||||
badge: 'border-cyber-neon-cyan/30 bg-cyber-neon-cyan/10 text-cyber-neon-cyan'
|
||||
},
|
||||
xls: {
|
||||
icon: 'text-cyber-neon-cyan',
|
||||
border: 'hover:border-cyber-neon-cyan/50',
|
||||
badge: 'border-cyber-neon-cyan/30 bg-cyber-neon-cyan/10 text-cyber-neon-cyan'
|
||||
},
|
||||
}
|
||||
|
||||
export function FileCard({ file }: FileCardProps) {
|
||||
const { t } = useTranslation('data')
|
||||
const [previewOpen, setPreviewOpen] = useState(false)
|
||||
|
||||
const Icon = fileIcons[file.type] || FileText
|
||||
const styles = fileStyles[file.type] || {
|
||||
icon: 'text-cyber-text-muted',
|
||||
border: 'hover:border-cyber-neon-cyan/50',
|
||||
badge: 'border-cyber-border-DEFAULT bg-cyber-bg-tertiary text-cyber-text-secondary'
|
||||
}
|
||||
|
||||
// 检查是否支持预览
|
||||
const isPreviewable = ['json', 'csv', 'xlsx', 'xls'].includes(file.type.toLowerCase())
|
||||
|
||||
const handleDownload = () => {
|
||||
const url = dataApi.getDownloadUrl(file.path)
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className={`relative overflow-hidden card-scan group transition-all ${styles.border} hover:shadow-[0_0_15px_rgb(var(--cyber-neon-cyan)/0.15)]`}>
|
||||
{/* Scan effect overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-cyber-neon-cyan/5 to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-700 pointer-events-none" />
|
||||
|
||||
<CardContent className="p-4 relative">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`p-2 rounded bg-cyber-bg-panel border border-cyber-border-DEFAULT ${styles.icon}`}>
|
||||
<Icon className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-mono font-medium text-sm text-cyber-text-primary truncate" title={file.name}>
|
||||
{file.name}
|
||||
</h3>
|
||||
<p className="text-xs text-cyber-text-muted mt-1 font-mono">
|
||||
{formatFileSize(file.size)}
|
||||
{file.record_count !== null && (
|
||||
<span className="text-cyber-neon-green"> | {t('file.entries', { count: file.record_count })}</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-cyber-text-muted mt-1 font-mono">
|
||||
{formatDateTime(file.modified_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-3 pt-3 border-t border-cyber-border-subtle">
|
||||
<Badge variant="outline" className={`text-[10px] font-mono ${styles.badge}`}>
|
||||
.{file.type.toUpperCase()}
|
||||
</Badge>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{isPreviewable && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 font-mono text-cyber-neon-cyan hover:text-cyber-neon-cyan hover:bg-cyber-neon-cyan/10"
|
||||
onClick={() => setPreviewOpen(true)}
|
||||
>
|
||||
<Eye className="w-3 h-3 mr-1" />
|
||||
{t('file.preview')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 font-mono text-cyber-neon-cyan hover:text-cyber-neon-cyan hover:bg-cyber-neon-cyan/10"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
{t('file.extract')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 预览对话框 */}
|
||||
{isPreviewable && (
|
||||
<DataPreviewDialog
|
||||
file={file}
|
||||
open={previewOpen}
|
||||
onOpenChange={setPreviewOpen}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Download } from 'lucide-react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { dataApi } from '@/lib/api'
|
||||
import { DataPreviewTable } from './DataPreviewTable'
|
||||
import type { DataFile } from '@/types/crawler'
|
||||
|
||||
interface DataPreviewDialogProps {
|
||||
file: DataFile
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function DataPreviewDialog({ file, open, onOpenChange }: DataPreviewDialogProps) {
|
||||
const { t } = useTranslation('data')
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['filePreview', file.path],
|
||||
queryFn: async () => {
|
||||
const { data } = await dataApi.getFileContent(file.path, 100)
|
||||
return data
|
||||
},
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
const handleDownload = () => {
|
||||
const url = dataApi.getDownloadUrl(file.path)
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-6xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<DialogTitle className="font-mono text-cyber-neon-cyan">
|
||||
{file.name}
|
||||
</DialogTitle>
|
||||
<Badge variant="outline" className="font-mono text-[10px]">
|
||||
.{file.type.toUpperCase()}
|
||||
</Badge>
|
||||
{data && (
|
||||
<Badge variant="default" className="font-mono text-[10px]">
|
||||
{t('preview.records', { count: data.total })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownload}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
{t('preview.download')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="flex-1 overflow-hidden min-h-0 mt-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-cyber-text-muted font-mono animate-pulse">
|
||||
{t('preview.loading')}
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-cyber-neon-pink font-mono">
|
||||
{t('preview.error')}
|
||||
</div>
|
||||
</div>
|
||||
) : data ? (
|
||||
<DataPreviewTable
|
||||
data={data.data}
|
||||
columns={data.columns}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Search } from 'lucide-react'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
interface DataPreviewTableProps {
|
||||
data: Record<string, unknown>[]
|
||||
columns?: string[]
|
||||
}
|
||||
|
||||
export function DataPreviewTable({ data, columns: propColumns }: DataPreviewTableProps) {
|
||||
const { t } = useTranslation('data')
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
|
||||
// 自动获取列名(JSON 可能没有 columns)
|
||||
const columns = useMemo(() => {
|
||||
if (propColumns && propColumns.length > 0) return propColumns
|
||||
if (data.length === 0) return []
|
||||
return Object.keys(data[0])
|
||||
}, [data, propColumns])
|
||||
|
||||
// 过滤数据
|
||||
const filteredData = useMemo(() => {
|
||||
if (!searchTerm) return data
|
||||
const term = searchTerm.toLowerCase()
|
||||
return data.filter(row =>
|
||||
Object.values(row).some(value =>
|
||||
String(value ?? '').toLowerCase().includes(term)
|
||||
)
|
||||
)
|
||||
}, [data, searchTerm])
|
||||
|
||||
// 格式化单元格值
|
||||
const formatCellValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return '-'
|
||||
if (typeof value === 'object') return JSON.stringify(value)
|
||||
return String(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 搜索栏 */}
|
||||
<div className="flex-shrink-0 mb-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-cyber-text-muted" />
|
||||
<Input
|
||||
placeholder={t('preview.searchPlaceholder')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9 h-9 text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
<ScrollArea className="flex-1 border border-cyber-border-DEFAULT rounded-lg">
|
||||
<div className="min-w-full">
|
||||
<table className="w-full text-xs font-mono">
|
||||
<thead className="sticky top-0 bg-cyber-bg-tertiary border-b border-cyber-border-DEFAULT">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-cyber-text-muted w-12">#</th>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col}
|
||||
className="px-3 py-2 text-left text-cyber-neon-cyan whitespace-nowrap"
|
||||
>
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredData.map((row, idx) => (
|
||||
<tr
|
||||
key={idx}
|
||||
className="border-b border-cyber-border-subtle hover:bg-cyber-bg-elevated/50 transition-colors"
|
||||
>
|
||||
<td className="px-3 py-2 text-cyber-text-muted">{idx + 1}</td>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col}
|
||||
className="px-3 py-2 text-cyber-text-primary max-w-xs truncate"
|
||||
title={formatCellValue(row[col])}
|
||||
>
|
||||
{formatCellValue(row[col])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 过滤结果提示 */}
|
||||
{searchTerm && (
|
||||
<div className="flex-shrink-0 mt-2 text-xs text-cyber-text-muted font-mono">
|
||||
{t('preview.showing', { filtered: filteredData.length, total: data.length })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user