Spaces:
Running
Running
File size: 6,302 Bytes
2eb87e3 d22337b 2eb87e3 d22337b 2eb87e3 d22337b 2eb87e3 d22337b 2eb87e3 d22337b 2eb87e3 d22337b 2eb87e3 d22337b 2eb87e3 d22337b 2eb87e3 d22337b 2eb87e3 d22337b 2eb87e3 d22337b 2eb87e3 d22337b 2eb87e3 d22337b 2eb87e3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 | 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>
);
}
|