压缩历史至 95081aff

This commit is contained in:
ryan
2026-06-08 20:34:27 +08:00
commit 8a782525de
435 changed files with 71146 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
}
}
+143
View File
@@ -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>
)
}
+159
View File
@@ -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>
)
}
+201
View File
@@ -0,0 +1,201 @@
"use client"
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"
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 />
<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 className="mx-1 size-7 rounded-full bg-primary text-primary-foreground hover:bg-primary/90" onClick={() => router.push('/merchant')}>
<Plus className="size-4" />
<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">「LINUX DO Credit」实时积分收入脚本已发布</span>
<a href="https://linux.do/t/topic/1365853" 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>
)
}
+227
View File
@@ -0,0 +1,227 @@
import * as React from "react"
import { motion } from "motion/react"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { FileTextIcon } from "lucide-react"
/**
* 加载页面组件
*
* 用于统一显示加载状态
* @example
* ```tsx
* <LoadingPage text="系统" badgeText="系统" />
* ```
* @param {string} text - 显示的文本内容,默认为"系统"
* @param {string} badgeText - 显示的徽章文本,默认为"系统"
* @returns {React.ReactNode} 加载页面组件
*/
function LoadingCard() {
return (
<div className="relative w-[240px] aspect-[1.586] mx-auto perspective-1200 group">
<div className={cn("absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[120%] h-[120%] blur-[80px] pointer-events-none transition-colors duration-1000 bg-primary/10")} />
<motion.div
initial={{ rotateY: 0, rotateX: 0, y: 0 }}
animate={{
rotateY: [-10, 10, -10],
rotateX: [4, -4, 4],
y: [-6, 6, -6],
}}
transition={{
duration: 8,
repeat: Infinity,
ease: "easeInOut",
}}
style={{ transformStyle: "preserve-3d" }}
className="relative w-full h-full"
>
<div
className="absolute -bottom-10 left-1/2 -translate-x-1/2 w-[85%] h-4 bg-black/40 blur-xl rounded-[100%] transition-all duration-500"
style={{ transform: 'translateZ(-20px)' }}
/>
<div className={cn("absolute inset-0 rounded-2xl bg-[#1a1a1a] shadow-2xl border border-white/10 overflow-hidden backdrop-blur-xl")}>
<div className="absolute inset-0 bg-gradient-to-br from-white/5 to-transparent pointer-events-none" />
<div className="absolute inset-0 opacity-[0.05] bg-[url('https://grainy-gradients.vercel.app/noise.svg')] mix-blend-overlay" />
<div className="relative h-full p-6 flex flex-col justify-between z-10">
<div className="flex justify-between items-start">
<div className="relative w-11 h-8 rounded-md bg-[#d4d4d8] shadow-inner overflow-hidden flex items-center justify-center">
<div className="absolute inset-0 bg-[linear-gradient(135deg,transparent_0%,rgba(0,0,0,0.1)_50%,transparent_100%)]" />
<div className="w-full h-[1px] bg-black/10 absolute top-1/2" />
<div className="h-full w-[1px] bg-black/10 absolute left-1/3" />
<div className="h-full w-[1px] bg-black/10 absolute right-1/3" />
</div>
<div className="flex flex-col items-end gap-[2px]">
<div className="w-1.5 h-1.5 rounded-full bg-white/20" />
<div className="w-1.5 h-1.5 rounded-full bg-white/40" />
<div className="w-1.5 h-1.5 rounded-full bg-white/80" />
</div>
</div>
<div className="space-y-3 mt-4">
<div className="flex gap-2">
<div className="h-1 w-12 rounded-full bg-white/10" />
<div className="h-1 w-8 rounded-full bg-white/10" />
</div>
</div>
<div className="flex justify-between items-end">
<div className="space-y-1.5">
<div className="h-1.5 w-20 rounded-full bg-white/20" />
<div className="h-1.5 w-12 rounded-full bg-white/20" />
</div>
<div className="font-serif italic text-xl font-bold tracking-widest text-white drop-shadow-md">
Credit
</div>
</div>
</div>
<motion.div
className="absolute -inset-[200%] bg-gradient-to-r from-transparent via-white/5 to-transparent -skew-x-12"
animate={{
x: ['-100%', '200%'],
}}
transition={{
duration: 3,
repeat: Infinity,
ease: "easeInOut",
repeatDelay: 2
}}
style={{ mixBlendMode: 'overlay' }}
/>
</div>
<div
className={cn("absolute inset-0 rounded-2xl translate-z-[-2px] border border-white/5 bg-[#0f0f0f]")}
style={{ transform: 'translateZ(-2px)' }}
/>
<div
className={cn("absolute inset-0 rounded-2xl translate-z-[-4px] border border-white/5 bg-[#0f0f0f]")}
style={{ transform: 'translateZ(-4px)' }}
/>
<div
className="absolute inset-0 rounded-2xl bg-black/80 translate-z-[-10px] blur-xl"
style={{ transform: 'translateZ(-15px)' }}
/>
</motion.div>
</div>
)
}
/**
* 加载页面组件
*
* 用于统一显示加载状态
*/
export function LoadingPage({ text = "系统", badgeText = "系统" }: { text?: string, badgeText?: string }) {
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">
<div className="w-full max-w-md mx-auto text-center px-4 flex flex-col items-center gap-8">
<LoadingCard />
<div className="space-y-3 w-full flex flex-col items-center">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="relative flex items-center justify-center gap-2"
>
<span className="text-3xl font-bold tracking-tight text-foreground">LINUX DO</span>
<span className="text-4xl font-serif font-bold italic text-primary">Credit</span>
{badgeText && (
<div className="absolute left-full top-1/3 -translate-y-1/2 ml-2">
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-5 border-foreground/20 text-muted-foreground whitespace-nowrap">
{badgeText}
</Badge>
</div>
)}
</motion.div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
className="flex flex-col items-center justify-center gap-4 text-sm text-muted-foreground w-full max-w-[200px]"
>
<div className="relative h-1 w-full overflow-hidden rounded-full bg-muted">
<motion.div
className="absolute inset-y-0 left-0 h-full w-1/3 rounded-full bg-primary"
animate={{
x: ["-100%", "300%"],
}}
transition={{
duration: 1.2,
repeat: Infinity,
ease: "easeInOut",
}}
/>
</div>
<span className="font-mono text-xs tracking-wider opacity-60">{text.toUpperCase()}</span>
</motion.div>
</div>
</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,272 @@
"use client"
import { useEffect, useState, useCallback } 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 { searchItems, type SearchItem } from "@/lib/utils/search-data"
import { Home, Settings, FileText, 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>
</>
),
(
<>
<span className="text-muted-foreground/80 lowercase">你知道吗:积分会在每天 0 点更新</span>
</>
),
(
<>
<span className="text-muted-foreground/80 lowercase">你知道吗:新用户前三天享有划转掉分保护</span>
</>
),
(
<>
<span className="text-muted-foreground/80 lowercase">想实时查看积分变化?试试这个</span>
<a
href="https://linux.do/t/topic/1365853"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline mx-1"
>
脚本
</a>
</>
),
(
<>
<span className="text-muted-foreground/80 lowercase">遇到问题?欢迎提交</span>
<a
href="https://github.com/linux-do/credit/issues"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline mx-1"
>
Issue
</a>
<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>
)
}
+392
View File
@@ -0,0 +1,392 @@
"use client"
import * as React from "react"
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
import packageJson from "../../package.json"
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,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} 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 {
Home,
CreditCard,
Settings,
Wallet,
FileText,
CircleDollarSign,
LogOut,
Store,
ChevronDown,
UserRound,
FileQuestionMark,
ShieldCheck,
Globe,
Layers,
Trophy,
ArrowUpRight,
} from "lucide-react"
import { useUser } from "@/contexts/user-context"
/* 导航数据 */
const data = {
navMain: [
{ title: "首页", url: "/home", icon: Home },
{ title: "集市", url: "/merchant", icon: Store },
{ title: "积分", url: "/balance", icon: Wallet },
{ title: "活动", url: "/trade", icon: CircleDollarSign },
{ title: "排行榜", url: "/leaderboard", icon: Trophy },
],
admin: [
{ title: "系统配置", url: "/admin/system", icon: ShieldCheck },
{ title: "积分配置", url: "/admin/credit", icon: Settings },
{ title: "用户管理", url: "/admin/users", icon: UserRound },
{ title: "任务管理", url: "/admin/tasks", icon: Layers },
],
document: [
{ title: "接口文档", url: "/docs/api", icon: CreditCard },
{ title: "使用文档", url: "/docs/how-to-use", icon: FileText },
],
products: [
{ title: "在线流转", url: "/merchant/online-paying", icon: Globe },
],
}
/**
* 应用侧边栏组件
* 显示应用侧边栏
*
* @example
* ```tsx
* <AppSidebar />
* ```
* @returns {React.ReactNode} 应用侧边栏组件
*/
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { toggleSidebar, state, isMobile, setOpenMobile } = useSidebar()
const { user, getTrustLevelLabel, logout } = useUser()
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)
}
}
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 ? getTrustLevelLabel(user.trust_level) : "Trust Level Unknown"}
</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">
{user?.nickname || user?.username || "Unknown User"}
</span>
<span className="text-xs font-base text-muted-foreground">
{user ? getTrustLevelLabel(user.trust_level) : "Trust Level Unknown"}
</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]">
<SidebarGroup className="py-0">
<SidebarGroupContent className="py-1">
<SidebarMenu className="gap-1">
{data.navMain.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 && (
<SidebarGroup className="py-0 pt-4">
<SidebarGroupLabel className="text-xs font-normal text-muted-foreground">
管理
</SidebarGroupLabel>
<SidebarGroupContent className="py-1">
<SidebarMenu className="gap-1">
{data.admin.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>
)}
<SidebarGroup className="py-0 pt-4">
<SidebarGroupLabel className="text-xs font-normal text-muted-foreground">
文档库
</SidebarGroupLabel>
<SidebarGroupContent className="py-1">
<SidebarMenu className="gap-1">
{data.document.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
asChild
>
<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>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup className="py-0 pt-4">
<SidebarGroupLabel className="text-xs font-normal text-muted-foreground">
服务
</SidebarGroupLabel>
<SidebarGroupContent className="py-1">
<SidebarMenu className="gap-1">
{data.products.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
asChild
>
<Link href={item.url} onClick={handleCloseSidebar}>
{item.icon && <item.icon />}
<span>{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 {packageJson.version}</div>
<div>Build At {packageJson.buildDate}</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>
}
+30
View File
@@ -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>
)
}