Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions frontend/app/(main)/settings/notifications/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { NotificationsMain } from "@/components/common/settings/notifications"

export default function NotificationsPage() {
return <NotificationsMain />
}
19 changes: 19 additions & 0 deletions frontend/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,23 @@
0% { transform: translateY(0) scale(1); opacity: 1; }
100% { transform: translateY(-100px) scale(0); opacity: 0; }
}

/*
* 铃铛响铃动画配置
* 使用微妙的摆动效果,优雅且不过度
*/
--animate-bell-ring: bell-ring 0.6s cubic-bezier(0.36, 0.07, 0.19, 0.97);

@keyframes bell-ring {
0% { transform: rotate(0deg); }
10% { transform: rotate(14deg); }
20% { transform: rotate(-12deg); }
30% { transform: rotate(10deg); }
40% { transform: rotate(-8deg); }
50% { transform: rotate(6deg); }
60% { transform: rotate(-4deg); }
70% { transform: rotate(2deg); }
80% { transform: rotate(-1deg); }
100% { transform: rotate(0deg); }
}
}
10 changes: 8 additions & 2 deletions frontend/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { Inter, Noto_Sans_SC, Geist_Mono } from 'next/font/google';
import { Toaster } from "@/components/ui/sonner";
import { ThemeProvider } from "@/components/layout/theme-provider";
import { CustomThemeProvider } from "@/lib/theme";
import { BellRingProvider } from "@/contexts/bell-ring-context";
import { NotificationSettingsProvider } from "@/contexts/notification-settings-context";
import "./globals.css";

const inter = Inter({
Expand Down Expand Up @@ -49,8 +51,12 @@ export default function RootLayout({
disableTransitionOnChange
>
<CustomThemeProvider>
{children}
<Toaster position="top-center" />
<NotificationSettingsProvider>
<BellRingProvider>
{children}
<Toaster position="top-center" />
</BellRingProvider>
</NotificationSettingsProvider>
</CustomThemeProvider>
</ThemeProvider>
</body>
Expand Down
60 changes: 60 additions & 0 deletions frontend/components/common/settings/notifications.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"use client"

import Link from "next/link"
import { Bell } from "lucide-react"
import { Switch } from "@/components/ui/switch"
import { Label } from "@/components/ui/label"
import { useNotificationSettings } from "@/contexts/notification-settings-context"
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"

export function NotificationsMain() {
const { showBell, setShowBell } = useNotificationSettings()

return (
<div className="py-6 space-y-6">
<div className="font-semibold">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/settings" className="text-base text-primary">设置</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className="text-base font-semibold">通知设置</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>

<div className="space-y-6">
<div>
<h2 className="font-medium text-sm text-foreground">通知显示</h2>
<p className="text-xs text-muted-foreground">
设置通知相关的显示选项
</p>
</div>

<div className="flex items-center justify-between rounded-lg border p-4">
<div className="flex items-center gap-3">
<Bell className="size-5 text-primary" />
<div className="space-y-0.5">
<Label htmlFor="show-bell" className="text-sm font-medium cursor-pointer">
显示通知铃铛
</Label>
<p className="text-xs text-muted-foreground">
在顶部导航栏中显示通知铃铛图标
</p>
</div>
</div>
<Switch
id="show-bell"
checked={showBell}
onCheckedChange={setShowBell}
/>
</div>
</div>
</div>
)
}
47 changes: 38 additions & 9 deletions frontend/components/layout/header.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,51 @@
"use client"

import { useState, useEffect } from "react"
import { useState, useEffect, memo } from "react"
import { AnimatePresence, motion } from "motion/react"
import { Button } from "@/components/ui/button"
import { Bell, Plus, Settings, Search, Moon, Sun, Maximize2, Minimize2 } from "lucide-react"
import { useUser } from "@/contexts/user-context"
import { useBellRing } from "@/contexts/bell-ring-context"
import { useNotificationSettings } from "@/contexts/notification-settings-context"
import { SidebarTrigger } from "@/components/ui/sidebar"
import { useTheme } from "next-themes"
import { useRouter } from "next/navigation"
import { SearchDialog } from "@/components/layout/search-dialog"


/**
* 铃铛按钮组件
*/
const BellButton = memo(function BellButton() {
const { isRinging } = useBellRing()
const { showBell, isMounted } = useNotificationSettings()
const [isClickAnimating, setIsClickAnimating] = useState(false)

if (!isMounted) return null
if (!showBell) return null

const handleClick = () => {
setIsClickAnimating(true)
setTimeout(() => setIsClickAnimating(false), 600)
}

return (
<Button
variant="ghost"
size="icon"
className="size-9 text-muted-foreground hover:text-foreground"
onClick={handleClick}
>
<Bell
className="size-[18px]"
style={(isRinging || isClickAnimating) ? { animation: 'var(--animate-bell-ring)', transformOrigin: 'top center' } : undefined}
/>
<span className="sr-only">通知</span>
</Button>
)
})


/**
* 站点头部组件
* 用于显示站点头部
Expand Down Expand Up @@ -52,10 +87,7 @@ export function SiteHeader({ isFullWidth = false, onToggleFullWidth }: { isFullW
<Search className="size-[18px]" />
<span className="sr-only">搜索</span>
</Button>
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground">
<Bell className="size-[18px]" />
<span className="sr-only">通知</span>
</Button>
<BellButton />
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => router.push('/settings')}>
<Settings className="size-[18px]" />
<span className="sr-only">设置</span>
Expand All @@ -75,10 +107,7 @@ export function SiteHeader({ isFullWidth = false, onToggleFullWidth }: { isFullW
</div>

<div className="ml-auto flex items-center gap-1">
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground">
<Bell className="size-[18px]" />
<span className="sr-only">通知</span>
</Button>
<BellButton />
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => router.push('/settings')}>
<Settings className="size-[18px]" />
<span className="sr-only">设置</span>
Expand Down
68 changes: 47 additions & 21 deletions frontend/components/ui/sonner.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client"

import { useEffect, useRef } from "react"
import {
CircleCheckIcon,
InfoIcon,
Expand All @@ -8,33 +9,58 @@ import {
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { Toaster as Sonner, useSonner, type ToasterProps } from "sonner"
import { useBellRing } from "@/contexts/bell-ring-context"

/**
* Toast 观察器组件
* 监听 toast 事件并触发铃铛响铃动画
*/
function ToastObserver() {
const { toasts } = useSonner()
const { triggerRing } = useBellRing()
const prevToastCountRef = useRef(0)

useEffect(() => {
// 当 toast 数量增加时触发响铃
if (toasts.length > prevToastCountRef.current) {
triggerRing()
}
prevToastCountRef.current = toasts.length
}, [toasts.length, triggerRing])

return null
}

const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()

return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
<>
<ToastObserver />
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
</>
)
}

export { Toaster }

54 changes: 54 additions & 0 deletions frontend/contexts/bell-ring-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"use client"

import { createContext, useContext, useState, useCallback, useRef } from "react"

interface BellRingContextType {
isRinging: boolean
triggerRing: () => void
}

const BellRingContext = createContext<BellRingContextType | undefined>(undefined)

/**
* 铃铛响铃动画上下文提供者
* 用于在 toast 通知时触发顶栏铃铛的响铃动画
*/
export function BellRingProvider({ children }: { children: React.ReactNode }) {
const [isRinging, setIsRinging] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)

const triggerRing = useCallback(() => {
// 如果正在响铃,立即重新开始动画
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
setIsRinging(false)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setIsRinging(true)
timeoutRef.current = setTimeout(() => {
setIsRinging(false)
timeoutRef.current = null
}, 600)
})
})
}, [])

