geo/frontend/components/layout/alert-bell.tsx

295 lines
9.8 KiB
TypeScript
Raw Permalink 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 { useCallback, useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { Bell, Check, AlertTriangle, TrendingDown, TrendingUp, Users, Globe } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { api } from "@/lib/api";
// ============================================================
// 类型定义
// ============================================================
interface AlertItem {
id: string;
brand_id: string;
user_id: string;
alert_type: string;
severity: string;
title: string;
message: string;
data: Record<string, unknown> | null;
is_read: boolean;
created_at: string;
}
interface AlertListResponse {
items: AlertItem[];
total: number;
}
interface UnreadCountResponse {
unread_count: number;
}
// ============================================================
// 告警类型配置
// ============================================================
const ALERT_TYPE_CONFIG: Record<string, { label: string; icon: React.ElementType; color: string }> = {
score_drop: { label: "评分下降", icon: TrendingDown, color: "text-red-500" },
score_rise: { label: "评分上升", icon: TrendingUp, color: "text-emerald-500" },
negative_sentiment: { label: "负面情感", icon: AlertTriangle, color: "text-orange-500" },
competitor_overtake: { label: "竞品超越", icon: Users, color: "text-amber-500" },
new_platform_mention: { label: "新平台提及", icon: Globe, color: "text-blue-500" },
};
const SEVERITY_CONFIG: Record<string, { label: string; className: string }> = {
critical: { label: "严重", className: "bg-red-100 text-red-700 border-red-200" },
warning: { label: "警告", className: "bg-orange-100 text-orange-700 border-orange-200" },
info: { label: "信息", className: "bg-blue-100 text-blue-700 border-blue-200" },
};
// ============================================================
// 工具函数
// ============================================================
function formatTimeAgo(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMinutes = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMinutes < 1) return "刚刚";
if (diffMinutes < 60) return `${diffMinutes}分钟前`;
if (diffHours < 24) return `${diffHours}小时前`;
if (diffDays < 7) return `${diffDays}天前`;
return date.toLocaleDateString("zh-CN");
}
// ============================================================
// 通知铃铛组件
// ============================================================
export function AlertBell() {
const { data: session } = useSession();
const [unreadCount, setUnreadCount] = useState(0);
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false);
const token = session?.accessToken;
// 获取未读数量
const fetchUnreadCount = useCallback(async () => {
if (!token) return;
try {
const data = (await api.alerts.getUnreadCount(token)) as UnreadCountResponse;
setUnreadCount(data.unread_count);
} catch {
// 静默失败
}
}, [token]);
// 获取告警列表
const fetchAlerts = useCallback(async () => {
if (!token) return;
setLoading(true);
try {
const data = (await api.alerts.getAlerts(token, {
limit: 20,
})) as AlertListResponse;
setAlerts(data.items);
} catch {
// 静默失败
} finally {
setLoading(false);
}
}, [token]);
// 标记单条已读
const handleMarkRead = async (alertId: string) => {
if (!token) return;
try {
await api.alerts.markRead(alertId, token);
setAlerts((prev) =>
prev.map((a) => (a.id === alertId ? { ...a, is_read: true } : a)),
);
setUnreadCount((prev) => Math.max(0, prev - 1));
} catch {
// 静默失败
}
};
// 全部标记已读
const handleMarkAllRead = async () => {
if (!token) return;
try {
await api.alerts.markAllRead(token);
setAlerts((prev) => prev.map((a) => ({ ...a, is_read: true })));
setUnreadCount(0);
} catch {
// 静默失败
}
};
// 打开面板时加载告警列表
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
if (open) {
fetchAlerts();
}
};
// 轮询检查新告警每60秒
useEffect(() => {
fetchUnreadCount();
const interval = setInterval(fetchUnreadCount, 60000);
return () => clearInterval(interval);
}, [fetchUnreadCount]);
return (
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<Badge
className="absolute -right-1 -top-1 flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-xs text-white"
style={{
backgroundColor:
unreadCount >= 5 ? "#ef4444" : unreadCount >= 2 ? "#f97316" : "#3b82f6",
}}
>
{unreadCount > 99 ? "99+" : unreadCount}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-96 p-0" align="end">
{/* 头部 */}
<div className="flex items-center justify-between border-b px-4 py-3">
<h3 className="text-sm font-semibold"></h3>
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
className="h-auto p-0 text-xs text-muted-foreground hover:text-foreground"
onClick={handleMarkAllRead}
>
</Button>
)}
</div>
{/* 告警列表 */}
<div className="max-h-96 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-8">
<p className="text-sm text-muted-foreground">...</p>
</div>
) : alerts.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8">
<Bell className="mb-2 h-8 w-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground"></p>
</div>
) : (
<div className="divide-y">
{alerts.map((alert) => {
const typeConfig = ALERT_TYPE_CONFIG[alert.alert_type] || {
label: alert.alert_type,
icon: Bell,
color: "text-gray-500",
};
const severityConfig = SEVERITY_CONFIG[alert.severity] || {
label: alert.severity,
className: "bg-gray-100 text-gray-700 border-gray-200",
};
const IconComponent = typeConfig.icon;
return (
<div
key={alert.id}
className={`flex gap-3 px-4 py-3 transition-colors hover:bg-muted/50 ${
!alert.is_read ? "bg-blue-50/50 dark:bg-blue-950/20" : ""
}`}
onClick={() => {
if (!alert.is_read) {
handleMarkRead(alert.id);
}
}}
>
{/* 图标 */}
<div className="mt-0.5 flex-shrink-0">
<IconComponent className={`h-5 w-5 ${typeConfig.color}`} />
</div>
{/* 内容 */}
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<p
className={`text-sm leading-tight ${
!alert.is_read ? "font-medium" : "text-muted-foreground"
}`}
>
{alert.title}
</p>
<span
className={`inline-flex flex-shrink-0 items-center rounded border px-1.5 py-0.5 text-[10px] font-medium ${
severityConfig.className
}`}
>
{severityConfig.label}
</span>
</div>
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{alert.message}
</p>
<p className="mt-1 text-[10px] text-muted-foreground/60">
{formatTimeAgo(alert.created_at)}
</p>
</div>
{/* 未读标记 */}
{!alert.is_read && (
<div className="mt-1.5 flex-shrink-0">
<div className="h-2 w-2 rounded-full bg-blue-500" />
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* 底部 */}
{alerts.length > 0 && (
<div className="border-t px-4 py-2">
<Button
variant="ghost"
size="sm"
className="w-full text-xs text-muted-foreground"
onClick={() => {
setIsOpen(false);
window.location.href = "/dashboard/settings";
}}
>
<Check className="mr-1 h-3 w-3" />
</Button>
</div>
)}
</PopoverContent>
</Popover>
);
}