geo/frontend/app/(dashboard)/dashboard/monitoring/page.tsx

660 lines
23 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.

"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { useApi } from "@/lib/hooks/use-api";
import { monitoringApi, MonitoringRecord, MonitoringChangeReport } from "@/lib/api/monitoring";
import { alertsApi } from "@/lib/api/alerts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { LoadingState, ErrorState, EmptyState } from "@/components/ui/api-states";
import {
Bell,
BellRing,
AlertTriangle,
CheckCheck,
Settings,
Clock,
Filter,
Activity,
Play,
Pause,
RefreshCw,
ChevronDown,
ChevronUp,
Eye,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface Alert {
id: string;
title: string;
description?: string;
type: string;
severity: "critical" | "warning" | "info";
is_read: boolean;
created_at: string;
brand_id?: string;
}
interface AlertsResponse {
items: Alert[];
total: number;
}
interface UnreadCountResponse {
unread_count: number;
}
interface BrandsResponse {
items: { id: string; name: string }[];
}
function formatTimeAgo(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "刚刚";
if (diffMins < 60) return `${diffMins}分钟前`;
if (diffHours < 24) return `${diffHours}小时前`;
if (diffDays < 7) return `${diffDays}天前`;
return date.toLocaleDateString("zh-CN");
}
function ChangeTypeBadge({ changeType }: { changeType: string | null }) {
if (!changeType) return null;
const config: Record<string, { variant: "default" | "destructive" | "secondary" | "outline"; label: string }> = {
positive: { variant: "default", label: "正向" },
negative: { variant: "destructive", label: "负向" },
neutral: { variant: "secondary", label: "中性" },
};
const c = config[changeType] || { variant: "outline" as const, label: changeType };
return <Badge variant={c.variant} className="text-xs">{c.label}</Badge>;
}
function RecordCard({
record,
token,
onRefresh,
}: {
record: MonitoringRecord;
token: string;
onRefresh: () => void;
}) {
const [expanded, setExpanded] = React.useState(false);
const [report, setReport] = React.useState<MonitoringChangeReport | null>(null);
const [reportLoading, setReportLoading] = React.useState(false);
const [reportError, setReportError] = React.useState<string | null>(null);
const [checking, setChecking] = React.useState(false);
const [togglingStatus, setTogglingStatus] = React.useState(false);
const handleExpand = async () => {
if (expanded) {
setExpanded(false);
return;
}
setExpanded(true);
if (!report) {
setReportLoading(true);
setReportError(null);
try {
const result = await monitoringApi.getReport(token, record.id);
setReport(result);
} catch (err) {
setReportError(err instanceof Error ? err.message : "加载报告失败");
} finally {
setReportLoading(false);
}
}
};
const handleTriggerCheck = async () => {
setChecking(true);
try {
await monitoringApi.triggerCheck(token, record.id);
onRefresh();
} catch {
} finally {
setChecking(false);
}
};
const handleToggleStatus = async () => {
setTogglingStatus(true);
try {
const newStatus = record.status === "active" ? "paused" : "active";
await monitoringApi.updateStatus(token, record.id, { status: newStatus });
onRefresh();
} catch {
} finally {
setTogglingStatus(false);
}
};
return (
<Card>
<CardContent className="p-0">
<div
className="flex items-center gap-4 px-6 py-4 cursor-pointer hover:bg-muted/30 transition-colors"
onClick={handleExpand}
>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50 text-blue-600 shrink-0">
<Activity className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-foreground truncate">
{record.platform || "全平台"}
</p>
<ChangeTypeBadge changeType={record.change_type} />
{record.status === "paused" && (
<Badge variant="outline" className="text-xs"></Badge>
)}
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{record.last_checked_at ? formatTimeAgo(record.last_checked_at) : "未检测"}
</span>
<span>: {record.query_keywords}</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-xs"
onClick={(e) => { e.stopPropagation(); handleTriggerCheck(); }}
disabled={checking}
>
<RefreshCw className={cn("mr-1 h-3.5 w-3.5", checking && "animate-spin")} />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-xs"
onClick={(e) => { e.stopPropagation(); handleToggleStatus(); }}
disabled={togglingStatus}
>
{record.status === "active" ? (
<><Pause className="mr-1 h-3.5 w-3.5" /></>
) : (
<><Play className="mr-1 h-3.5 w-3.5" /></>
)}
</Button>
{expanded ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</div>
</div>
{expanded && (
<div className="border-t px-6 py-4 space-y-4">
{reportLoading && <LoadingState rows={2} rowHeight="h-16" />}
{reportError && <ErrorState error={reportError} onRetry={handleExpand} />}
{report && !reportLoading && !reportError && (
<>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">线</p>
<div className="rounded-lg border p-3 space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{String(report.baseline.citation_count ?? "—")}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{report.baseline.sentiment != null ? String(report.baseline.sentiment) : "—"}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{report.baseline.rank != null ? String(report.baseline.rank) : "—"}</span>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground"></p>
<div className="rounded-lg border p-3 space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{String(report.current.citation_count ?? "—")}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{report.current.sentiment != null ? String(report.current.sentiment) : "—"}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"></span>
<span className="font-medium">{report.current.rank != null ? String(report.current.rank) : "—"}</span>
</div>
</div>
</div>
</div>
{report.recommendations && report.recommendations.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground"></p>
<ul className="space-y-1">
{report.recommendations.map((rec, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<span className="mt-1.5 h-1.5 w-1.5 rounded-full bg-blue-500 shrink-0" />
<span>{rec}</span>
</li>
))}
</ul>
</div>
)}
</>
)}
</div>
)}
</CardContent>
</Card>
);
}
function MonitoringRecordsTab() {
const { data: session } = useSession();
const token = session?.accessToken || "";
const { data: brandsData } = useApi<BrandsResponse>("/api/v1/brands/?limit=1");
const brandId = brandsData?.items?.[0]?.id ?? null;
const {
data: recordsData,
isLoading,
error,
refresh,
} = useApi<{ records: MonitoringRecord[]; total: number }>(
token && brandId ? `/api/v1/monitoring/brand/${brandId}` : null
);
const records = recordsData?.records ?? [];
if (isLoading) {
return <LoadingState rows={4} rowHeight="h-24" />;
}
if (error) {
return <ErrorState error={error} onRetry={refresh} />;
}
if (records.length === 0) {
return (
<EmptyState
icon={<Activity className="h-6 w-6 text-muted-foreground" />}
message="暂无监测记录"
description="创建监测任务后将在此显示监测结果"
/>
);
}
return (
<div className="space-y-4">
{records.map((record) => (
<RecordCard
key={record.id}
record={record}
token={token}
onRefresh={refresh}
/>
))}
</div>
);
}
interface StatCardProps {
title: string;
value: number | string;
icon: React.ReactNode;
trend?: string;
color: "emerald" | "amber" | "red" | "blue";
}
function StatCard({ title, value, icon, trend, color }: StatCardProps) {
const colorMap = {
emerald: { bg: "bg-emerald-50", icon: "text-emerald-600", border: "border-emerald-100" },
amber: { bg: "bg-amber-50", icon: "text-amber-600", border: "border-amber-100" },
red: { bg: "bg-red-50", icon: "text-red-600", border: "border-red-100" },
blue: { bg: "bg-blue-50", icon: "text-blue-600", border: "border-blue-100" },
};
const colors = colorMap[color];
return (
<Card className={cn("border", colors.border)}>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">{title}</p>
<p className="text-2xl font-bold tracking-tight">{value}</p>
{trend && <p className="text-xs text-muted-foreground">{trend}</p>}
</div>
<div className={cn("flex h-12 w-12 items-center justify-center rounded-lg", colors.bg)}>
<div className={colors.icon}>{icon}</div>
</div>
</div>
</CardContent>
</Card>
);
}
interface AlertRowProps {
alert: Alert;
onMarkRead: (id: string) => void;
isMutating: boolean;
}
function AlertRow({ alert, onMarkRead, isMutating }: AlertRowProps) {
const severityConfig = {
critical: { badge: "destructive", label: "严重", icon: <AlertTriangle className="h-4 w-4" /> },
warning: { badge: "default", label: "警告", icon: <BellRing className="h-4 w-4" /> },
info: { badge: "secondary", label: "信息", icon: <Bell className="h-4 w-4" /> },
};
const typeMap: Record<string, string> = {
citation_drop: "引用下降",
ranking_drop: "排名下降",
new_competitor: "新竞争对手",
content_update: "内容更新",
system: "系统通知",
};
const config = severityConfig[alert.severity];
const timeAgo = formatTimeAgo(alert.created_at);
return (
<div
className={cn(
"group flex items-start gap-4 px-6 py-4 transition-colors hover:bg-muted/30",
!alert.is_read && "bg-muted/20"
)}
>
<div className="mt-0.5 shrink-0">
<div
className={cn(
"flex h-9 w-9 items-center justify-center rounded-lg",
alert.severity === "critical" && "bg-red-50 text-red-600",
alert.severity === "warning" && "bg-amber-50 text-amber-600",
alert.severity === "info" && "bg-blue-50 text-blue-600"
)}
>
{config.icon}
</div>
</div>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-foreground leading-snug">
{alert.title}
</p>
{!alert.is_read && (
<span className="flex h-2 w-2 rounded-full bg-red-500" />
)}
</div>
<span className="shrink-0 text-xs text-muted-foreground">{timeAgo}</span>
</div>
{alert.description && (
<p className="text-xs text-muted-foreground leading-snug">{alert.description}</p>
)}
<div className="flex items-center gap-2 pt-1">
<Badge variant={config.badge as "destructive" | "default" | "secondary"} className="text-xs">{config.label}</Badge>
<Badge variant="outline" className="text-xs">{typeMap[alert.type] || alert.type}</Badge>
</div>
<div className="flex items-center gap-2 pt-2">
{!alert.is_read && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => onMarkRead(alert.id)}
disabled={isMutating}
>
<CheckCheck className="mr-1 h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
</div>
);
}
function AlertsTab() {
const [filterType, setFilterType] = React.useState<string>("all");
const [filterSeverity, setFilterSeverity] = React.useState<string>("all");
const [filterRead, setFilterRead] = React.useState<string>("all");
const {
data: alertsData,
isLoading: alertsLoading,
error: alertsError,
refresh: refreshAlerts,
} = useApi<AlertsResponse>("/api/v1/alerts?limit=50");
const {
data: unreadData,
isLoading: unreadLoading,
refresh: refreshUnread,
} = useApi<UnreadCountResponse>("/api/v1/alerts/unread-count");
const [mutatingId, setMutatingId] = React.useState<string | null>(null);
const [mutatingAll, setMutatingAll] = React.useState(false);
const handleMarkRead = async (alertId: string) => {
try {
setMutatingId(alertId);
await alertsApi.markRead(alertId);
refreshAlerts();
refreshUnread();
} finally {
setMutatingId(null);
}
};
const handleMarkAllRead = async () => {
try {
setMutatingAll(true);
await alertsApi.markAllRead();
refreshAlerts();
refreshUnread();
} finally {
setMutatingAll(false);
}
};
const alerts = alertsData?.items ?? [];
const unreadCount = unreadData?.unread_count ?? 0;
const filteredAlerts = alerts.filter((alert) => {
if (filterType !== "all" && alert.type !== filterType) return false;
if (filterSeverity !== "all" && alert.severity !== filterSeverity) return false;
if (filterRead === "unread" && alert.is_read) return false;
if (filterRead === "read" && !alert.is_read) return false;
return true;
});
const criticalCount = alerts.filter((a) => a.severity === "critical").length;
const todayCount = alerts.filter((a) => {
const date = new Date(a.created_at);
const today = new Date();
return date.toDateString() === today.toDateString();
}).length;
const processedCount = alerts.filter((a) => a.is_read).length;
const uniqueTypes = Array.from(new Set(alerts.map((a) => a.type)));
if (alertsLoading || unreadLoading) {
return <LoadingState rows={6} rowHeight="h-20" />;
}
if (alertsError) {
return <ErrorState error={alertsError} onRetry={refreshAlerts} />;
}
return (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard title="未读告警" value={unreadCount} icon={<Bell className="h-6 w-6" />} trend="需要处理" color="blue" />
<StatCard title="严重告警" value={criticalCount} icon={<AlertTriangle className="h-6 w-6" />} trend="高优先级" color="red" />
<StatCard title="今日新增" value={todayCount} icon={<Clock className="h-6 w-6" />} trend="今天" color="amber" />
<StatCard title="已处理" value={processedCount} icon={<CheckCheck className="h-6 w-6" />} trend="已读" color="emerald" />
</div>
<Card>
<CardHeader className="pb-3">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="flex items-center gap-2">
<BellRing className="h-5 w-5" />
{alerts.length > 0 && (
<Badge variant="secondary" className="ml-2">{alerts.length}</Badge>
)}
</CardTitle>
{unreadCount > 0 && (
<Button variant="outline" size="sm" onClick={handleMarkAllRead} disabled={mutatingAll}>
<CheckCheck className="mr-1 h-4 w-4" />
</Button>
)}
</div>
<div className="flex flex-wrap items-center gap-2 pt-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<Select value={filterRead} onValueChange={setFilterRead}>
<SelectTrigger className="h-8 w-[120px]">
<SelectValue placeholder="已读状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="unread"></SelectItem>
<SelectItem value="read"></SelectItem>
</SelectContent>
</Select>
<Select value={filterSeverity} onValueChange={setFilterSeverity}>
<SelectTrigger className="h-8 w-[120px]">
<SelectValue placeholder="严重程度" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="critical"></SelectItem>
<SelectItem value="warning"></SelectItem>
<SelectItem value="info"></SelectItem>
</SelectContent>
</Select>
{uniqueTypes.length > 0 && (
<Select value={filterType} onValueChange={setFilterType}>
<SelectTrigger className="h-8 w-[140px]">
<SelectValue placeholder="告警类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{uniqueTypes.map((type) => (
<SelectItem key={type} value={type}>
{{
citation_drop: "引用下降",
ranking_drop: "排名下降",
new_competitor: "新竞争对手",
content_update: "内容更新",
system: "系统通知",
}[type] || type}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</CardHeader>
<CardContent className="p-0">
{filteredAlerts.length === 0 ? (
<EmptyState
icon={<Bell className="h-6 w-6 text-muted-foreground" />}
message="暂无告警"
description="当前没有符合条件的告警记录"
/>
) : (
<div className="divide-y">
{filteredAlerts.map((alert) => (
<AlertRow
key={alert.id}
alert={alert}
onMarkRead={handleMarkRead}
isMutating={mutatingId === alert.id}
/>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}
export default function MonitoringPage() {
const router = useRouter();
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground">
AI可见性
</p>
</div>
<Button
variant="outline"
onClick={() => router.push("/dashboard/monitoring/settings")}
>
<Settings className="mr-2 h-4 w-4" />
</Button>
</div>
<Tabs defaultValue="records">
<TabsList>
<TabsTrigger value="records">
<Eye className="mr-1.5 h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="alerts">
<Bell className="mr-1.5 h-4 w-4" />
</TabsTrigger>
</TabsList>
<TabsContent value="records">
<MonitoringRecordsTab />
</TabsContent>
<TabsContent value="alerts">
<AlertsTab />
</TabsContent>
</Tabs>
</div>
);
}