295 lines
9.8 KiB
TypeScript
295 lines
9.8 KiB
TypeScript
"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(token, alertId);
|
||
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>
|
||
);
|
||
}
|