mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 08:36:37 +08:00
prettier
This commit is contained in:
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user