refactor: 将 WebUI 源码纳入仓库,移除打包产物

- 新增 webui/ 前端源码目录(React+Vite+TypeScript+Tailwind)
- 删除 api/webui/ 中旧的打包静态资源
- 更新 .gitignore 忽略 api/webui/ 构建输出和 webui/node_modules
- 更新 README 增加前端构建说明
This commit is contained in:
程序员阿江(Relakkes)
2026-07-01 23:05:03 +08:00
parent 2ac0716c41
commit 65ddaa8828
68 changed files with 9064 additions and 356 deletions
+157
View File
@@ -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>
)
}
+132
View File
@@ -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>
)
}