import { useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Bell, CheckCheck } from "lucide-react"; import { api, type NotificationItem } from "../shared/api/client"; import { useMe } from "../features/auth/useAuth"; import { formatRelativeTime } from "../shared/lib/format"; function summary(n: NotificationItem): string { const c = n.payload.counts; const total = c.critical + c.high + c.medium + c.low; if (n.payload.no_value) return "Scan completed — no auditable content found"; if (total === 0) return "Scan completed — no findings"; const parts: string[] = []; if (c.critical) parts.push(`${c.critical} critical`); if (c.high) parts.push(`${c.high} high`); if (c.medium) parts.push(`${c.medium} medium`); if (c.low) parts.push(`${c.low} low`); return `Scan completed — ${parts.join(" · ")}`; } /** 站内通知铃铛(task-78c9fb3a):未读红点 + 下拉列表,点击跳项目页并标已读。60s 轮询。 */ export function NotificationBell({ appearance = "light" }: { appearance?: "light" | "dark" }) { void appearance; const meQ = useMe(); const authed = meQ.data?.authenticated === true; const nav = useNavigate(); const qc = useQueryClient(); const [open, setOpen] = useState(false); const rootRef = useRef(null); const listQ = useQuery({ queryKey: ["notifications"], queryFn: () => api.notifications(20), enabled: authed, refetchInterval: 60_000, refetchOnWindowFocus: true, retry: false, }); const invalidate = () => qc.invalidateQueries({ queryKey: ["notifications"] }); const readM = useMutation({ mutationFn: api.markNotificationsRead, onSettled: invalidate }); const readAllM = useMutation({ mutationFn: api.markAllNotificationsRead, onSettled: invalidate }); useEffect(() => { if (!open) return; const onDocDown = (e: MouseEvent) => { if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); }; document.addEventListener("mousedown", onDocDown); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDocDown); document.removeEventListener("keydown", onKey); }; }, [open]); if (!authed) return null; const items = listQ.data?.notifications ?? []; const unread = listQ.data?.unread_count ?? 0; const openItem = (n: NotificationItem) => { setOpen(false); if (!n.read_at) readM.mutate([n.id]); const [owner, repo] = n.payload.full_name.split("/"); if (owner && repo) nav(`/p/${owner}/${repo}`); }; return (
{open && (
Notifications {unread > 0 && ( )}
    {listQ.isPending ? (
  • Loading…
  • ) : items.length === 0 ? (
  • No notifications yet
  • ) : ( items.map((n) => { const isUnread = !n.read_at; return (
  • ); }) )}
)}
); }