-
-
+
+
)
}
diff --git a/web/app/dashboard/notifications/page.tsx b/web/app/dashboard/notifications/page.tsx
new file mode 100644
index 0000000..1ca754e
--- /dev/null
+++ b/web/app/dashboard/notifications/page.tsx
@@ -0,0 +1,187 @@
+"use client"
+
+import { useCallback, useEffect, useState } from "react"
+import { useRouter } from "next/navigation"
+import { BellIcon, CheckCheckIcon, RefreshCwIcon } from "lucide-react"
+import { toast } from "sonner"
+
+import { ListPagination } from "@/components/list-pagination"
+import { useNotifications } from "@/components/notification-provider"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ fetchNotifications,
+ markAllNotificationsRead,
+ markNotificationRead,
+ type NotificationItem,
+ type NotificationReadStatus,
+} from "@/lib/api/notification"
+import type { PageResult } from "@/lib/api/admin"
+import { cn, formatDateTime } from "@/lib/utils"
+
+const readStatusOptions: Array<{ value: NotificationReadStatus; label: string }> = [
+ { value: "all", label: "全部" },
+ { value: "unread", label: "未读" },
+ { value: "read", label: "已读" },
+]
+
+export default function DashboardNotificationsPage() {
+ const router = useRouter()
+ const { refreshUnreadCount } = useNotifications()
+ const [readStatus, setReadStatus] = useState
("all")
+ const [page, setPage] = useState(1)
+ const [limit, setLimit] = useState(20)
+ const [loading, setLoading] = useState(true)
+ const [actionLoading, setActionLoading] = useState(false)
+ const [result, setResult] = useState>({
+ results: [],
+ page: { page: 1, limit: 20, total: 0 },
+ })
+
+ const loadData = useCallback(async () => {
+ setLoading(true)
+ try {
+ const data = await fetchNotifications({
+ page,
+ limit,
+ readStatus,
+ })
+ setResult(data)
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "加载通知失败")
+ } finally {
+ setLoading(false)
+ }
+ }, [limit, page, readStatus])
+
+ useEffect(() => {
+ void loadData()
+ }, [loadData])
+
+ async function openNotification(item: NotificationItem) {
+ try {
+ if (!item.readAt) {
+ await markNotificationRead(item.id)
+ await refreshUnreadCount()
+ }
+ if (item.actionUrl) {
+ router.push(item.actionUrl)
+ }
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "打开通知失败")
+ }
+ }
+
+ async function handleMarkAllRead() {
+ setActionLoading(true)
+ try {
+ await markAllNotificationsRead()
+ await refreshUnreadCount()
+ await loadData()
+ toast.success("已全部标记为已读")
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "全部已读失败")
+ } finally {
+ setActionLoading(false)
+ }
+ }
+
+ function handleStatusChange(nextStatus: NotificationReadStatus) {
+ setReadStatus(nextStatus)
+ setPage(1)
+ }
+
+ function handlePageChange(nextPage: number) {
+ if (nextPage < 1 || nextPage === page) {
+ return
+ }
+ setPage(nextPage)
+ }
+
+ function handleLimitChange(nextLimit: number) {
+ setLimit(nextLimit)
+ setPage(1)
+ }
+
+ return (
+
+
+
+
通知中心
+
+ 查看工单、会话等业务流转提醒
+
+
+
+
+
+
+
+
+
+ {readStatusOptions.map((option) => (
+
+ ))}
+
+
+
+ {result.results.length > 0 ? (
+
+ {result.results.map((item) => {
+ const unread = !item.readAt
+ return (
+
+ )
+ })}
+
+ ) : (
+
+ {loading ? "正在加载通知" : "暂无通知"}
+
+ )}
+
+
+
+
+ )
+}
diff --git a/web/components/nav-user.tsx b/web/components/nav-user.tsx
index dec43e8..801b9ac 100644
--- a/web/components/nav-user.tsx
+++ b/web/components/nav-user.tsx
@@ -5,6 +5,8 @@ import { useState } from "react"
import { useAuth } from "@/components/auth-provider"
import { ChangePasswordDialog } from "@/components/change-password-dialog"
+import { useNotifications } from "@/components/notification-provider"
+import { Badge } from "@/components/ui/badge"
import {
Avatar,
AvatarFallback,
@@ -42,7 +44,9 @@ export function NavUser({
}
}) {
const { signOut } = useAuth()
+ const { unreadCount } = useNotifications()
const { isMobile } = useSidebar()
+ const router = useRouter()
const [changePasswordOpen, setChangePasswordOpen] = useState(false)
const fallback = user.name.slice(0, 1).toUpperCase() || "U"
return (
@@ -99,9 +103,19 @@ export function NavUser({
修改密码
-
+ {
+ router.push("/dashboard/notifications")
+ }}
+ className="gap-2"
+ >
- 通知中心
+ 通知中心
+ {unreadCount > 0 ? (
+
+ {unreadCount > 99 ? "99+" : unreadCount}
+
+ ) : null}
diff --git a/web/components/notification-provider.tsx b/web/components/notification-provider.tsx
new file mode 100644
index 0000000..7ab2871
--- /dev/null
+++ b/web/components/notification-provider.tsx
@@ -0,0 +1,159 @@
+"use client"
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react"
+import { useRouter } from "next/navigation"
+import { toast } from "sonner"
+
+import {
+ createNotificationWebSocketUrl,
+ fetchNotificationUnreadCount,
+ markNotificationRead,
+ type NotificationItem,
+} from "@/lib/api/notification"
+import { readSession } from "@/lib/auth"
+import {
+ createRealtimeConnectionManager,
+ type RealtimeConnectionStatus,
+} from "@/lib/realtime-connection"
+
+type NotificationRealtimeEnvelope = {
+ eventId?: string
+ type?: string
+ data?: {
+ notification?: NotificationItem
+ }
+}
+
+type NotificationContextValue = {
+ unreadCount: number
+ realtimeStatus: RealtimeConnectionStatus
+ refreshUnreadCount: () => Promise
+ markReadAndNavigate: (notification: NotificationItem) => Promise
+}
+
+const NotificationContext = createContext(null)
+
+export function NotificationProvider({ children }: { children: ReactNode }) {
+ const router = useRouter()
+ const [unreadCount, setUnreadCount] = useState(0)
+ const [realtimeStatus, setRealtimeStatus] =
+ useState("disconnected")
+ const currentUserIdRef = useRef(readSession()?.user.id ?? 0)
+
+ const refreshUnreadCount = useCallback(async () => {
+ const result = await fetchNotificationUnreadCount()
+ setUnreadCount(result.unreadCount)
+ }, [])
+
+ const markReadAndNavigate = useCallback(
+ async (notification: NotificationItem) => {
+ if (!notification.readAt) {
+ await markNotificationRead(notification.id)
+ setUnreadCount((current) => Math.max(0, current - 1))
+ }
+ if (notification.actionUrl) {
+ router.push(notification.actionUrl)
+ }
+ },
+ [router]
+ )
+
+ useEffect(() => {
+ currentUserIdRef.current = readSession()?.user.id ?? 0
+ void refreshUnreadCount().catch(() => {
+ setUnreadCount(0)
+ })
+ }, [refreshUnreadCount])
+
+ useEffect(() => {
+ const realtime = createRealtimeConnectionManager({
+ createSocket: () => new WebSocket(createNotificationWebSocketUrl()),
+ canReconnect: () => Boolean(readSession()?.accessToken),
+ onStatusChange: setRealtimeStatus,
+ onOpen: () => {
+ void refreshUnreadCount().catch(() => undefined)
+ },
+ onMessage: (event, socket) => {
+ try {
+ const envelope = JSON.parse(event.data) as NotificationRealtimeEnvelope
+ const eventType = envelope.type ?? ""
+ const eventId = envelope.eventId?.trim() ?? ""
+ if (
+ eventType === "" ||
+ eventType === "connected" ||
+ eventType === "pong" ||
+ eventType === "subscribed" ||
+ eventType === "unsubscribed"
+ ) {
+ return
+ }
+ if (eventId && socket.readyState === WebSocket.OPEN) {
+ socket.send(JSON.stringify({ type: "ack", eventId }))
+ }
+ if (eventType !== "notification.created") {
+ return
+ }
+ const notification = envelope.data?.notification
+ if (!notification || notification.recipientUserId !== currentUserIdRef.current) {
+ return
+ }
+ setUnreadCount((current) => current + 1)
+ toast(notification.title || "新通知", {
+ description: notification.content,
+ action: {
+ label: "查看",
+ onClick: () => {
+ void markReadAndNavigate(notification).catch((error) => {
+ toast.error(error instanceof Error ? error.message : "打开通知失败")
+ })
+ },
+ },
+ })
+ } catch {
+ // ignore invalid realtime payload
+ }
+ },
+ onConnectError: (error) => {
+ toast.error(error instanceof Error ? error.message : "连接通知服务失败")
+ },
+ })
+
+ realtime.connect()
+ return () => {
+ realtime.disconnect()
+ }
+ }, [markReadAndNavigate, refreshUnreadCount])
+
+ const value = useMemo(
+ () => ({
+ unreadCount,
+ realtimeStatus,
+ refreshUnreadCount,
+ markReadAndNavigate,
+ }),
+ [markReadAndNavigate, realtimeStatus, refreshUnreadCount, unreadCount]
+ )
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useNotifications() {
+ const context = useContext(NotificationContext)
+ if (!context) {
+ throw new Error("useNotifications must be used within NotificationProvider")
+ }
+ return context
+}
diff --git a/web/lib/api/notification.ts b/web/lib/api/notification.ts
new file mode 100644
index 0000000..5299462
--- /dev/null
+++ b/web/lib/api/notification.ts
@@ -0,0 +1,81 @@
+import { readSession } from "@/lib/auth"
+import { request } from "@/lib/api/client"
+import { createWebSocketBaseUrl } from "@/lib/api/websocket"
+import type { PageResult } from "@/lib/api/admin"
+
+export type NotificationReadStatus = "all" | "unread" | "read"
+
+export type NotificationItem = {
+ id: number
+ recipientUserId: number
+ title: string
+ content: string
+ notificationType: string
+ bizType: string
+ bizId: number
+ actionUrl: string
+ readAt?: string
+ createdAt?: string
+}
+
+export type NotificationUnreadCount = {
+ unreadCount: number
+}
+
+export type NotificationListQuery = {
+ page?: number
+ limit?: number
+ readStatus?: NotificationReadStatus
+ type?: string
+}
+
+function toQueryString(query?: Record) {
+ if (!query) {
+ return ""
+ }
+
+ const params = new URLSearchParams()
+ Object.entries(query).forEach(([key, value]) => {
+ if (value === undefined || value === "") {
+ return
+ }
+ params.set(key, String(value))
+ })
+ const output = params.toString()
+ return output ? `?${output}` : ""
+}
+
+export function fetchNotifications(query?: NotificationListQuery) {
+ return request>(
+ `/api/dashboard/notification/list${toQueryString(query)}`
+ )
+}
+
+export function fetchNotificationUnreadCount() {
+ return request("/api/dashboard/notification/unread_count")
+}
+
+export function markNotificationRead(id: number) {
+ return request("/api/dashboard/notification/mark_read", {
+ method: "POST",
+ body: JSON.stringify({ id }),
+ })
+}
+
+export function markAllNotificationsRead() {
+ return request("/api/dashboard/notification/mark_all_read", {
+ method: "POST",
+ })
+}
+
+export function createNotificationWebSocketUrl() {
+ const session = readSession()
+ if (!session?.accessToken) {
+ throw new Error("未登录或登录已过期")
+ }
+
+ const params = new URLSearchParams({
+ accessToken: session.accessToken,
+ })
+ return `${createWebSocketBaseUrl()}/api/ws/dashboard/notification?${params.toString()}`
+}