NAC_Blockchain/services/nac-data-crawler/client/src/pages/Crawlers.tsx

198 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}