wavelet init

This commit is contained in:
ryan
2026-06-18 15:24:48 +08:00
parent d6a7011885
commit 99738bbc17
714 changed files with 139987 additions and 0 deletions
@@ -0,0 +1,97 @@
/**
* 圣诞节主题装饰组件归档
* 包含圣诞树、圣诞帽图标以及下雪效果的集成组件
*
* 使用示例:
* ```tsx
* import { ChristmasDecorations } from "@/components/layout/avater-style/Christmas"
*
* function MyComponent() {
* const [showChristmas, setShowChristmas] = useState(false)
*
* return (
* <>
* {showChristmas && <ChristmasDecorations.SnowEffect />}
* <div onClick={() => setShowChristmas(!showChristmas)}>
* <ChristmasDecorations.Hat className="w-8 h-8" />
* <ChristmasDecorations.Tree
* className="w-12 h-12"
* show={showChristmas}
* />
* </div>
* </>
* )
* }
* ```
*/
import {cn} from "@/lib/utils"
import {SnowEffect} from "@/components/ui/snow-effect"
/**
* 圣诞树图标组件
*/
export const ChristmasTree = ({
className,
show = true
}: {
className?: string
show?: boolean
}) => (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={cn(
"transition-all duration-500 ease-out",
show ? "opacity-90 scale-100 translate-y-0" : "opacity-0 scale-50 translate-y-4",
className
)}
>
<rect x="10" y="18" width="4" height="4" fill="#92400E" rx="0.5" />
<path d="M12 4L8 9H16L12 4Z" fill="#15803D" />
<path d="M12 8L7 14H17L12 8Z" fill="#16A34A" />
<path d="M12 12L6 19H18L12 12Z" fill="#22C55E" />
<circle cx="10" cy="10" r="0.8" fill="#EF4444" />
<circle cx="14" cy="11" r="0.8" fill="#F59E0B" />
<circle cx="9" cy="15" r="0.8" fill="#3B82F6" />
<circle cx="15" cy="16" r="0.8" fill="#EF4444" />
<circle cx="12" cy="14" r="0.8" fill="#F59E0B" />
<path d="M12 2L12.5 3.5L14 4L12.5 4.5L12 6L11.5 4.5L10 4L11.5 3.5L12 2Z" fill="#FBBF24" />
</svg>
)
/**
* 圣诞帽图标组件
*/
export const ChristmasHat = ({ className }: { className?: string }) => (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M12 3C12 3 5 10 5 15C5 17.5 7 19 8 19H16C17 19 19 17.5 19 15C19 10 12 3 12 3Z"
fill="#EF4444"
stroke="#B91C1C"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="12" cy="3" r="2.5" fill="white" stroke="#E5E7EB" strokeWidth="1.5" />
<rect x="4" y="16" width="16" height="5" rx="2.5" fill="white" stroke="#E5E7EB" strokeWidth="1.5" />
</svg>
)
/**
* 圣诞节装饰集合
* 包含所有圣诞节主题组件的命名空间
*/
export const ChristmasDecorations = {
Tree: ChristmasTree,
Hat: ChristmasHat,
SnowEffect: SnowEffect,
}
export default ChristmasDecorations
@@ -0,0 +1,121 @@
/**
* 元旦/新年主题装饰组件归档
* 包含灯笼、福字以及烟花效果的集成组件
*
* 使用示例:
* ```tsx
* import { NewYearDecorations } from "@/components/layout/avater-style/NewYear"
*
* function MyComponent() {
* const [showNewYear, setShowNewYear] = useState(false)
*
* return (
* <>
* {showNewYear && <NewYearDecorations.FireworksEffect />}
* <div onClick={() => setShowNewYear(!showNewYear)}>
* <NewYearDecorations.Lantern className="w-8 h-8" />
* <NewYearDecorations.Fu
* className="w-12 h-12"
* show={showNewYear}
* />
* </div>
* </>
* )
* }
* ```
*/
import {cn} from "@/lib/utils"
import {FireworksEffect} from "@/components/ui/fireworks-effect"
/**
* 红灯笼组件 - 默认头像装饰
*/
export const Lantern = ({ className }: { className?: string }) => (
<svg
viewBox="0 0 1024 1024"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M512 80V160" stroke="#FFD700" strokeWidth="32" strokeLinecap="round" />
<ellipse cx="512" cy="420" rx="300" ry="260" fill="#D92424" />
<ellipse cx="512" cy="420" rx="300" ry="260" stroke="#961C1C" strokeWidth="10" />
<path d="M340 180H684C700 180 712 192 712 208V220H312V208C312 192 324 180 340 180Z" fill="#FFD700" />
<path d="M340 620H684C700 620 712 632 712 648V660H312V648C312 632 324 620 340 620Z" fill="#FFD700" />
<path d="M512 160C512 160 620 250 620 420C620 590 512 680 512 680" stroke="#FFD700" strokeWidth="20" strokeLinecap="round" strokeOpacity="0.4" />
<path d="M512 160C512 160 404 250 404 420C404 590 512 680 512 680" stroke="#FFD700" strokeWidth="20" strokeLinecap="round" strokeOpacity="0.4" />
<path d="M512 160V680" stroke="#FFD700" strokeWidth="20" strokeOpacity="0.4" />
<circle cx="512" cy="420" r="80" fill="#FFD700" fillOpacity="0.9" />
<text x="512" y="450" fontSize="80" fill="#D92424" textAnchor="middle" fontWeight="bold" fontFamily="serif">福</text>
<path d="M512 660V720" stroke="#FFD700" strokeWidth="20" />
<path d="M480 720H544" stroke="#D92424" strokeWidth="40" strokeLinecap="round" />
<path d="M492 740V900" stroke="#D92424" strokeWidth="16" strokeLinecap="round" />
<path d="M512 740V940" stroke="#D92424" strokeWidth="16" strokeLinecap="round" />
<path d="M532 740V900" stroke="#D92424" strokeWidth="16" strokeLinecap="round" />
</svg>
)
/**
* "福"字挂件 - 点击出现的装饰
* 一个菱形的福字牌,使用文字渲染确保准确
*/
export const FuCharacter = ({
className,
show = true
}: {
className?: string
show?: boolean
}) => (
<svg
viewBox="0 0 100 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={cn(
"transition-all duration-500 ease-out origin-center",
show ? "opacity-100 scale-100 rotate-0" : "opacity-0 scale-50 rotate-[-15deg]",
className
)}
>
<path d="M50 0V15" stroke="#F59E0B" strokeWidth="2" />
<g transform="rotate(25 50 50)">
<rect x="20" y="20" width="60" height="60" rx="2" fill="#D92424" stroke="#F59E0B" strokeWidth="1.5" />
<rect x="23" y="23" width="54" height="54" rx="1" stroke="#F59E0B" strokeWidth="1" strokeOpacity="0.5" strokeDasharray="4 2" fill="none" />
<text
x="50"
y="60"
fontSize="32"
fill="#FFD700"
textAnchor="middle"
fontWeight="bold"
fontFamily="'YouYuan', 'Yuanti SC', 'STYuanti', 'HanyiYuanti', 'Comic Sans MS', sans-serif"
style={{ letterSpacing: "0px" }}
transform="rotate(130 50 50)"
>
福
</text>
</g>
<path d="M50 85V95" stroke="#D92424" strokeWidth="3" />
<circle cx="50" cy="95" r="2" fill="#F59E0B" />
<path d="M50 95L47 100M50 95L53 100" stroke="#D92424" strokeWidth="1" />
</svg>
)
/**
* 元旦/新年装饰集合
*/
export const NewYearDecorations = {
Lantern,
Fu: FuCharacter,
FireworksEffect,
}
export default NewYearDecorations
@@ -0,0 +1,48 @@
import {AvatarDecorationTheme} from "./types"
import {ChristmasDecorations} from "./Christmas"
import {NewYearDecorations} from "./NewYear"
export const DecorationThemes: Record<string, AvatarDecorationTheme> = {
christmas: {
key: "christmas",
MainDecoration: ChristmasDecorations.Hat,
InteractionDecoration: ChristmasDecorations.Tree,
BackgroundEffect: ChristmasDecorations.SnowEffect,
smallMainDecorationConfig: {
className: "absolute -top-1 -left-1 w-3 h-3 z-20 rotate-[-20deg] drop-shadow-sm"
},
largeMainDecorationConfig: {
className: "absolute -top-2 -left-1 w-5 h-5 z-20 rotate-[-20deg] drop-shadow-sm"
},
interactionDecorationConfig: {
className: "absolute -top-2 right-2 w-12 h-12 z-0 rotate-[10deg] transition-all duration-500 ease-out"
}
},
newyear: {
key: "newyear",
MainDecoration: NewYearDecorations.Lantern,
InteractionDecoration: NewYearDecorations.Fu,
BackgroundEffect: NewYearDecorations.FireworksEffect,
smallMainDecorationConfig: {
className: "absolute -top-2 -right-1 w-8 h-8 z-20 rotate-[10deg] drop-shadow-md pointer-events-none"
},
largeMainDecorationConfig: {
className: "absolute -top-3 -right-3 w-8 h-8 z-20 rotate-[10deg] drop-shadow-md pointer-events-none"
},
interactionDecorationConfig: {
className: "absolute top-6 left-4 w-10 h-10 z-20 -rotate-[5deg] transition-all duration-500 ease-out"
}
},
none: {
key: "none",
MainDecoration: () => null,
InteractionDecoration: () => null,
}
}
/* 可选值: 'christmas', 'newyear', 'none' */
export const CURRENT_THEME_KEY: string = "none"
export const getCurrentTheme = (): AvatarDecorationTheme => {
return DecorationThemes[CURRENT_THEME_KEY] || DecorationThemes.none
}
@@ -0,0 +1,17 @@
import {ComponentType} from "react"
export interface AvatarDecorationTheme {
key: string
MainDecoration: ComponentType<{ className?: string }>
InteractionDecoration: ComponentType<{ className?: string; show?: boolean }>
BackgroundEffect?: ComponentType
smallMainDecorationConfig?: {
className?: string
}
largeMainDecorationConfig?: {
className?: string
}
interactionDecorationConfig?: {
className?: string
}
}
@@ -0,0 +1,143 @@
import * as React from "react"
import {FileTextIcon} from "lucide-react"
import {Button} from "@/components/ui/button"
import {cn} from "@/lib/utils"
/**
* 空状态展示组件属性
* 用于统一显示无数据、无内容等空状态
*/
export interface EmptyStateProps extends React.ComponentProps<"div"> {
title?: string
description?: string
icon?: React.ComponentType<{ className?: string }>
actionText?: string
onAction?: () => void
iconSize?: "sm" | "md" | "lg"
}
/**
* 空状态展示组件
* 用于统一显示无数据、无内容等空状态
*
* @example
* ```tsx
* // 基础用法
* <EmptyState
* title="暂无数据"
* description="当前没有任何记录"
* />
*
* // 带操作按钮
* <EmptyState
* title="暂无交易记录"
* description="还没有任何交易"
* actionText="创建交易"
* onAction={() => router.push('/create')}
* />
*
* // 自定义图标
* <EmptyState
* icon={BarChartIcon}
* description="未发现提现记录"
* />
* ```
* @param {EmptyStateProps} props - 空状态展示组件属性
* @returns {React.ReactNode} 空状态展示组件
*/
export function EmptyState({
title,
description,
icon: Icon = FileTextIcon,
actionText,
onAction,
className,
iconSize = "md",
}: EmptyStateProps) {
const iconSizes = { sm: "size-10", md: "size-12", lg: "size-16" }
const iconInnerSizes = { sm: "size-5", md: "size-6", lg: "size-8" }
return (
<div className={cn("flex flex-col items-center justify-center py-12 text-center", className)}>
<div className={cn(
"rounded-full bg-muted flex items-center justify-center mb-4",
iconSizes[iconSize],
)}>
<Icon className={cn("text-muted-foreground", iconInnerSizes[iconSize])} />
</div>
{title && (
<h3 className="text-base font-medium mb-1">
{title}
</h3>
)}
{description && (
<p className="text-sm text-muted-foreground max-w-md">
{description}
</p>
)}
{onAction && actionText && (
<Button
onClick={onAction}
variant="outline"
className="mt-4"
>
{actionText}
</Button>
)}
</div>
)
}
/**
* 带边框的空状态组件
* 适合在卡片或容器内使用
*
* @example
* ```tsx
* <EmptyStateWithBorder
* title="暂无数据"
* description="当前没有任何记录"
* />
* ```
* @param {EmptyStateProps} props - 空状态展示组件属性
* @returns {React.ReactNode} 带边框的空状态组件
*/
export function EmptyStateWithBorder(props: EmptyStateProps) {
return (
<div className="border border-dashed rounded-lg">
<EmptyState {...props} />
</div>
)
}
/**
* 简化版空状态组件(内联使用)
* 适合在较小的区域显示
*
* @example
* ```tsx
* <EmptyInline
* message="暂无数据"
* icon={FileTextIcon}
* />
* ```
* @param {Pick<EmptyStateProps, 'icon' | 'className'> & { message?: string }} props - 简化版空状态组件属性
* @returns {React.ReactNode} 简化版空状态组件
*/
export function EmptyInline({
message = "暂无数据",
icon: Icon = FileTextIcon,
className,
}: Pick<EmptyStateProps, 'icon' | 'className'> & { message?: string }) {
return (
<div className={cn("flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground", className)}>
<Icon className="size-4" />
<span>{message}</span>
</div>
)
}
@@ -0,0 +1,159 @@
import * as React from "react"
import {AlertCircle} from "lucide-react"
import {Button} from "@/components/ui/button"
import {cn} from "@/lib/utils"
/**
* 错误展示组件属性
* 用于统一显示加载失败、请求失败等错误状态
*/
export interface ErrorDisplayProps {
title?: string
message?: string
error?: Error | null
onRetry?: () => void
retryText?: string
icon?: React.ComponentType<{ className?: string }>
className?: string
showStack?: boolean
}
/**
* 错误展示组件
* 用于统一显示加载失败、请求失败等错误状态
*
* @example
* ```tsx
* // 基础用法
* <ErrorDisplay
* title="加载失败"
* message="无法获取数据,请稍后重试"
* />
*
* // 带重试按钮
* <ErrorDisplay
* error={error}
* onRetry={() => refetch()}
* />
*
* // 自定义样式
* <ErrorDisplay
* title="网络错误"
* message="请检查您的网络连接"
* className="min-h-[400px]"
* />
* ```
* @param {ErrorDisplayProps} props - 错误展示组件属性
* @returns {React.ReactNode} 错误展示组件
*/
export function ErrorDisplay({
title = "加载失败",
message,
error,
onRetry,
retryText = "重试",
icon: Icon = AlertCircle,
className,
showStack = false,
}: ErrorDisplayProps) {
const errorMessage = message || error?.message || "发生未知错误,请稍后重试"
return (
<div className={cn("flex flex-col items-center justify-center py-12 text-center", className)}>
<div className="size-12 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center mb-4">
<Icon className="size-6 text-red-600 dark:text-red-400" />
</div>
<h3 className="text-lg font-semibold mb-2">{title}</h3>
<p className="text-sm text-muted-foreground max-w-md mb-4">{errorMessage}</p>
{onRetry && (
<Button
onClick={onRetry}
variant="outline"
className="mt-2"
>
{retryText}
</Button>
)}
{showStack && error?.stack && process.env.NODE_ENV === 'development' && (
<details className="mt-6 text-left max-w-2xl w-full">
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">
查看详细错误信息
</summary>
<pre className="mt-2 p-4 bg-muted rounded-lg text-xs overflow-auto">
{error.stack}
</pre>
</details>
)}
</div>
)
}
/**
* 区域级错误组件
* 适合在较小的区域显示错误
*
* @example
* ```tsx
* <ErrorInline
* message="发生错误"
* error={error}
* onRetry={() => refetch()}
* />
* ```
*/
export function ErrorInline({
message,
error,
onRetry,
className,
}: Omit<ErrorDisplayProps, 'title' | 'icon' | 'showStack'>) {
const errorMessage = message || error?.message || "发生未知错误"
return (
<div className={cn("flex items-center gap-2 text-sm text-red-600 dark:text-red-400", className)}>
<AlertCircle className="size-3 shrink-0 text-destructive" />
<span className="flex-1 text-xs">{errorMessage}</span>
{onRetry && (
<Button
onClick={onRetry}
variant="secondary"
size="sm"
className="h-6 px-4 text-xs"
>
重试
</Button>
)}
</div>
)
}
/**
* 页面级错误组件
* 适合作为页面主要内容的错误状态
*
* @example
* ```tsx
* <ErrorPage
* title="加载失败"
* message="无法获取数据,请稍后重试"
* error={error}
* onRetry={() => refetch()}
* />
* ```
*/
export function ErrorPage(props: ErrorDisplayProps) {
return (
<div className="flex items-center justify-center min-h-[calc(100vh-8rem)]">
<ErrorDisplay
{...props}
showStack={props.showStack ?? process.env.NODE_ENV === 'development'}
/>
</div>
)
}
@@ -0,0 +1,213 @@
"use client"
import {memo, useEffect, useState} from "react"
import {AnimatePresence, motion} from "motion/react"
import {Button} from "@/components/ui/button"
import {Bell, Info, Maximize2, Minimize2, Moon, Search, Settings, Sun} 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"
import {Kbd} from "@/components/ui/kbd"
/**
* 铃铛按钮组件
*/
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>
)
})
/**
* 站点头部组件
* 用于显示站点头部
*
* @example
* ```tsx
* <SiteHeader />
* ```
*/
export function SiteHeader({ isFullWidth = false, onToggleFullWidth }: { isFullWidth?: boolean, onToggleFullWidth?: (value: boolean) => void }) {
const { user } = useUser()
const { setTheme, resolvedTheme } = useTheme()
const router = useRouter()
const [mounted, setMounted] = useState(false)
const [searchOpen, setSearchOpen] = useState(false)
const [metaKey, setMetaKey] = useState("⌘")
useEffect(() => {
setMounted(true)
if (typeof navigator !== 'undefined' && !navigator.userAgent?.includes("Mac")) {
setMetaKey("Ctrl")
}
}, [])
return (
<div className="flex flex-col w-full">
<AppBanner />
{user?.need_change_password && (
<div className="bg-amber-500/10 border-b border-amber-500/20 px-4 py-3 text-amber-500 text-xs sm:text-sm flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Info className="size-4 shrink-0 text-amber-500" />
<span>为了您的账号安全,请立即修改密码!</span>
</div>
<Button
variant="outline"
size="sm"
className="text-amber-500 hover:text-amber-600 border-amber-500/30 hover:border-amber-500/50 bg-transparent shrink-0 h-8 px-3 text-xs"
onClick={() => router.push("/settings/profile")}
>
去修改密码
</Button>
</div>
)}
<header className="flex h-(--header-height) shrink-0 items-center bg-background px-4 md:px-0">
<div className="flex w-full items-center justify-between md:hidden">
<div className="flex items-center gap-2">
<SidebarTrigger />
<span className="text-sm font-medium truncate max-w-[120px]">
{user?.nickname || user?.username || "Guest"}
</span>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => setSearchOpen(true)}>
<Search 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>
</Button>
</div>
</div>
<div className={`hidden md:flex w-full items-center gap-4 transition-all duration-300 ease-in-out ${!isFullWidth ? "max-w-[1320px]" : "max-w-full"} mx-auto md:px-8 lg:px-12`}>
<div className="relative w-64 cursor-pointer" onClick={() => setSearchOpen(true)}>
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<div className="flex h-8 items-center rounded-md border border-border/60 bg-muted/70 pl-10 pr-2.5 text-sm text-muted-foreground transition-colors hover:border-border hover:bg-muted">
<span>搜索</span>
<Kbd className="ml-auto gap-0.5 font-mono">
<span>{metaKey}</span>
<span>K</span>
</Kbd>
</div>
</div>
<div className="ml-auto flex items-center gap-1">
<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>
</Button>
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => onToggleFullWidth?.(!isFullWidth)}>
{isFullWidth ? <Minimize2 className="size-[18px]" /> : <Maximize2 className="size-[18px]" />}
<span className="sr-only">切换全宽</span>
</Button>
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}>
{mounted ? (resolvedTheme === 'dark' ? <Sun className="size-[18px]" /> : <Moon className="size-[18px]" />) : <Moon className="size-[18px]" />}
<span className="sr-only">主题切换</span>
</Button>
</div>
</div>
{mounted && <SearchDialog open={searchOpen} onOpenChange={setSearchOpen} />}
</header>
</div>
)
}
function AppBanner() {
const [isVisible, setIsVisible] = useState(false)
const bannerKey = 'ldc-banner-dismissed-script'
useEffect(() => {
const dismissed = localStorage.getItem(bannerKey)
if (!dismissed) {
setIsVisible(true)
}
}, [])
const handleDismiss = () => {
localStorage.setItem(bannerKey, 'true')
setIsVisible(false)
}
return (
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
className="relative flex w-full items-center justify-center bg-muted/50 overflow-hidden"
>
<div className="flex w-full items-center justify-center px-4 pr-10 py-2 text-xs md:text-[13px] font-medium text-muted-foreground">
<p className="flex flex-wrap items-center justify-center gap-x-2 text-center">
<span className="text-foreground">最新通知</span>
<a href="https://example.com" target="_blank" className="underline underline-offset-4 hover:text-foreground">
查看详情
</a>
</p>
<Button
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 size-6 -translate-y-1/2 text-muted-foreground hover:text-foreground shrink-0"
onClick={handleDismiss}
>
<span className="sr-only">Dismiss</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-x"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
)
}
@@ -0,0 +1,125 @@
import * as React from "react"
import {motion} from "motion/react"
import {cn} from "@/lib/utils"
import {FileTextIcon} from "lucide-react"
/**
* 加载页面组件
*
* 用于统一显示加载状态
* @example
* ```tsx
* <LoadingPage text="系统" badgeText="系统" />
* ```
* @param {string} text - 显示的文本内容,默认为"系统"
* @param {string} badgeText - 显示的徽章文本,默认为"系统"
* @returns {React.ReactNode} 加载页面组件
*/
export function AppleSpinner({ className = "size-8 text-muted-foreground" }: { className?: string }) {
const spokes = Array.from({ length: 12 })
return (
<div className={cn("relative flex items-center justify-center", className)}>
<style dangerouslySetInnerHTML={{ __html: `
@keyframes apple-spinner-fade {
0% { opacity: 1; }
100% { opacity: 0.15; }
}
.apple-spinner-spoke {
animation: apple-spinner-fade 1.2s linear infinite;
}
`}} />
<div className="relative size-full">
{spokes.map((_, i) => (
<div
key={i}
className="apple-spinner-spoke absolute top-0 left-[46%] w-[8%] h-[28%] bg-current rounded-full"
style={{
transform: `rotate(${i * 30}deg)`,
transformOrigin: "50% 178.5%",
animationDelay: `${(i - 12) * 0.1}s`,
}}
/>
))}
</div>
</div>
)
}
/**
* 加载页面组件
*
* 用于统一显示加载状态
*/
export function LoadingPage(props: { text?: string; badgeText?: string }) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _ = props
return (
<div className="absolute inset-0 z-50 overflow-hidden font-sans bg-background/80 backdrop-blur-md text-foreground flex items-center justify-center">
<motion.div
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="flex items-center justify-center"
>
<AppleSpinner className="size-9 text-muted-foreground/80" />
</motion.div>
</div>
)
}
interface LoadingStateProps extends React.ComponentProps<"div"> {
title?: string
description?: string
icon?: React.ComponentType<{ className?: string }>
iconSize?: "sm" | "md" | "lg"
}
/**
* 加载状态展示组件
* 用于统一显示加载中的状态
*/
export function LoadingState({
title = "加载中",
description = "正在获取活动数据...",
icon: Icon = FileTextIcon,
className,
iconSize = "md",
}: LoadingStateProps) {
const iconSizes = { sm: "size-8", md: "size-10", lg: "size-14" }
const iconInnerSizes = { sm: "size-4", md: "size-5", lg: "size-7" }
return (
<div className={cn("flex flex-col items-center justify-center py-12 text-center", className)}>
<div className={cn(
"rounded-full bg-muted flex items-center justify-center mb-2 animate-pulse",
iconSizes[iconSize]
)}>
<Icon className={cn("text-muted-foreground", iconInnerSizes[iconSize])} />
</div>
{title && (
<h3 className="text-sm font-medium mb-1 animate-pulse">
{title}
</h3>
)}
{description && (
<p className="text-xs text-muted-foreground max-w-md animate-pulse">
{description}
</p>
)}
</div>
)
}
/**
* 带边框的加载状态组件
*/
export function LoadingStateWithBorder(props: LoadingStateProps) {
return (
<div className="border border-dashed rounded-lg">
<LoadingState {...props} />
</div>
)
}
@@ -0,0 +1,17 @@
"use client"
import {usePublicConfig} from "@/hooks/use-public-config"
export function RobotsMeta() {
const { config } = usePublicConfig()
// Default to noindex, nofollow if config is loading or if it's explicitly disabled
const enabled = config?.search_engine_indexing_enabled === "true"
return (
<meta
name="robots"
content={enabled ? "index, follow" : "noindex, nofollow"}
/>
)
}
@@ -0,0 +1,234 @@
"use client"
import {useCallback, useEffect, useState} from "react"
import {useRouter} from "next/navigation"
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {Kbd, KbdGroup} from "@/components/ui/kbd"
import {type SearchItem, searchItems} from "@/lib/utils/search-data"
import {FileText, Home, Settings, Shield} from "lucide-react"
import {useUser} from "@/contexts/user-context"
interface SearchDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
const categoryIcons = {
page: Home,
feature: FileText,
setting: Settings,
admin: Shield,
}
const categoryLabels = {
page: '页面',
feature: '功能',
setting: '设置',
admin: '管理',
}
const getTips = (metaKey: string) => [
(
<>
<span className="text-muted-foreground/80 lowercase">Tips: 还可以使用</span>
<Kbd className="mx-1">/</Kbd>
<span className="text-muted-foreground/80 lowercase">来打开此界面</span>
</>
),
(
<>
<span className="text-muted-foreground/80 lowercase">Tips: 使用</span>
<Kbd className="mx-1">↑</Kbd>
<Kbd className="mx-1">↓</Kbd>
<span className="text-muted-foreground/80 lowercase">来切换选中项</span>
</>
),
(
<>
<span className="text-muted-foreground/80 lowercase">Tips: 按住</span>
<Kbd className="mx-1">{metaKey}</Kbd>
<span className="text-muted-foreground/80 lowercase">+</span>
<Kbd className="mx-1">↵</Kbd>
<span className="text-muted-foreground/80 lowercase">在新标签页打开</span>
</>
),
(
<>
<span className="text-muted-foreground/80 lowercase">你知道吗:搜索功能还在持续升级中</span>
</>
)
]
export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
const router = useRouter()
const { user } = useUser()
const [search, setSearch] = useState('')
const [currentTip, setCurrentTip] = useState<React.ReactNode>(null)
const [results, setResults] = useState<SearchItem[]>([])
const [metaKey, setMetaKey] = useState("⌘")
const [isCtrlPressed, setIsCtrlPressed] = useState(false)
useEffect(() => {
if (typeof navigator !== 'undefined' && !navigator.userAgent?.includes("Mac")) {
setMetaKey("Ctrl")
}
}, [])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Control' || e.key === 'Meta') {
setIsCtrlPressed(true)
}
}
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Control' || e.key === 'Meta') {
setIsCtrlPressed(false)
}
}
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
}
}, [])
useEffect(() => {
if (open) {
const tips = getTips(metaKey)
const randomTip = tips[Math.floor(Math.random() * tips.length)]
setCurrentTip(randomTip)
}
}, [open, metaKey])
useEffect(() => {
const items = searchItems(search, user?.is_admin)
setResults(items)
}, [search, user?.is_admin])
const handleSelect = useCallback((item: SearchItem, openInNewTab = false) => {
onOpenChange(false)
if (openInNewTab) {
window.open(item.url, '_blank')
} else {
router.push(item.url)
}
setSearch('')
}, [onOpenChange, router])
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
onOpenChange(!open)
}
if (e.key === '/'&& !e.ctrlKey && !e.metaKey && !e.altKey) {
const target = e.target as HTMLElement
const isEditing = target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable ||
target.closest('[contenteditable="true"]')
if (!isEditing) {
e.preventDefault()
onOpenChange(true)
}
}
}
document.addEventListener('keydown', down)
return () => document.removeEventListener('keydown', down)
}, [open, onOpenChange])
// Group results by category
const groupedResults = results.reduce((acc, item) => {
if (!acc[item.category]) {
acc[item.category] = []
}
acc[item.category].push(item)
return acc
}, {} as Record<string, SearchItem[]>)
return (
<CommandDialog
open={open}
onOpenChange={onOpenChange}
shouldFilter={false}
className="max-w-[calc(100%-1.5rem)] sm:max-w-[500px] md:max-w-[540px]"
>
<CommandInput
placeholder="搜索页面和功能..."
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandEmpty>没有找到相关内容,换个词试试?</CommandEmpty>
{Object.entries(groupedResults).map(([category, items]) => {
const Icon = categoryIcons[category as keyof typeof categoryIcons]
return (
<CommandGroup key={category} heading={categoryLabels[category as keyof typeof categoryLabels]}>
{items.map((item) => (
<CommandItem
key={item.id}
value={item.title}
onSelect={() => handleSelect(item, isCtrlPressed)}
className="flex items-center justify-between"
>
<div className="flex min-w-0 items-center gap-3">
<div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted/70">
<Icon className="size-4" />
</div>
<div className="flex min-w-0 flex-col">
<span>
{item.matchRange ? (
<>
{item.title.substring(0, item.matchRange[0])}
<span className="text-primary font-bold">
{item.title.substring(item.matchRange[0], item.matchRange[1] + 1)}
</span>
{item.title.substring(item.matchRange[1] + 1)}
</>
) : (
item.title
)}
</span>
<span className="truncate text-[11px] leading-4 text-muted-foreground">{item.description}</span>
</div>
</div>
<span className="ml-3 shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-[10px] leading-none font-medium text-muted-foreground">
{categoryLabels[item.category as keyof typeof categoryLabels]}
</span>
</CommandItem>
))}
</CommandGroup>
)
})}
</CommandList>
<div className="hidden border-t bg-muted/20 px-4 py-2.5 md:flex items-center gap-4 text-[10px] text-muted-foreground uppercase tracking-wider font-medium select-none">
<div className="flex items-center gap-1">
<KbdGroup>
{isCtrlPressed && <Kbd>{metaKey}</Kbd>}
<Kbd>↵</Kbd>
</KbdGroup>
<span>{isCtrlPressed ? '在新标签页打开' : '打开'}</span>
</div>
<div className="flex items-center gap-1">
<Kbd>Esc</Kbd>
<span>关闭搜索界面</span>
</div>
<div className="ml-auto flex items-center gap-1">
{currentTip}
</div>
</div>
</CommandDialog>
)
}
@@ -0,0 +1,417 @@
"use client"
import * as React from "react"
import Link from "next/link"
import {usePathname, useRouter} from "next/navigation"
import {APP_BUILD_DATE, APP_VERSION} from "@/lib/app-info"
import {toast} from "sonner"
import {Button} from "@/components/ui/button"
import {Spinner} from "@/components/ui/spinner"
import {AnimateIcon} from "@/components/animate-ui/icons/icon"
import {ChevronLeft} from "@/components/animate-ui/icons/chevron-left"
import {ChevronRight} from "@/components/animate-ui/icons/chevron-right"
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {getCurrentTheme} from "@/components/layout/avater-style/registry"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
ArrowUpRight,
Bell,
ChevronDown,
Code,
CreditCard,
Database,
FileQuestionMark,
FileText,
FolderOpen,
Home,
Layers,
LogOut,
Settings,
ShieldCheck,
Terminal,
UserRound,
} from "lucide-react"
import {useUser} from "@/contexts/user-context"
import {usePublicConfig} from "@/hooks/use-public-config"
/* 导航数据 */
const data = {
navMain: [
{ title: "首页", url: "/home", icon: Home },
{ title: "我的文件", url: "/files", icon: FolderOpen },
],
admin: [
{ title: "用户管理", url: "/admin/users", icon: UserRound },
{ title: "任务管理", url: "/admin/tasks", icon: Layers },
{ title: "存储管理", url: "/admin/files", icon: FolderOpen },
{ title: "数据管理", url: "/admin/database", icon: Database },
{ title: "通知推送", url: "/admin/push", icon: Bell },
{ title: "系统日志", url: "/admin/logs", icon: Terminal },
{ title: "系统配置", url: "/admin/system", icon: ShieldCheck },
{ title: "系统设置", url: "/admin/settings", icon: Settings },
],
document: [
{ title: "规范示例", url: "/admin/demo", icon: Code },
{ title: "接口文档", url: "/docs/api", icon: CreditCard, external: true },
{ title: "使用文档", url: "/docs/how-to-use", icon: FileText, external: true },
],
}
function parseMenuDisplayConfig(raw: string | undefined): Record<string, boolean> {
if (!raw) return {}
try {
const parsed: unknown = JSON.parse(raw)
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}
return Object.entries(parsed).reduce<Record<string, boolean>>((result, [key, value]) => {
if (typeof value === "boolean") {
result[key] = value
}
return result
}, {})
} catch {
return {}
}
}
/**
* 应用侧边栏组件
* 显示应用侧边栏
*
* @example
* ```tsx
* <AppSidebar />
* ```
* @returns {React.ReactNode} 应用侧边栏组件
*/
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { toggleSidebar, state, isMobile, setOpenMobile } = useSidebar()
const {user, logout, loading: userLoading} = useUser()
const { config } = usePublicConfig()
const [showLogoutDialog, setShowLogoutDialog] = React.useState(false)
const [isLoggingOut, setIsLoggingOut] = React.useState(false)
const pathname = usePathname()
const router = useRouter()
const currentTheme = getCurrentTheme()
const {
MainDecoration,
InteractionDecoration,
BackgroundEffect,
smallMainDecorationConfig,
largeMainDecorationConfig,
interactionDecorationConfig
} = currentTheme
const hasAvatarDecoration = currentTheme.key !== "none"
const [showEffect, setShowEffect] = React.useState(false)
const [userMenuOpen, setUserMenuOpen] = React.useState(false)
const userMenuLastToggle = React.useRef(0)
const handleUserMenuOpenChange = React.useCallback((open: boolean) => {
const now = Date.now()
if (now - userMenuLastToggle.current < 65) return
userMenuLastToggle.current = now
setUserMenuOpen(open)
}, [])
const handleCloseSidebar = React.useCallback(() => {
if (isMobile) {
setOpenMobile(false)
}
}, [isMobile, setOpenMobile])
const handleLogout = async () => {
setIsLoggingOut(true)
try {
await logout()
setShowLogoutDialog(false)
} catch (error) {
toast.error("登出失败", {
description: error instanceof Error ? error.message : "登出时发生错误,请重试"
})
setIsLoggingOut(false)
}
}
const navMainFiltered = React.useMemo(() => {
const displayConfig = parseMenuDisplayConfig(config?.menu_display_config)
return data.navMain.filter((item) => displayConfig[item.url] !== false)
}, [config])
const adminFiltered = React.useMemo(() => {
const displayConfig = parseMenuDisplayConfig(config?.menu_display_config)
return data.admin.filter((item) => displayConfig[item.url] !== false)
}, [config])
const documentFiltered = React.useMemo(() => {
const displayConfig = parseMenuDisplayConfig(config?.menu_display_config)
return data.document.filter((item) => displayConfig[item.url] !== false)
}, [config])
return (
<>
{hasAvatarDecoration && showEffect && BackgroundEffect && <BackgroundEffect />}
<Sidebar collapsible="icon" {...props} className="px-2 relative border-r border-border/40 group-data-[collapsible=icon]">
<Button
onClick={toggleSidebar}
variant="ghost"
size="icon"
className="absolute top-1/2 -right-6 w-2 h-4 text-muted-foreground hover:bg-background hidden md:flex"
>
{state === "expanded" ? (
<AnimateIcon animateOnHover>
<ChevronLeft className="size-6" />
</AnimateIcon>
) : (
<AnimateIcon animateOnHover>
<ChevronRight className="size-6" />
</AnimateIcon>
)}
</Button>
<SidebarHeader className="py-4 md:-ml-2">
<DropdownMenu open={userMenuOpen} onOpenChange={handleUserMenuOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="flex h-12 hover:bg-accent group-data-[collapsible=icon]:ml-2"
suppressHydrationWarning
>
<div className="relative overflow-visible">
{hasAvatarDecoration && <MainDecoration className={smallMainDecorationConfig?.className} />}
<Avatar className="size-6 rounded relative z-10">
<AvatarImage
src={user?.avatar_url}
alt={user?.nickname}
/>
<AvatarFallback className="rounded bg-muted text-sm">
{user?.nickname?.charAt(0)?.toUpperCase() || "L"}
</AvatarFallback>
</Avatar>
</div>
<div className="flex flex-col items-start flex-1 text-left group-data-[collapsible=icon]:hidden">
<span className="text-xs font-medium truncate w-full text-left ml-2">
{user?.nickname || user?.username || "Unknown User"}
</span>
<span className="text-[11px] font-medium text-muted-foreground/100 truncate w-full text-left ml-2">
{user?.is_admin ? "系统管理员" : "普通用户"}
</span>
</div>
<ChevronDown className="size-4 text-muted-foreground ml-auto group-data-[collapsible=icon]:hidden" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className={isMobile || state === "collapsed" ? "ml-1 w-68 z-[200]" : "w-64 z-[200]"}
align="start"
side={isMobile || state === "collapsed" ? "bottom" : "right"}
sideOffset={4}
>
<DropdownMenuLabel>
<div className="flex flex-col items-center mb-4 gap-1 pt-4">
<div
className={`relative w-32 h-16 flex items-center justify-center overflow-visible select-none transition-transform ${hasAvatarDecoration ? "cursor-pointer active:scale-95" : ""}`}
onClick={hasAvatarDecoration ? () => setShowEffect(!showEffect) : undefined}
>
{hasAvatarDecoration && (
<InteractionDecoration
show={showEffect}
className={interactionDecorationConfig?.className}
/>
)}
<div className="relative z-10">
{hasAvatarDecoration && <MainDecoration className={largeMainDecorationConfig?.className} />}
<Avatar className="size-14 rounded-full pointer-events-none">
<AvatarImage
src={user?.avatar_url}
alt={user?.nickname}
/>
<AvatarFallback className="rounded-full bg-transparent text-lg">
{user?.nickname?.charAt(0)?.toUpperCase() || "L"}
</AvatarFallback>
</Avatar>
</div>
</div>
<span className="text-base font-semibold truncate mt-2">
{userLoading
? "加载中..."
: user?.nickname || user?.username || "未登录"}
</span>
<span className="text-xs font-base text-muted-foreground">
{userLoading ? "正在同步账户" : user?.is_admin ? "系统管理员" : "普通用户"}
</span>
</div>
</DropdownMenuLabel>
<DropdownMenuItem onClick={() => {
router.push("/settings/profile")
handleCloseSidebar()
}}>
<UserRound className="mr-2 size-4" />
<span>我的资料</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => {
router.push("/settings")
handleCloseSidebar()
}}>
<Settings className="mr-2 size-4" />
<span>设置</span>
</DropdownMenuItem>
<DropdownMenuSeparator className="my-2" />
<DropdownMenuItem onClick={() => {
router.push("/docs/how-to-use")
handleCloseSidebar()
}}>
<FileQuestionMark className="mr-2 size-4" />
<span>使用帮助</span>
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive hover:bg-destructive/50" onClick={() => setShowLogoutDialog(true)}>
<LogOut className="mr-2 size-4 text-destructive" />
<span>退出登录</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarHeader>
<SidebarContent className="group-data-[collapsible=icon]">
{navMainFiltered.length > 0 && (
<SidebarGroup className="py-0">
<SidebarGroupContent className="py-1">
<SidebarMenu className="gap-1">
{navMainFiltered.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
isActive={pathname === item.url}
asChild
>
<Link href={item.url} onClick={handleCloseSidebar}>
{item.icon && <item.icon />}
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)}
{user?.is_admin && adminFiltered.length > 0 && (
<SidebarGroup className="py-0 pt-4">
<SidebarGroupLabel className="text-xs font-normal text-muted-foreground">
管理
</SidebarGroupLabel>
<SidebarGroupContent className="py-1">
<SidebarMenu className="gap-1">
{adminFiltered.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
isActive={pathname === item.url || pathname.startsWith(`${item.url}/`)}
asChild
>
<Link href={item.url} onClick={handleCloseSidebar}>
{item.icon && <item.icon />}
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)}
{documentFiltered.length > 0 && (
<SidebarGroup className="py-0 pt-4">
<SidebarGroupLabel className="text-xs font-normal text-muted-foreground">
文档库
</SidebarGroupLabel>
<SidebarGroupContent className="py-1">
<SidebarMenu className="gap-1">
{documentFiltered.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
isActive={pathname === item.url}
asChild
>
{item.external ? (
<Link href={item.url} target="_blank" rel="noopener noreferrer" onClick={handleCloseSidebar}>
{item.icon && <item.icon />}
<span className="flex-1">{item.title}</span>
<ArrowUpRight className="size-3 text-muted-foreground" />
</Link>
) : (
<Link href={item.url} onClick={handleCloseSidebar}>
{item.icon && <item.icon />}
<span className="flex-1">{item.title}</span>
</Link>
)}
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)}
</SidebarContent>
<SidebarFooter className="mt-auto px-3 py-3 group-data-[collapsible=icon]:hidden">
<div className="border-t border-border/60 pt-3 text-[11px] leading-5 text-muted-foreground">
<div>Version {APP_VERSION}</div>
{APP_BUILD_DATE && <div>Build At {APP_BUILD_DATE}</div>}
</div>
</SidebarFooter>
</Sidebar>
<AlertDialog open={showLogoutDialog} onOpenChange={(open) => !isLoggingOut && setShowLogoutDialog(open)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认登出</AlertDialogTitle>
<AlertDialogDescription>
{isLoggingOut ? '正在登出,请稍候...' : '您确定要登出当前账户吗?'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoggingOut}>取消</AlertDialogCancel>
<AlertDialogAction onClick={handleLogout} disabled={isLoggingOut}>
{isLoggingOut && <Spinner className="mr-2" />}
{isLoggingOut ? '登出中...' : '确认登出'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
@@ -0,0 +1,11 @@
"use client"
import * as React from "react"
import {ThemeProvider as NextThemesProvider} from "next-themes"
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
@@ -0,0 +1,30 @@
import {motion} from "motion/react"
/**
* 页面过渡组件
* 用于统一显示页面过渡效果
*
* @example
* ```tsx
* <PageTransition>
* <div>内容</div>
* </PageTransition>
* ```
* @param {React.ReactNode} children - 页面过渡组件的子元素
* @returns {React.ReactNode} 页面过渡组件
*/
export function PageTransition({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0.6,
ease: "easeOut",
}}
>
{children}
</motion.div>
)
}