This commit is contained in:
ryan
2026-07-13 15:17:45 +08:00
parent 08e61ce833
commit e0eb4e5975
334 changed files with 17212 additions and 12321 deletions
@@ -1,21 +1,21 @@
/**
* 圣诞节主题装饰组件归档
* 包含圣诞树、圣诞帽图标以及下雪效果的集成组件
*
*
* 使用示例:
* ```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"
* <ChristmasDecorations.Tree
* className="w-12 h-12"
* show={showChristmas}
* />
* </div>
@@ -25,64 +25,85 @@
* ```
*/
import { cn } from "@/lib/utils"
import { SnowEffect } from "@/components/ui/snow-effect"
import { cn } from '@/lib/utils';
import { SnowEffect } from '@/components/ui/snow-effect';
/**
* 圣诞树图标组件
*/
export const ChristmasTree = ({
className,
show = true
show = true,
}: {
className?: string
show?: boolean
className?: string;
show?: boolean;
}) => (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/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
'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" />
<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"
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"
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'
/>
<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>
)
);
/**
* 圣诞节装饰集合
@@ -92,6 +113,6 @@ export const ChristmasDecorations = {
Tree: ChristmasTree,
Hat: ChristmasHat,
SnowEffect: SnowEffect,
}
};
export default ChristmasDecorations
export default ChristmasDecorations;
@@ -1,21 +1,21 @@
/**
* 元旦/新年主题装饰组件归档
* 包含灯笼、福字以及烟花效果的集成组件
*
*
* 使用示例:
* ```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"
* <NewYearDecorations.Fu
* className="w-12 h-12"
* show={showNewYear}
* />
* </div>
@@ -25,42 +25,107 @@
* ```
*/
import { cn } from "@/lib/utils"
import { FireworksEffect } from "@/components/ui/fireworks-effect"
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"
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" />
<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" />
<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='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" />
<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>
<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='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" />
<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>
)
);
/**
* "福"字挂件 - 点击出现的装饰
@@ -68,46 +133,68 @@ export const Lantern = ({ className }: { className?: string }) => (
*/
export const FuCharacter = ({
className,
show = true
show = true,
}: {
className?: string
show?: boolean
className?: string;
show?: boolean;
}) => (
<svg
viewBox="0 0 100 100"
fill="none"
xmlns="http://www.w3.org/2000/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
'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" />
<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"
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)"
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" />
<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>
)
);
/**
* 元旦/新年装饰集合
@@ -116,6 +203,6 @@ export const NewYearDecorations = {
Lantern,
Fu: FuCharacter,
FireworksEffect,
}
};
export default NewYearDecorations
export default NewYearDecorations;
@@ -1,48 +1,54 @@
import { AvatarDecorationTheme } from "./types"
import { ChristmasDecorations } from "./Christmas"
import { NewYearDecorations } from "./NewYear"
import { AvatarDecorationTheme } from './types';
import { ChristmasDecorations } from './Christmas';
import { NewYearDecorations } from './NewYear';
export const DecorationThemes: Record<string, AvatarDecorationTheme> = {
christmas: {
key: "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"
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"
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"
}
className:
'absolute -top-2 right-2 w-12 h-12 z-0 rotate-[10deg] transition-all duration-500 ease-out',
},
},
newyear: {
key: "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"
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"
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"
}
className:
'absolute top-6 left-4 w-10 h-10 z-20 -rotate-[5deg] transition-all duration-500 ease-out',
},
},
none: {
key: "none",
key: 'none',
MainDecoration: () => null,
InteractionDecoration: () => null,
}
}
},
};
/* 可选值: 'christmas', 'newyear', 'none' */
export const CURRENT_THEME_KEY: string = "none"
export const CURRENT_THEME_KEY: string = 'none';
export const getCurrentTheme = (): AvatarDecorationTheme => {
return DecorationThemes[CURRENT_THEME_KEY] || DecorationThemes.none
}
return DecorationThemes[CURRENT_THEME_KEY] || DecorationThemes.none;
};
@@ -1,17 +1,17 @@
import { ComponentType } from "react"
import { ComponentType } from 'react';
export interface AvatarDecorationTheme {
key: string
MainDecoration: ComponentType<{ className?: string }>
InteractionDecoration: ComponentType<{ className?: string; show?: boolean }>
BackgroundEffect?: ComponentType
key: string;
MainDecoration: ComponentType<{ className?: string }>;
InteractionDecoration: ComponentType<{ className?: string; show?: boolean }>;
BackgroundEffect?: ComponentType;
smallMainDecorationConfig?: {
className?: string
}
className?: string;
};
largeMainDecorationConfig?: {
className?: string
}
className?: string;
};
interactionDecorationConfig?: {
className?: string
}
className?: string;
};
}
+54 -51
View File
@@ -1,45 +1,44 @@
import * as React from "react"
import { FileTextIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
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"
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="当前没有任何记录"
* />
*
*
* // 带操作按钮
* <EmptyState
* <EmptyState
* title="暂无交易记录"
* description="还没有任何交易"
* actionText="创建交易"
* onAction={() => router.push('/create')}
* />
*
*
* // 自定义图标
* <EmptyState
* <EmptyState
* icon={BarChartIcon}
* description="未发现提现记录"
* />
@@ -54,49 +53,48 @@ export function EmptyState({
actionText,
onAction,
className,
iconSize = "md",
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" }
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
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>
)}
{title && <h3 className='text-base font-medium mb-1'>{title}</h3>}
{description && (
<p className="text-sm text-muted-foreground max-w-md">
{description}
</p>
<p className='text-sm text-muted-foreground max-w-md'>{description}</p>
)}
{onAction && actionText && (
<Button
onClick={onAction}
variant="outline"
className="mt-4"
>
<Button onClick={onAction} variant='outline' className='mt-4'>
{actionText}
</Button>
)}
</div>
)
);
}
/**
* 带边框的空状态组件
* 适合在卡片或容器内使用
*
*
* @example
* ```tsx
* <EmptyStateWithBorder
@@ -109,16 +107,16 @@ export function EmptyState({
*/
export function EmptyStateWithBorder(props: EmptyStateProps) {
return (
<div className="border border-dashed rounded-lg">
<div className='border border-dashed rounded-lg'>
<EmptyState {...props} />
</div>
)
);
}
/**
* 简化版空状态组件(内联使用)
* 适合在较小的区域显示
*
*
* @example
* ```tsx
* <EmptyInline
@@ -130,14 +128,19 @@ export function EmptyStateWithBorder(props: EmptyStateProps) {
* @returns {React.ReactNode} 简化版空状态组件
*/
export function EmptyInline({
message = "暂无数据",
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" />
<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>
)
);
}
+67 -60
View File
@@ -1,45 +1,44 @@
import * as React from "react"
import { AlertCircle } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
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
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
* title="加载失败"
* message="无法获取数据,请稍后重试"
* />
*
*
* // 带重试按钮
* <ErrorDisplay
* <ErrorDisplay
* error={error}
* onRetry={() => refetch()}
* onRetry={() => refetch()}
* />
*
*
* // 自定义样式
* <ErrorDisplay
* <ErrorDisplay
* title="网络错误"
* message="请检查您的网络连接"
* className="min-h-[400px]"
@@ -49,61 +48,64 @@ export interface ErrorDisplayProps {
* @returns {React.ReactNode} 错误展示组件
*/
export function ErrorDisplay({
title = "加载失败",
title = '加载失败',
message,
error,
onRetry,
retryText = "重试",
retryText = '重试',
icon: Icon = AlertCircle,
className,
showStack = false,
}: ErrorDisplayProps) {
const errorMessage = message || error?.message || "发生未知错误,请稍后重试"
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
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>
<h3 className='text-lg font-semibold mb-2'>{title}</h3>
<p className="text-sm text-muted-foreground max-w-md mb-4">{errorMessage}</p>
<p className='text-sm text-muted-foreground max-w-md mb-4'>
{errorMessage}
</p>
{onRetry && (
<Button
onClick={onRetry}
variant="outline"
className="mt-2"
>
<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">
<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">
<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()}
* <ErrorInline
* message="发生错误"
* error={error}
* onRetry={() => refetch()}
* />
* ```
*/
@@ -113,47 +115,52 @@ export function ErrorInline({
onRetry,
className,
}: Omit<ErrorDisplayProps, 'title' | 'icon' | 'showStack'>) {
const errorMessage = message || error?.message || "发生未知错误"
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>
<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"
variant='secondary'
size='sm'
className='h-6 px-4 text-xs'
>
重试
</Button>
)}
</div>
)
);
}
/**
* 页面级错误组件
* 适合作为页面主要内容的错误状态
*
*
* @example
* ```tsx
* <ErrorPage
* title="加载失败"
* message="无法获取数据,请稍后重试"
* error={error}
* onRetry={() => refetch()}
* <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)]">
<div className='flex items-center justify-center min-h-[calc(100vh-8rem)]'>
<ErrorDisplay
{...props}
showStack={props.showStack ?? process.env.NODE_ENV === 'development'}
/>
</div>
)
);
}
+184 -111
View File
@@ -1,51 +1,65 @@
"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"
'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)
const { isRinging } = useBellRing();
const { showBell, isMounted } = useNotificationSettings();
const [isClickAnimating, setIsClickAnimating] = useState(false);
if (!isMounted) return null
if (!showBell) return null
if (!isMounted) return null;
if (!showBell) return null;
const handleClick = () => {
setIsClickAnimating(true)
setTimeout(() => setIsClickAnimating(false), 600)
}
setIsClickAnimating(true);
setTimeout(() => setIsClickAnimating(false), 600);
};
return (
<Button
variant="ghost"
size="icon"
className="size-9 text-muted-foreground hover:text-foreground"
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}
className='size-[18px]'
style={
isRinging || isClickAnimating
? {
animation: 'var(--animate-bell-ring)',
transformOrigin: 'top center',
}
: undefined
}
/>
<span className="sr-only">通知</span>
<span className='sr-only'>通知</span>
</Button>
)
})
);
});
/**
* 站点头部组件
@@ -56,158 +70,217 @@ const BellButton = memo(function BellButton() {
* <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)
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("⌘")
const [metaKey, setMetaKey] = useState('⌘');
useEffect(() => {
setMounted(true)
if (typeof navigator !== 'undefined' && !navigator.userAgent?.includes("Mac")) {
setMetaKey("Ctrl")
setMounted(true);
if (
typeof navigator !== 'undefined' &&
!navigator.userAgent?.includes('Mac')
) {
setMetaKey('Ctrl');
}
}, [])
}, []);
return (
<div className="flex flex-col w-full">
<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" />
<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")}
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">
<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 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>
<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
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">
<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">
<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">
<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
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
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
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} />}
{mounted && (
<SearchDialog open={searchOpen} onOpenChange={setSearchOpen} />
)}
</header>
</div>
)
);
}
function AppBanner() {
const [isVisible, setIsVisible] = useState(false)
const bannerKey = 'ldc-banner-dismissed-script'
const [isVisible, setIsVisible] = useState(false);
const bannerKey = 'ldc-banner-dismissed-script';
useEffect(() => {
const dismissed = localStorage.getItem(bannerKey)
const dismissed = localStorage.getItem(bannerKey);
if (!dismissed) {
setIsVisible(true)
setIsVisible(true);
}
}, [])
}, []);
const handleDismiss = () => {
localStorage.setItem(bannerKey, 'true')
setIsVisible(false)
}
localStorage.setItem(bannerKey, 'true');
setIsVisible(false);
};
return (
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
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"
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">
<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"
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>
<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"
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" />
<path d='M18 6 6 18' />
<path d='m6 6 12 12' />
</svg>
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
)
);
}
+57 -42
View File
@@ -1,7 +1,7 @@
import * as React from "react"
import {motion} from "motion/react"
import {cn} from "@/lib/utils"
import {FileTextIcon} from "lucide-react"
import * as React from 'react';
import { motion } from 'motion/react';
import { cn } from '@/lib/utils';
import { FileTextIcon } from 'lucide-react';
/**
* 加载页面组件
@@ -16,11 +16,17 @@ import {FileTextIcon} from "lucide-react"
* @returns {React.ReactNode} 加载页面组件
*/
export function AppleSpinner({ className = "size-8 text-muted-foreground" }: { className?: string }) {
const spokes = Array.from({ length: 12 })
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: `
<div className={cn('relative flex items-center justify-center', className)}>
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes apple-spinner-fade {
0% { opacity: 1; }
100% { opacity: 0.15; }
@@ -28,22 +34,24 @@ export function AppleSpinner({ className = "size-8 text-muted-foreground" }: { c
.apple-spinner-spoke {
animation: apple-spinner-fade 1.2s linear infinite;
}
`}} />
<div className="relative size-full">
`,
}}
/>
<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"
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%",
transformOrigin: '50% 178.5%',
animationDelay: `${(i - 12) * 0.1}s`,
}}
/>
))}
</div>
</div>
)
);
}
/**
@@ -53,26 +61,26 @@ export function AppleSpinner({ className = "size-8 text-muted-foreground" }: { c
*/
export function LoadingPage(props: { text?: string; badgeText?: string }) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _ = props
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">
<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"
transition={{ duration: 0.25, ease: 'easeOut' }}
className='flex items-center justify-center'
>
<AppleSpinner className="size-9 text-muted-foreground/80" />
<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"
interface LoadingStateProps extends React.ComponentProps<'div'> {
title?: string;
description?: string;
icon?: React.ComponentType<{ className?: string }>;
iconSize?: 'sm' | 'md' | 'lg';
}
/**
@@ -80,37 +88,44 @@ interface LoadingStateProps extends React.ComponentProps<"div"> {
* 用于统一显示加载中的状态
*/
export function LoadingState({
title = "加载中",
description = "正在获取活动数据...",
title = '加载中',
description = '正在获取活动数据...',
icon: Icon = FileTextIcon,
className,
iconSize = "md",
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" }
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
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>
<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">
<p className='text-xs text-muted-foreground max-w-md animate-pulse'>
{description}
</p>
)}
</div>
)
);
}
/**
@@ -118,8 +133,8 @@ export function LoadingState({
*/
export function LoadingStateWithBorder(props: LoadingStateProps) {
return (
<div className="border border-dashed rounded-lg">
<div className='border border-dashed rounded-lg'>
<LoadingState {...props} />
</div>
)
);
}
+7 -7
View File
@@ -1,17 +1,17 @@
"use client"
'use client';
import {usePublicConfig} from "@/hooks/use-public-config"
import { usePublicConfig } from '@/hooks/use-public-config';
export function RobotsMeta() {
const { config } = usePublicConfig()
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"
const enabled = config?.search_engine_indexing_enabled === 'true';
return (
<meta
name="robots"
content={enabled ? "index, follow" : "noindex, nofollow"}
name='robots'
content={enabled ? 'index, follow' : 'noindex, nofollow'}
/>
)
);
}
+136 -122
View File
@@ -1,7 +1,7 @@
"use client"
'use client';
import {useCallback, useEffect, useState} from "react"
import {useRouter} from "next/navigation"
import { useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
CommandDialog,
CommandEmpty,
@@ -9,15 +9,15 @@ import {
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"
} 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
open: boolean;
onOpenChange: (open: boolean) => void;
}
const categoryIcons = {
@@ -25,175 +25,185 @@ const categoryIcons = {
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'>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)
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")
if (
typeof navigator !== 'undefined' &&
!navigator.userAgent?.includes('Mac')
) {
setMetaKey('Ctrl');
}
}, [])
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Control' || e.key === 'Meta') {
setIsCtrlPressed(true)
setIsCtrlPressed(true);
}
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Control' || e.key === 'Meta') {
setIsCtrlPressed(false)
setIsCtrlPressed(false);
}
}
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
}
}, [])
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)
const tips = getTips(metaKey);
const randomTip = tips[Math.floor(Math.random() * tips.length)];
setCurrentTip(randomTip);
}
}, [open, metaKey])
}, [open, metaKey]);
useEffect(() => {
const items = searchItems(search, user?.is_admin)
setResults(items)
}, [search, user?.is_admin])
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])
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)
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 (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)
e.preventDefault();
onOpenChange(true);
}
}
}
};
document.addEventListener('keydown', down)
return () => document.removeEventListener('keydown', down)
}, [open, onOpenChange])
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[]>)
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]"
className='max-w-[calc(100%-1.5rem)] sm:max-w-[500px] md:max-w-[540px]'
>
<CommandInput
placeholder="搜索页面和功能..."
placeholder='搜索页面和功能...'
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandEmpty>没有找到相关内容,换个词试试?</CommandEmpty>
{Object.entries(groupedResults).map(([category, items]) => {
const Icon = categoryIcons[category as keyof typeof categoryIcons]
const Icon = categoryIcons[category as keyof typeof categoryIcons];
return (
<CommandGroup key={category} heading={categoryLabels[category as keyof typeof categoryLabels]}>
<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"
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 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">
<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 className='text-primary font-bold'>
{item.title.substring(
item.matchRange[0],
item.matchRange[1] + 1,
)}
</span>
{item.title.substring(item.matchRange[1] + 1)}
</>
@@ -201,34 +211,38 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
item.title
)}
</span>
<span className="truncate text-[11px] leading-4 text-muted-foreground">{item.description}</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 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">
<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">
<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 className='ml-auto flex items-center gap-1'>{currentTip}</div>
</div>
</CommandDialog>
)
);
}
+225 -166
View File
@@ -1,16 +1,16 @@
"use client"
'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 * 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,
@@ -18,8 +18,8 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {getCurrentTheme} from "@/components/layout/avater-style/registry"
} from '@/components/ui/dropdown-menu';
import { getCurrentTheme } from '@/components/layout/avater-style/registry';
import {
Sidebar,
SidebarContent,
@@ -32,7 +32,7 @@ import {
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
} from '@/components/ui/sidebar';
import {
AlertDialog,
AlertDialogAction,
@@ -42,7 +42,7 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
} from '@/components/ui/alert-dialog';
import {
ArrowUpRight,
Bell,
@@ -60,48 +60,59 @@ import {
ShieldCheck,
Terminal,
UserRound,
} from "lucide-react"
} from 'lucide-react';
import {useUser} from "@/contexts/user-context"
import {usePublicConfig} from "@/hooks/use-public-config"
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 },
{ 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 },
{ 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 },
{ 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 {}
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 {}
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
}, {})
return Object.entries(parsed).reduce<Record<string, boolean>>(
(result, [key, value]) => {
if (typeof value === 'boolean') {
result[key] = value;
}
return result;
},
{},
);
} catch {
return {}
return {};
}
}
@@ -116,133 +127,152 @@ function parseMenuDisplayConfig(raw: string | undefined): Record<string, boolean
* @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 { 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 currentTheme = getCurrentTheme();
const {
MainDecoration,
InteractionDecoration,
BackgroundEffect,
smallMainDecorationConfig,
largeMainDecorationConfig,
interactionDecorationConfig
} = currentTheme
const hasAvatarDecoration = currentTheme.key !== "none"
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 [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 now = Date.now();
if (now - userMenuLastToggle.current < 65) return;
userMenuLastToggle.current = now;
setUserMenuOpen(open);
}, []);
const handleCloseSidebar = React.useCallback(() => {
if (isMobile) {
setOpenMobile(false)
setOpenMobile(false);
}
}, [isMobile, setOpenMobile])
}, [isMobile, setOpenMobile]);
const handleLogout = async () => {
setIsLoggingOut(true)
setIsLoggingOut(true);
try {
await logout()
setShowLogoutDialog(false)
await logout();
setShowLogoutDialog(false);
} catch (error) {
toast.error("登出失败", {
description: error instanceof Error ? error.message : "登出时发生错误,请重试"
})
setIsLoggingOut(false)
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 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 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])
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]">
{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"
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" ? (
{state === 'expanded' ? (
<AnimateIcon animateOnHover>
<ChevronLeft className="size-6" />
<ChevronLeft className='size-6' />
</AnimateIcon>
) : (
<AnimateIcon animateOnHover>
<ChevronRight className="size-6" />
<ChevronRight className='size-6' />
</AnimateIcon>
)}
</Button>
<SidebarHeader className="py-4 md:-ml-2">
<DropdownMenu open={userMenuOpen} onOpenChange={handleUserMenuOpenChange}>
<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"
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}
<div className='relative overflow-visible'>
{hasAvatarDecoration && (
<MainDecoration
className={smallMainDecorationConfig?.className}
/>
<AvatarFallback className="rounded bg-muted text-sm">
{user?.nickname?.charAt(0)?.toUpperCase() || "L"}
)}
<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"}
<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 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" />
<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"}
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='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}
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
@@ -250,63 +280,80 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
className={interactionDecorationConfig?.className}
/>
)}
<div className="relative z-10">
{hasAvatarDecoration && <MainDecoration className={largeMainDecorationConfig?.className} />}
<Avatar className="size-14 rounded-full pointer-events-none">
<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 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">
<span className='text-base font-semibold truncate mt-2'>
{userLoading
? "加载中..."
: user?.nickname || user?.username || "未登录"}
? '加载中...'
: user?.nickname || user?.username || '未登录'}
</span>
<span className="text-xs font-base text-muted-foreground">
{userLoading ? "正在同步账户" : user?.is_admin ? "系统管理员" : "普通用户"}
<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" />
<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" />
<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" />
<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" />
<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]">
<SidebarContent className='group-data-[collapsible=icon]'>
{navMainFiltered.length > 0 && (
<SidebarGroup className="py-0">
<SidebarGroupContent className="py-1">
<SidebarMenu className="gap-1">
<SidebarGroup className='py-0'>
<SidebarGroupContent className='py-1'>
<SidebarMenu className='gap-1'>
{navMainFiltered.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
@@ -327,17 +374,20 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
)}
{user?.is_admin && adminFiltered.length > 0 && (
<SidebarGroup className="py-0 pt-4">
<SidebarGroupLabel className="text-xs font-normal text-muted-foreground">
<SidebarGroup className='py-0 pt-4'>
<SidebarGroupLabel className='text-xs font-normal text-muted-foreground'>
管理
</SidebarGroupLabel>
<SidebarGroupContent className="py-1">
<SidebarMenu className="gap-1">
<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}/`)}
isActive={
pathname === item.url ||
pathname.startsWith(`${item.url}/`)
}
asChild
>
<Link href={item.url} onClick={handleCloseSidebar}>
@@ -353,12 +403,12 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
)}
{documentFiltered.length > 0 && (
<SidebarGroup className="py-0 pt-4">
<SidebarGroupLabel className="text-xs font-normal text-muted-foreground">
<SidebarGroup className='py-0 pt-4'>
<SidebarGroupLabel className='text-xs font-normal text-muted-foreground'>
文档库
</SidebarGroupLabel>
<SidebarGroupContent className="py-1">
<SidebarMenu className="gap-1">
<SidebarGroupContent className='py-1'>
<SidebarMenu className='gap-1'>
{documentFiltered.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
@@ -367,15 +417,20 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
asChild
>
{item.external ? (
<Link href={item.url} target="_blank" rel="noopener noreferrer" onClick={handleCloseSidebar}>
<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" />
<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>
<span className='flex-1'>{item.title}</span>
</Link>
)}
</SidebarMenuButton>
@@ -385,33 +440,37 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</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">
<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)}>
<AlertDialog
open={showLogoutDialog}
onOpenChange={(open) => !isLoggingOut && setShowLogoutDialog(open)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认登出</AlertDialogTitle>
<AlertDialogDescription>
{isLoggingOut ? '正在登出,请稍候...' : '您确定要登出当前账户吗?'}
{isLoggingOut
? '正在登出,请稍候...'
: '您确定要登出当前账户吗?'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoggingOut}>取消</AlertDialogCancel>
<AlertDialogAction onClick={handleLogout} disabled={isLoggingOut}>
{isLoggingOut && <Spinner className="mr-2" />}
{isLoggingOut && <Spinner className='mr-2' />}
{isLoggingOut ? '登出中...' : '确认登出'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
);
}
@@ -1,11 +1,11 @@
"use client"
'use client';
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
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>
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
+4 -5
View File
@@ -1,10 +1,9 @@
import { motion } from "motion/react"
import { motion } from 'motion/react';
/**
* 页面过渡组件
* 用于统一显示页面过渡效果
*
*
* @example
* ```tsx
* <PageTransition>
@@ -21,10 +20,10 @@ export function PageTransition({ children }: { children: React.ReactNode }) {
animate={{ opacity: 1 }}
transition={{
duration: 0.6,
ease: "easeOut",
ease: 'easeOut',
}}
>
{children}
</motion.div>
)
);
}