Spaces:
Running
Running
| 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<HTMLDivElement>(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 ( | |
| <div ref={rootRef} className="relative"> | |
| <button | |
| type="button" | |
| onClick={() => setOpen((v) => !v)} | |
| aria-expanded={open} | |
| aria-haspopup="menu" | |
| aria-label={unread > 0 ? `Notifications (${unread} unread)` : "Notifications"} | |
| className="relative flex h-9 w-9 items-center justify-center rounded-full border border-line bg-surface-raised text-ink-secondary transition-colors hover:border-line-strong hover:bg-surface-sunken hover:text-ink focus-ring" | |
| > | |
| <Bell size={15} /> | |
| {unread > 0 && ( | |
| <span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-danger px-1 text-[10px] font-semibold leading-none text-surface"> | |
| {unread > 9 ? "9+" : unread} | |
| </span> | |
| )} | |
| </button> | |
| {open && ( | |
| <div | |
| role="menu" | |
| className="absolute right-0 top-full z-40 mt-2 w-80 overflow-hidden rounded-xl border border-line bg-surface-raised shadow-[0_16px_48px_-12px_rgba(0,0,0,0.7)]" | |
| > | |
| <div className="flex items-center justify-between border-b border-line px-3.5 py-2.5"> | |
| <span className="text-[13px] font-medium text-ink">Notifications</span> | |
| {unread > 0 && ( | |
| <button | |
| type="button" | |
| onClick={() => readAllM.mutate()} | |
| disabled={readAllM.isPending} | |
| className="inline-flex items-center gap-1 text-[12px] text-ink-secondary hover:text-ink" | |
| > | |
| <CheckCheck size={13} /> | |
| Mark all read | |
| </button> | |
| )} | |
| </div> | |
| <ul className="max-h-80 overflow-y-auto"> | |
| {listQ.isPending ? ( | |
| <li className="px-3.5 py-6 text-center text-[13px] text-ink-tertiary">Loading…</li> | |
| ) : items.length === 0 ? ( | |
| <li className="px-3.5 py-6 text-center text-[13px] text-ink-tertiary"> | |
| No notifications yet | |
| </li> | |
| ) : ( | |
| items.map((n) => { | |
| const isUnread = !n.read_at; | |
| return ( | |
| <li key={n.id}> | |
| <button | |
| type="button" | |
| role="menuitem" | |
| onClick={() => openItem(n)} | |
| className="flex w-full items-start gap-2.5 px-3.5 py-2.5 text-left transition-colors hover:bg-surface-sunken" | |
| > | |
| <span | |
| className={`mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full ${isUnread ? "bg-accent-600" : "bg-transparent"}`} | |
| aria-hidden | |
| /> | |
| <span className="min-w-0 flex-1"> | |
| <span className="block truncate text-[13px] font-medium text-ink"> | |
| {n.payload.full_name} | |
| </span> | |
| <span className="mt-0.5 block text-[12px] leading-snug text-ink-secondary"> | |
| {summary(n)} | |
| </span> | |
| <span className="mt-0.5 block font-mono text-[11px] text-ink-tertiary"> | |
| {formatRelativeTime(n.created_at)} | |
| </span> | |
| </span> | |
| </button> | |
| </li> | |
| ); | |
| }) | |
| )} | |
| </ul> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |