660 lines
23 KiB
TypeScript
660 lines
23 KiB
TypeScript
"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>
|
||
);
|
||
}
|