diff --git a/client/src/api/notifications.ts b/client/src/api/notifications.ts new file mode 100644 index 00000000..3461b47a --- /dev/null +++ b/client/src/api/notifications.ts @@ -0,0 +1,51 @@ +import { apiRequestData } from "./http"; + +export type NotificationItem = { + id: string; + type: string; + title: string; + body: string | null; + targetType: string | null; + targetId: string | null; + readAt: string | null; + createdAt: string; +}; + +export type NotificationListResponse = { + page: number; + pageSize: number; + total: number; + unreadOnly: boolean; + unreadCount: number; + items: NotificationItem[]; +}; + +export type NotificationListQuery = { + page?: number; + pageSize?: number; + unreadOnly?: boolean; +}; + +function toQueryString(query: NotificationListQuery) { + const params = new URLSearchParams(); + if (query.page != null) params.set("page", String(query.page)); + if (query.pageSize != null) params.set("pageSize", String(query.pageSize)); + if (query.unreadOnly != null) params.set("unreadOnly", query.unreadOnly ? "1" : "0"); + const qs = params.toString(); + return qs ? `?${qs}` : ""; +} + +export async function fetchNotifications(query: NotificationListQuery = {}) { + return apiRequestData(`/api/notifications${toQueryString(query)}`); +} + +export async function markNotificationRead(notificationId: string) { + return apiRequestData<{ id: string; readAt: string | null }>( + `/api/notifications/${notificationId}/mark-read`, + { + method: "POST", + body: JSON.stringify({}), + }, + ); +} + diff --git a/client/src/components/navigation/Topbar.tsx b/client/src/components/navigation/Topbar.tsx index 3ea67451..fa4a689f 100644 --- a/client/src/components/navigation/Topbar.tsx +++ b/client/src/components/navigation/Topbar.tsx @@ -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 ( + + ); +} + 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 (
@@ -89,6 +122,24 @@ export function Topbar() {
+ +

角色

{roleCodes.join(" / ") || "-"}

diff --git a/client/src/pages/NotificationsPage.tsx b/client/src/pages/NotificationsPage.tsx new file mode 100644 index 00000000..353d24f5 --- /dev/null +++ b/client/src/pages/NotificationsPage.tsx @@ -0,0 +1,224 @@ +import { Link, useSearchParams } from "react-router-dom"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { fetchNotifications, markNotificationRead } from "../api/notifications"; +import { isApiError } from "../api/types"; +import { mapApiErrorMessage } from "../api/errorMessages"; +import { PageHeader } from "../components/common/PageHeader"; +import { Button } from "../components/ui/button"; +import { Card } from "../components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../components/ui/table"; +import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs"; + +const PAGE_SIZE = 20; + +function resolveTargetLink(item: { targetType: string | null; targetId: string | null }) { + if (!item.targetType || !item.targetId) return null; + + if (item.targetType === "workflow_task") { + return `/workflow/tasks/${item.targetId}`; + } + + if (item.targetType === "reimbursement_form") { + return `/reimbursements/${item.targetId}`; + } + + if (item.targetType === "employee") { + return "/settings/profile"; + } + + return null; +} + +export function NotificationsPage() { + const queryClient = useQueryClient(); + const [searchParams, setSearchParams] = useSearchParams(); + + const unreadOnlyRaw = searchParams.get("unreadOnly") ?? "0"; + const unreadOnly = unreadOnlyRaw === "1" || unreadOnlyRaw === "true"; + const page = Number(searchParams.get("page") ?? "1") || 1; + + const listQuery = useQuery({ + queryKey: ["notifications", "list", unreadOnly, page], + queryFn: () => + fetchNotifications({ + unreadOnly, + page, + pageSize: PAGE_SIZE, + }), + }); + + const markReadMutation = useMutation({ + mutationFn: async (notificationId: string) => markNotificationRead(notificationId), + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["notifications"] }), + queryClient.invalidateQueries({ queryKey: ["notifications", "unread-count"] }), + ]); + }, + }); + + const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE)); + + return ( +
+ + 返回仪表盘 + + } + /> + + +
+ + setSearchParams({ + unreadOnly: value === "unread" ? "1" : "0", + page: "1", + }) + } + > + + + 全部 + + + 未读 + + + + +
+ 未读 {listQuery.data?.unreadCount ?? 0} 条 +
+
+ + {listQuery.isLoading ?

加载中...

: null} + + {listQuery.isError ? ( +

+ {isApiError(listQuery.error) + ? `${listQuery.error.code}: ${mapApiErrorMessage( + listQuery.error.code, + listQuery.error.details, + listQuery.error.message, + )}` + : "加载通知失败"} +

+ ) : null} + + {!listQuery.isLoading && !listQuery.isError ? ( +
+ + + + 标题 + 时间 + 状态 + 操作 + + + + {(listQuery.data?.items ?? []).length ? ( + listQuery.data?.items.map((item) => { + const targetLink = resolveTargetLink(item); + return ( + + +
{item.title}
+ {item.body ? ( +
{item.body}
+ ) : null} +
+ + {item.createdAt.slice(0, 19).replace("T", " ")} + + + {item.readAt ? "已读" : "未读"} + + +
+ {targetLink ? ( + + ) : null} + + {!item.readAt ? ( + + ) : null} +
+
+
+ ); + }) + ) : ( + + + 暂无通知 + + + )} +
+
+
+ ) : null} + +
+ + 第 {page} / {totalPages} 页 + +
+ + +
+
+
+
+ ); +} + diff --git a/client/src/router/index.tsx b/client/src/router/index.tsx index 9d9dce65..50bcc42c 100644 --- a/client/src/router/index.tsx +++ b/client/src/router/index.tsx @@ -7,6 +7,7 @@ import { DelegationsPage } from "../pages/DelegationsPage"; import { ForbiddenPage } from "../pages/ForbiddenPage"; import { InvoicePoolPage } from "../pages/InvoicePoolPage"; import { LoginPage } from "../pages/LoginPage"; +import { NotificationsPage } from "../pages/NotificationsPage"; import { NotFoundPage } from "../pages/NotFoundPage"; import { ProfilePage } from "../pages/ProfilePage"; import { ReimbursementDetailPage } from "../pages/ReimbursementDetailPage"; @@ -86,6 +87,8 @@ export function AppRouter() { } /> + } /> +