198 lines
11 KiB
TypeScript
198 lines
11 KiB
TypeScript
import { useState } from "react";
|
||
import { trpc } from "@/lib/trpc";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||
import { toast } from "sonner";
|
||
import { Plus, Play, RefreshCw, Activity, Clock, CheckCircle2, XCircle, Wifi, WifiOff } from "lucide-react";
|
||
|
||
const JURISDICTIONS = ["CN", "HK", "US", "EU", "SG", "AE", "ALL"];
|
||
const CATEGORIES = ["regulation", "trade_rule", "credit", "asset_document", "court_judgment", "tax_rule"];
|
||
const FREQUENCIES = ["realtime", "hourly", "daily", "weekly", "monthly"];
|
||
|
||
export default function Crawlers() {
|
||
const [createOpen, setCreateOpen] = useState(false);
|
||
const [form, setForm] = useState({ name: "", jurisdiction: "", type: "external" as "internal" | "external", source: "", category: "", frequency: "daily" });
|
||
const [selectedCrawler, setSelectedCrawler] = useState<string | null>(null);
|
||
|
||
const utils = trpc.useUtils();
|
||
const { data: crawlers, isLoading, refetch } = trpc.crawler.list.useQuery();
|
||
const { data: logs } = trpc.crawler.logs.useQuery({ crawlerId: selectedCrawler || undefined, limit: 30 });
|
||
|
||
const triggerMutation = trpc.crawler.trigger.useMutation({
|
||
onSuccess: (data) => { toast.success((data as any).message); utils.crawler.list.invalidate(); utils.crawler.logs.invalidate(); },
|
||
onError: (e) => toast.error(e.message),
|
||
});
|
||
|
||
const createMutation = trpc.crawler.create.useMutation({
|
||
onSuccess: () => { toast.success("采集器创建成功"); setCreateOpen(false); utils.crawler.list.invalidate(); setForm({ name: "", jurisdiction: "", type: "external", source: "", category: "", frequency: "daily" }); },
|
||
onError: (e) => toast.error(e.message),
|
||
});
|
||
|
||
const crawlerList = (crawlers as any[]) || [];
|
||
const logList = (logs as any[]) || [];
|
||
|
||
return (
|
||
<div className="p-6 space-y-5">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold nac-gradient-text">采集器监控</h1>
|
||
<p className="text-sm text-muted-foreground mt-0.5">管理内部/外部数据采集通道</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button variant="outline" size="sm" onClick={() => refetch()} className="border-border/50">
|
||
<RefreshCw className="w-4 h-4 mr-1.5" />刷新
|
||
</Button>
|
||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||
<DialogTrigger asChild>
|
||
<Button size="sm" className="nac-gradient text-white"><Plus className="w-4 h-4 mr-1.5" />新增采集器</Button>
|
||
</DialogTrigger>
|
||
<DialogContent className="bg-card border-border/50 max-w-lg">
|
||
<DialogHeader><DialogTitle>新增采集器</DialogTitle></DialogHeader>
|
||
<div className="space-y-4 mt-2">
|
||
<div className="space-y-1.5">
|
||
<Label className="text-xs">采集器名称</Label>
|
||
<Input value={form.name} onChange={(e) => setForm(f => ({ ...f, name: e.target.value }))} className="bg-input border-border/50 h-9" placeholder="例:JP-FSA法规采集器" />
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="space-y-1.5">
|
||
<Label className="text-xs">司法辖区</Label>
|
||
<Select value={form.jurisdiction} onValueChange={(v) => setForm(f => ({ ...f, jurisdiction: v }))}>
|
||
<SelectTrigger className="bg-input border-border/50 h-9"><SelectValue placeholder="选择辖区" /></SelectTrigger>
|
||
<SelectContent className="bg-card border-border/50">
|
||
{JURISDICTIONS.map(j => <SelectItem key={j} value={j}>{j}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label className="text-xs">采集类型</Label>
|
||
<Select value={form.type} onValueChange={(v: any) => setForm(f => ({ ...f, type: v }))}>
|
||
<SelectTrigger className="bg-input border-border/50 h-9"><SelectValue /></SelectTrigger>
|
||
<SelectContent className="bg-card border-border/50">
|
||
<SelectItem value="external">外部采集</SelectItem>
|
||
<SelectItem value="internal">内部监听</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label className="text-xs">数据源 URL</Label>
|
||
<Input value={form.source} onChange={(e) => setForm(f => ({ ...f, source: e.target.value }))} className="bg-input border-border/50 h-9" placeholder="https://www.fsa.go.jp" />
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="space-y-1.5">
|
||
<Label className="text-xs">数据分类</Label>
|
||
<Select value={form.category} onValueChange={(v) => setForm(f => ({ ...f, category: v }))}>
|
||
<SelectTrigger className="bg-input border-border/50 h-9"><SelectValue placeholder="选择分类" /></SelectTrigger>
|
||
<SelectContent className="bg-card border-border/50">
|
||
{CATEGORIES.map(c => <SelectItem key={c} value={c}>{c}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label className="text-xs">采集频率</Label>
|
||
<Select value={form.frequency} onValueChange={(v) => setForm(f => ({ ...f, frequency: v }))}>
|
||
<SelectTrigger className="bg-input border-border/50 h-9"><SelectValue /></SelectTrigger>
|
||
<SelectContent className="bg-card border-border/50">
|
||
{FREQUENCIES.map(f => <SelectItem key={f} value={f}>{f}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
<Button className="w-full nac-gradient text-white" onClick={() => createMutation.mutate(form)} disabled={createMutation.isPending || !form.name || !form.jurisdiction || !form.source || !form.category}>
|
||
{createMutation.isPending ? "创建中..." : "创建采集器"}
|
||
</Button>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||
{/* Crawler List */}
|
||
<div className="lg:col-span-2 space-y-3">
|
||
{isLoading ? (
|
||
<div className="p-8 text-center text-muted-foreground text-sm">加载中...</div>
|
||
) : crawlerList.map((crawler: any) => (
|
||
<Card
|
||
key={crawler._id?.toString()}
|
||
className={`border-border/50 cursor-pointer transition-all hover:border-primary/30 ${selectedCrawler === crawler._id?.toString() ? "border-primary/50 bg-primary/5" : ""}`}
|
||
onClick={() => setSelectedCrawler(crawler._id?.toString())}
|
||
>
|
||
<CardContent className="p-4">
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
{crawler.type === "internal" ? <Wifi className="w-3.5 h-3.5 text-primary" /> : <Activity className="w-3.5 h-3.5 text-amber-400" />}
|
||
<p className="font-medium text-sm truncate">{crawler.name}</p>
|
||
<Badge variant="outline" className="text-xs border-border/50 shrink-0">{crawler.jurisdiction}</Badge>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground truncate">{crawler.source}</p>
|
||
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
|
||
<span className="flex items-center gap-1"><Clock className="w-3 h-3" />{crawler.frequency}</span>
|
||
<span className="flex items-center gap-1"><CheckCircle2 className="w-3 h-3 text-emerald-400" />{crawler.successRate}%</span>
|
||
<span>采集 {crawler.totalCollected} 条</span>
|
||
{crawler.lastRun && <span>最后运行: {new Date(crawler.lastRun).toLocaleDateString("zh-CN")}</span>}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2 ml-3 shrink-0">
|
||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium status-${crawler.status}`}>
|
||
{crawler.status === "active" ? "运行中" : "已停止"}
|
||
</span>
|
||
<Button
|
||
size="sm" variant="outline"
|
||
className="h-7 px-2 border-border/50 hover:border-primary/50"
|
||
onClick={(e) => { e.stopPropagation(); triggerMutation.mutate({ crawlerId: crawler._id.toString() }); }}
|
||
disabled={triggerMutation.isPending}
|
||
>
|
||
<Play className="w-3 h-3 mr-1" />触发
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
|
||
{/* Logs Panel */}
|
||
<Card className="border-border/50">
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||
<Activity className="w-4 h-4 text-primary" />
|
||
{selectedCrawler ? "采集日志" : "选择采集器查看日志"}
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{logList.length === 0 ? (
|
||
<div className="h-60 flex items-center justify-center text-muted-foreground text-sm">
|
||
{selectedCrawler ? "暂无日志记录" : "点击左侧采集器查看日志"}
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2 max-h-[500px] overflow-y-auto">
|
||
{logList.map((log: any, i: number) => (
|
||
<div key={i} className="border-b border-border/20 pb-2 last:border-0">
|
||
<div className="flex items-center gap-1.5 mb-0.5">
|
||
{log.status === "triggered" ? <Play className="w-3 h-3 text-primary" /> :
|
||
log.status === "success" ? <CheckCircle2 className="w-3 h-3 text-emerald-400" /> :
|
||
<XCircle className="w-3 h-3 text-red-400" />}
|
||
<span className="text-xs font-medium">{log.action}</span>
|
||
</div>
|
||
<p className="text-xs text-muted-foreground">{log.message}</p>
|
||
<p className="text-xs text-muted-foreground/50 mt-0.5">
|
||
{new Date(log.timestamp).toLocaleString("zh-CN")}
|
||
</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|