feat(client): 通知中心与未读角标(F16)

- Topbar 增加通知铃铛与未读数
- 新增 /notifications 列表页,支持筛选与标记已读
- 为 workflow/reimbursement/auth 通知提供跳转入口
这个提交包含在:
2026-02-09 18:01:27 +08:00
父节点 589a248cc3
当前提交 273caf10a9
共修改 4 个文件,包含 329 行新增和 0 行删除
@@ -1,5 +1,7 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { fetchNotifications } from "../../api/notifications";
import { useAuth } from "../../auth/AuthContext";
import { Button } from "../ui/button";
import {
@@ -37,11 +39,42 @@ function MenuIcon() {
);
}
function BellIcon() {
return (
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15 17H9m9-2V11a6 6 0 10-12 0v4l-2 2h16l-2-2z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
);
}
export function Topbar() {
const { me, logout } = useAuth();
const roleCodes = me?.roleCodes ?? [];
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const unreadQuery = useQuery({
enabled: Boolean(me),
queryKey: ["notifications", "unread-count"],
queryFn: () => fetchNotifications({ unreadOnly: true, page: 1, pageSize: 1 }),
refetchInterval: 30_000,
});
const unreadCount = unreadQuery.data?.unreadCount ?? 0;
const unreadBadge = unreadCount > 99 ? "99+" : String(unreadCount);
return (
<header className="sticky top-0 z-20 border-b border-slate-200 bg-white/95 px-4 py-3 backdrop-blur sm:px-6">
<div className="flex items-center justify-between gap-4">
@@ -89,6 +122,24 @@ export function Topbar() {
</div>
<div className="flex items-center gap-3">
<Button
asChild
size="icon"
variant="secondary"
aria-label="通知"
type="button"
data-testid="notifications-bell"
>
<Link className="relative" to="/notifications">
<BellIcon />
{unreadCount > 0 ? (
<span className="absolute -right-2 -top-2 min-w-5 rounded-full bg-red-500 px-1.5 py-0.5 text-[10px] font-semibold leading-none text-white">
{unreadBadge}
</span>
) : null}
</Link>
</Button>
<div className="hidden text-right sm:block">
<p className="text-xs uppercase tracking-wide text-secondary">角色</p>
<p className="text-sm font-medium text-slate-900">{roleCodes.join(" / ") || "-"}</p>