- 替换 react-simple-maps/d3-geo/topojson-client 为 react-map-gl + maplibre-gl - 使用 CARTO dark-matter 免费暗色瓦片,自带国家/城市名标注 - 基于 locale 动态切换地图标注语言(name:zh / name_en) - MapLibre 原生 heatmap + circle 双层渲染替代 SVG 热力图 - 提取 MapPopup 组件,配合 react-map-gl Popup 实现点击弹窗 - continent page 改为 dynamic import (ssr: false) - dev 模式去掉 Turbopack 以兼容 maplibre-gl - 删除 heatmap-layer.tsx 和 react-simple-maps 类型声明
197 lines
6.7 KiB
TypeScript
197 lines
6.7 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useCallback } from "react";
|
|
import { useTranslations, useLocale } from "next-intl";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { useSSE } from "@/hooks/use-sse";
|
|
|
|
interface FeedItem {
|
|
id: string;
|
|
type: "task" | "online" | "offline" | "registered";
|
|
clawName: string;
|
|
city?: string;
|
|
country?: string;
|
|
summary?: string;
|
|
durationMs?: number;
|
|
timestamp: number;
|
|
}
|
|
|
|
export function ClawFeed() {
|
|
const t = useTranslations("clawFeed");
|
|
const locale = useLocale();
|
|
const [items, setItems] = useState<FeedItem[]>([]);
|
|
|
|
const handleEvent = useCallback((event: { type: string; data: Record<string, unknown> }) => {
|
|
if (event.type === "task" || event.type === "online" || event.type === "offline" || event.type === "registered") {
|
|
const newItem: FeedItem = {
|
|
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
type: event.type as FeedItem["type"],
|
|
clawName: (event.data.clawName as string) ?? "Unknown",
|
|
city: event.data.city as string | undefined,
|
|
country: event.data.country as string | undefined,
|
|
summary: event.data.summary as string | undefined,
|
|
durationMs: event.data.durationMs as number | undefined,
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
setItems((prev) => [newItem, ...prev].slice(0, 50));
|
|
}
|
|
}, []);
|
|
|
|
useSSE({
|
|
url: "/api/v1/stream",
|
|
onEvent: handleEvent,
|
|
});
|
|
|
|
// Load initial recent tasks + newest registrations
|
|
useEffect(() => {
|
|
const fetchRecent = async () => {
|
|
try {
|
|
const [taskRes, newestRes] = await Promise.all([
|
|
fetch("/api/v1/claws?limit=10"),
|
|
fetch("/api/v1/claws?sort=newest&limit=5"),
|
|
]);
|
|
|
|
const feedItems: FeedItem[] = [];
|
|
|
|
if (taskRes.ok) {
|
|
const data = await taskRes.json();
|
|
const taskItems: FeedItem[] = (data.claws ?? [])
|
|
.filter((l: Record<string, unknown>) => l.lastTask)
|
|
.map((l: Record<string, unknown>) => {
|
|
const task = l.lastTask as Record<string, unknown>;
|
|
return {
|
|
id: `init-task-${l.id}`,
|
|
type: "task" as const,
|
|
clawName: l.name as string,
|
|
city: l.city as string,
|
|
country: l.country as string,
|
|
summary: task.summary as string,
|
|
durationMs: task.durationMs as number,
|
|
timestamp: new Date(task.timestamp as string).getTime(),
|
|
};
|
|
});
|
|
feedItems.push(...taskItems);
|
|
}
|
|
|
|
if (newestRes.ok) {
|
|
const data = await newestRes.json();
|
|
const regItems: FeedItem[] = (data.claws ?? [])
|
|
.filter((l: Record<string, unknown>) => l.createdAt)
|
|
.map((l: Record<string, unknown>) => ({
|
|
id: `init-reg-${l.id}`,
|
|
type: "registered" as const,
|
|
clawName: l.name as string,
|
|
city: l.city as string,
|
|
country: l.country as string,
|
|
timestamp: new Date(l.createdAt as string).getTime(),
|
|
}));
|
|
feedItems.push(...regItems);
|
|
}
|
|
|
|
// Deduplicate by clawName+type, sort by timestamp desc
|
|
const seen = new Set<string>();
|
|
const unique = feedItems.filter((item) => {
|
|
const key = `${item.clawName}-${item.type}`;
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
unique.sort((a, b) => b.timestamp - a.timestamp);
|
|
|
|
setItems(unique);
|
|
} catch {
|
|
// will populate via SSE
|
|
}
|
|
};
|
|
fetchRecent();
|
|
}, []);
|
|
|
|
const formatDuration = (ms?: number) => {
|
|
if (!ms) return "";
|
|
if (ms < 1000) return `${ms}ms`;
|
|
return `${(ms / 1000).toFixed(1)}s`;
|
|
};
|
|
|
|
const getIcon = (type: string) => {
|
|
switch (type) {
|
|
case "task":
|
|
return "⚡";
|
|
case "online":
|
|
return "🟢";
|
|
case "offline":
|
|
return "⭕";
|
|
case "registered":
|
|
return "🦞";
|
|
default:
|
|
return "🦞";
|
|
}
|
|
};
|
|
|
|
const getDescription = (item: FeedItem) => {
|
|
if (item.type === "registered" && item.city && item.country) {
|
|
return t("joinedFrom", { city: item.city, country: item.country });
|
|
}
|
|
return item.summary;
|
|
};
|
|
|
|
return (
|
|
<Card className="border-white/5">
|
|
<CardHeader className="pb-2">
|
|
<CardTitle>{t("title")}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="max-h-[400px] overflow-y-auto p-4 pt-0">
|
|
<AnimatePresence initial={false}>
|
|
{items.length === 0 ? (
|
|
<p className="py-8 text-center text-xs text-[var(--text-muted)]">
|
|
{t("waiting")}
|
|
</p>
|
|
) : (
|
|
items.map((item) => (
|
|
<motion.div
|
|
key={item.id}
|
|
initial={{ opacity: 0, height: 0, y: -10 }}
|
|
animate={{ opacity: 1, height: "auto", y: 0 }}
|
|
exit={{ opacity: 0, height: 0 }}
|
|
transition={{ duration: 0.3 }}
|
|
className="border-b border-white/5 py-2.5 last:border-0"
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<span className="mt-0.5 text-sm">{getIcon(item.type)}</span>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-xs font-medium text-[var(--accent-cyan)]">
|
|
{item.clawName}
|
|
</span>
|
|
{item.city && item.type !== "registered" && (
|
|
<span className="text-xs text-[var(--text-muted)]">
|
|
{item.city}, {item.country}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{getDescription(item) && (
|
|
<p className="mt-0.5 truncate text-xs text-[var(--text-secondary)]">
|
|
{getDescription(item)}
|
|
</p>
|
|
)}
|
|
<div className="mt-1 flex items-center gap-2">
|
|
{item.durationMs && (
|
|
<Badge variant="secondary">{formatDuration(item.durationMs)}</Badge>
|
|
)}
|
|
<span className="text-[10px] text-[var(--text-muted)]">
|
|
{new Date(item.timestamp).toLocaleTimeString(locale)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
))
|
|
)}
|
|
</AnimatePresence>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|