return (
<BellRingContext.Provider value={{ isRinging, triggerRing }}>
{children}
</BellRingContext.Provider>
)
}

/**
* 使用铃铛响铃动画的 Hook
* @returns {{ isRinging: boolean, triggerRing: () => void }}
*/
export function useBellRing() {
const context = useContext(BellRingContext)
if (context === undefined) {
throw new Error("useBellRing must be used within a BellRingProvider")
}
return context
}
64 changes: 64 additions & 0 deletions frontend/contexts/notification-settings-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"use client"

import { createContext, useContext, useState, useEffect, ReactNode, useMemo } from "react"

interface NotificationSettingsContextType {
showBell: boolean
isMounted: boolean
setShowBell: (value: boolean) => void
}

const NotificationSettingsContext = createContext<NotificationSettingsContextType | undefined>(undefined)

const STORAGE_KEY = "notification-settings"

interface StoredSettings {
showBell: boolean
}

export function NotificationSettingsProvider({ children }: { children: ReactNode }) {
const [showBell, setShowBellState] = useState(true)
const [mounted, setMounted] = useState(false)

useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
try {
const settings: StoredSettings = JSON.parse(stored)
if (typeof settings.showBell === "boolean") {
setShowBellState(settings.showBell)
}
} catch {
// Ignore parse errors
}
}
setMounted(true)
}, [])

const setShowBell = (value: boolean) => {
setShowBellState(value)
const settings: StoredSettings = { showBell: value }
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
}

// Memoize context value to prevent unnecessary re-renders
const contextValue = useMemo<NotificationSettingsContextType>(() => ({
showBell,
isMounted: mounted,
setShowBell,
}), [showBell, mounted])

return (
<NotificationSettingsContext.Provider value={contextValue}>
{children}
</NotificationSettingsContext.Provider>
)
}

export function useNotificationSettings() {
const context = useContext(NotificationSettingsContext)
if (context === undefined) {
throw new Error("useNotificationSettings must be used within a NotificationSettingsProvider")
}
return context
}
Loading