mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-03 23:06:36 +08:00
refactor(repo): consolidate openflare-server to root and move subprojects to internal/apps
- Merge all files inside openflare-server to the repository root directory. - Relocate agent, relay, and flared subprojects from internal/ to internal/apps/. - Combine docker-compose files and update build context paths to root. - Update GitHub workflows and Dockerfiles to refer to new directories and package names. - Rewrite Go package imports across all files. - Resolve database renew test race condition and clean up docs.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {cva, type VariantProps} from 'class-variance-authority';
|
||||
|
||||
import {
|
||||
Button as ButtonPrimitive,
|
||||
type ButtonProps as ButtonPrimitiveProps,
|
||||
} from '@/components/animate-ui/primitives/buttons/button';
|
||||
import {cn} from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[box-shadow,_color,_background-color,_border-color,_outline-color,_text-decoration-color,_fill,_stroke] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||
accent: 'bg-accent text-accent-foreground shadow-xs hover:bg-accent/90',
|
||||
destructive:
|
||||
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
ghost:
|
||||
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
'icon-sm': 'size-8 rounded-md',
|
||||
'icon-lg': 'size-10 rounded-md',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
type ButtonProps = ButtonPrimitiveProps & VariantProps<typeof buttonVariants>;
|
||||
|
||||
function Button({ className, variant, size, ...props }: ButtonProps) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants, type ButtonProps };
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {type VariantProps} from 'class-variance-authority';
|
||||
|
||||
import {buttonVariants} from '@/components/animate-ui/components/buttons/button';
|
||||
import {
|
||||
RippleButton as RippleButtonPrimitive,
|
||||
type RippleButtonProps as RippleButtonPrimitiveProps,
|
||||
RippleButtonRipples as RippleButtonRipplesPrimitive,
|
||||
type RippleButtonRipplesProps as RippleButtonRipplesPrimitiveProps,
|
||||
} from '@/components/animate-ui/primitives/buttons/ripple';
|
||||
import {cn} from '@/lib/utils';
|
||||
|
||||
const rippleButtonVariants = {
|
||||
default: '[--ripple-button-ripple-color:var(--primary-foreground)]',
|
||||
accent: '[--ripple-button-ripple-color:var(--accent-foreground)]',
|
||||
destructive: '[--ripple-button-ripple-color:var(--destructive-foreground)]',
|
||||
outline: '[--ripple-button-ripple-color:var(--foreground)]',
|
||||
secondary: '[--ripple-button-ripple-color:var(--secondary-foreground)]',
|
||||
ghost: '[--ripple-button-ripple-color:var(--foreground)]',
|
||||
link: '[--ripple-button-ripple-color:var(--primary-foreground)]',
|
||||
};
|
||||
|
||||
type RippleButtonProps = RippleButtonPrimitiveProps &
|
||||
VariantProps<typeof buttonVariants>;
|
||||
|
||||
function RippleButton({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: RippleButtonProps) {
|
||||
return (
|
||||
<RippleButtonPrimitive
|
||||
className={cn(
|
||||
buttonVariants({ variant, size, className }),
|
||||
rippleButtonVariants[variant as keyof typeof rippleButtonVariants],
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type RippleButtonRipplesProps = RippleButtonRipplesPrimitiveProps;
|
||||
|
||||
function RippleButtonRipples(props: RippleButtonRipplesProps) {
|
||||
return <RippleButtonRipplesPrimitive {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
RippleButton,
|
||||
RippleButtonRipples,
|
||||
type RippleButtonProps,
|
||||
type RippleButtonRipplesProps,
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {motion, type Variants} from 'motion/react';
|
||||
|
||||
import {getVariants, type IconProps, IconWrapper, useAnimateIconContext,} from '@/components/animate-ui/icons/icon';
|
||||
|
||||
type CheckProps = IconProps<keyof typeof animations>;
|
||||
|
||||
const animations = {
|
||||
default: {
|
||||
path: {
|
||||
initial: {
|
||||
pathLength: 1,
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
},
|
||||
animate: {
|
||||
pathLength: [0, 1],
|
||||
opacity: [0, 1],
|
||||
scale: [1, 1.1, 1],
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Record<string, Variants>,
|
||||
} as const;
|
||||
|
||||
function IconComponent({ size, ...props }: CheckProps) {
|
||||
const { controls } = useAnimateIconContext();
|
||||
const variants = getVariants(animations);
|
||||
|
||||
return (
|
||||
<motion.svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
initial="initial"
|
||||
animate={controls}
|
||||
{...props}
|
||||
>
|
||||
<motion.path
|
||||
d="m4 12 5 5L20 6"
|
||||
variants={variants.path}
|
||||
initial="initial"
|
||||
animate={controls}
|
||||
/>
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Check(props: CheckProps) {
|
||||
return <IconWrapper icon={IconComponent} {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
animations,
|
||||
Check,
|
||||
Check as CheckIcon,
|
||||
type CheckProps,
|
||||
type CheckProps as CheckIconProps,
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {motion, type Variants} from 'motion/react';
|
||||
|
||||
import {getVariants, type IconProps, IconWrapper, useAnimateIconContext,} from '@/components/animate-ui/icons/icon';
|
||||
|
||||
type ChevronDownProps = IconProps<keyof typeof animations>;
|
||||
|
||||
const animations = {
|
||||
default: {
|
||||
path: {
|
||||
initial: {
|
||||
y: 0,
|
||||
transition: { duration: 0.3, ease: 'easeInOut' },
|
||||
},
|
||||
animate: {
|
||||
y: 4,
|
||||
transition: { duration: 0.3, ease: 'easeInOut' },
|
||||
},
|
||||
},
|
||||
} satisfies Record<string, Variants>,
|
||||
'default-loop': {
|
||||
path: {
|
||||
initial: {
|
||||
y: 0,
|
||||
},
|
||||
animate: {
|
||||
y: [0, 4, 0],
|
||||
transition: { duration: 0.6, ease: 'easeInOut' },
|
||||
},
|
||||
},
|
||||
} satisfies Record<string, Variants>,
|
||||
} as const;
|
||||
|
||||
function IconComponent({ size, ...props }: ChevronDownProps) {
|
||||
const { controls } = useAnimateIconContext();
|
||||
const variants = getVariants(animations);
|
||||
|
||||
return (
|
||||
<motion.svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<motion.path
|
||||
d="m6 9 6 6 6-6"
|
||||
variants={variants.path}
|
||||
initial="initial"
|
||||
animate={controls}
|
||||
/>
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronDown(props: ChevronDownProps) {
|
||||
return <IconWrapper icon={IconComponent} {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
animations,
|
||||
ChevronDown,
|
||||
ChevronDown as ChevronDownIcon,
|
||||
type ChevronDownProps,
|
||||
type ChevronDownProps as ChevronDownIconProps,
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {motion, type Variants} from 'motion/react';
|
||||
|
||||
import {getVariants, type IconProps, IconWrapper, useAnimateIconContext,} from '@/components/animate-ui/icons/icon';
|
||||
|
||||
type ChevronLeftProps = IconProps<keyof typeof animations>;
|
||||
|
||||
const animations = {
|
||||
default: {
|
||||
path: {
|
||||
initial: {
|
||||
x: 0,
|
||||
transition: { duration: 0.3, ease: 'easeInOut' },
|
||||
},
|
||||
animate: {
|
||||
x: -4,
|
||||
transition: { duration: 0.3, ease: 'easeInOut' },
|
||||
},
|
||||
},
|
||||
} satisfies Record<string, Variants>,
|
||||
'default-loop': {
|
||||
path: {
|
||||
initial: {
|
||||
x: 0,
|
||||
},
|
||||
animate: {
|
||||
x: [0, -4, 0],
|
||||
transition: { duration: 0.6, ease: 'easeInOut' },
|
||||
},
|
||||
},
|
||||
} satisfies Record<string, Variants>,
|
||||
} as const;
|
||||
|
||||
function IconComponent({ size, ...props }: ChevronLeftProps) {
|
||||
const { controls } = useAnimateIconContext();
|
||||
const variants = getVariants(animations);
|
||||
|
||||
return (
|
||||
<motion.svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<motion.path
|
||||
d="m15 18-6-6 6-6"
|
||||
variants={variants.path}
|
||||
initial="initial"
|
||||
animate={controls}
|
||||
/>
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronLeft(props: ChevronLeftProps) {
|
||||
return <IconWrapper icon={IconComponent} {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
animations,
|
||||
ChevronLeft,
|
||||
ChevronLeft as ChevronLeftIcon,
|
||||
type ChevronLeftProps,
|
||||
type ChevronLeftProps as ChevronLeftIconProps,
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {motion, type Variants} from 'motion/react';
|
||||
|
||||
import {getVariants, type IconProps, IconWrapper, useAnimateIconContext,} from '@/components/animate-ui/icons/icon';
|
||||
|
||||
type ChevronRightProps = IconProps<keyof typeof animations>;
|
||||
|
||||
const animations = {
|
||||
default: {
|
||||
path: {
|
||||
initial: {
|
||||
x: 0,
|
||||
transition: { duration: 0.3, ease: 'easeInOut' },
|
||||
},
|
||||
animate: {
|
||||
x: 4,
|
||||
transition: { duration: 0.3, ease: 'easeInOut' },
|
||||
},
|
||||
},
|
||||
} satisfies Record<string, Variants>,
|
||||
'default-loop': {
|
||||
path: {
|
||||
initial: {
|
||||
x: 0,
|
||||
},
|
||||
animate: {
|
||||
x: [0, 4, 0],
|
||||
transition: { duration: 0.6, ease: 'easeInOut' },
|
||||
},
|
||||
},
|
||||
} satisfies Record<string, Variants>,
|
||||
} as const;
|
||||
|
||||
function IconComponent({ size, ...props }: ChevronRightProps) {
|
||||
const { controls } = useAnimateIconContext();
|
||||
const variants = getVariants(animations);
|
||||
|
||||
return (
|
||||
<motion.svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<motion.path
|
||||
d="m9 18 6-6-6-6"
|
||||
variants={variants.path}
|
||||
initial="initial"
|
||||
animate={controls}
|
||||
/>
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronRight(props: ChevronRightProps) {
|
||||
return <IconWrapper icon={IconComponent} {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
animations,
|
||||
ChevronRight,
|
||||
ChevronRight as ChevronRightIcon,
|
||||
type ChevronRightProps,
|
||||
type ChevronRightProps as ChevronRightIconProps,
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {motion, type Variants} from 'motion/react';
|
||||
|
||||
import {getVariants, type IconProps, IconWrapper, useAnimateIconContext,} from '@/components/animate-ui/icons/icon';
|
||||
|
||||
type CircleCheckProps = IconProps<keyof typeof animations>;
|
||||
|
||||
const animations = {
|
||||
default: {
|
||||
circle: {},
|
||||
path: {
|
||||
initial: {
|
||||
pathLength: 1,
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
},
|
||||
animate: {
|
||||
pathLength: [0, 1],
|
||||
opacity: [0, 1],
|
||||
scale: [1, 1.1, 1],
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Record<string, Variants>,
|
||||
} as const;
|
||||
|
||||
function IconComponent({ size, ...props }: CircleCheckProps) {
|
||||
const { controls } = useAnimateIconContext();
|
||||
const variants = getVariants(animations);
|
||||
|
||||
return (
|
||||
<motion.svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<motion.circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
variants={variants.circle}
|
||||
initial="initial"
|
||||
animate={controls}
|
||||
/>
|
||||
<motion.path
|
||||
d="m9 12 2 2 4-4"
|
||||
variants={variants.path}
|
||||
initial="initial"
|
||||
animate={controls}
|
||||
/>
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CircleCheck(props: CircleCheckProps) {
|
||||
return <IconWrapper icon={IconComponent} {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
animations,
|
||||
CircleCheck,
|
||||
CircleCheck as CircleCheckIcon,
|
||||
type CircleCheckProps,
|
||||
type CircleCheckProps as CircleCheckIconProps,
|
||||
};
|
||||
@@ -0,0 +1,655 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
type HTMLMotionProps,
|
||||
type LegacyAnimationControls,
|
||||
motion,
|
||||
type SVGMotionProps,
|
||||
useAnimation,
|
||||
type UseInViewOptions,
|
||||
type Variants,
|
||||
} from 'motion/react';
|
||||
|
||||
import {cn} from '@/lib/utils';
|
||||
import {useIsInView} from '@/hooks/use-is-in-view';
|
||||
import {Slot, type WithAsChild} from '@/components/animate-ui/primitives/animate/slot';
|
||||
|
||||
const staticAnimations = {
|
||||
path: {
|
||||
initial: { pathLength: 1 },
|
||||
animate: {
|
||||
pathLength: [0.05, 1],
|
||||
transition: {
|
||||
duration: 0.8,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
},
|
||||
} as Variants,
|
||||
'path-loop': {
|
||||
initial: { pathLength: 1 },
|
||||
animate: {
|
||||
pathLength: [1, 0.05, 1],
|
||||
transition: {
|
||||
duration: 1.6,
|
||||
ease: 'easeInOut',
|
||||
},
|
||||
},
|
||||
} as Variants,
|
||||
} as const;
|
||||
|
||||
type StaticAnimations = keyof typeof staticAnimations;
|
||||
type TriggerProp<T = string> = boolean | StaticAnimations | T;
|
||||
type Trigger = TriggerProp<string>;
|
||||
|
||||
type AnimateIconContextValue = {
|
||||
controls: LegacyAnimationControls | undefined;
|
||||
animation: StaticAnimations | string;
|
||||
loop: boolean;
|
||||
loopDelay: number;
|
||||
active: boolean;
|
||||
animate?: Trigger;
|
||||
initialOnAnimateEnd?: boolean;
|
||||
completeOnStop?: boolean;
|
||||
persistOnAnimateEnd?: boolean;
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
type DefaultIconProps<T = string> = {
|
||||
animate?: TriggerProp<T>;
|
||||
animateOnHover?: TriggerProp<T>;
|
||||
animateOnTap?: TriggerProp<T>;
|
||||
animateOnView?: TriggerProp<T>;
|
||||
animateOnViewMargin?: UseInViewOptions['margin'];
|
||||
animateOnViewOnce?: boolean;
|
||||
animation?: T | StaticAnimations;
|
||||
loop?: boolean;
|
||||
loopDelay?: number;
|
||||
initialOnAnimateEnd?: boolean;
|
||||
completeOnStop?: boolean;
|
||||
persistOnAnimateEnd?: boolean;
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
type AnimateIconProps<T = string> = WithAsChild<
|
||||
HTMLMotionProps<'span'> &
|
||||
DefaultIconProps<T> & {
|
||||
children: React.ReactNode;
|
||||
asChild?: boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
type IconProps<T> = DefaultIconProps<T> &
|
||||
Omit<SVGMotionProps<SVGSVGElement>, 'animate'> & {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
type IconWrapperProps<T> = IconProps<T> & {
|
||||
icon: React.ComponentType<IconProps<T>>;
|
||||
};
|
||||
|
||||
const AnimateIconContext = React.createContext<AnimateIconContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
function useAnimateIconContext() {
|
||||
const context = React.useContext(AnimateIconContext);
|
||||
if (!context)
|
||||
return {
|
||||
controls: undefined,
|
||||
animation: 'default',
|
||||
loop: undefined,
|
||||
loopDelay: undefined,
|
||||
active: undefined,
|
||||
animate: undefined,
|
||||
initialOnAnimateEnd: undefined,
|
||||
completeOnStop: undefined,
|
||||
persistOnAnimateEnd: undefined,
|
||||
delay: undefined,
|
||||
};
|
||||
return context;
|
||||
}
|
||||
|
||||
function composeEventHandlers<E extends React.SyntheticEvent<unknown>>(
|
||||
theirs?: (event: E) => void,
|
||||
ours?: (event: E) => void,
|
||||
) {
|
||||
return (event: E) => {
|
||||
theirs?.(event);
|
||||
ours?.(event);
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyProps = Record<string, any>;
|
||||
|
||||
function AnimateIcon({
|
||||
asChild = false,
|
||||
animate = false,
|
||||
animateOnHover = false,
|
||||
animateOnTap = false,
|
||||
animateOnView = false,
|
||||
animateOnViewMargin = '0px',
|
||||
animateOnViewOnce = true,
|
||||
animation = 'default',
|
||||
loop = false,
|
||||
loopDelay = 0,
|
||||
initialOnAnimateEnd = false,
|
||||
completeOnStop = false,
|
||||
persistOnAnimateEnd = false,
|
||||
delay = 0,
|
||||
children,
|
||||
...props
|
||||
}: AnimateIconProps) {
|
||||
const controls = useAnimation();
|
||||
|
||||
const [localAnimate, setLocalAnimate] = React.useState<boolean>(() => {
|
||||
if (animate === undefined || animate === false) return false;
|
||||
return delay <= 0;
|
||||
});
|
||||
const [currentAnimation, setCurrentAnimation] = React.useState<
|
||||
string | StaticAnimations
|
||||
>(typeof animate === 'string' ? animate : animation);
|
||||
const [status, setStatus] = React.useState<'initial' | 'animate'>('initial');
|
||||
|
||||
const delayRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const loopDelayRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isAnimateInProgressRef = React.useRef<boolean>(false);
|
||||
const animateEndPromiseRef = React.useRef<Promise<void> | null>(null);
|
||||
const resolveAnimateEndRef = React.useRef<(() => void) | null>(null);
|
||||
const activeRef = React.useRef<boolean>(localAnimate);
|
||||
|
||||
const runGenRef = React.useRef(0);
|
||||
const cancelledRef = React.useRef(false);
|
||||
|
||||
const bumpGeneration = React.useCallback(() => {
|
||||
runGenRef.current++;
|
||||
}, []);
|
||||
|
||||
const startAnimation = React.useCallback(
|
||||
(trigger: TriggerProp) => {
|
||||
const next = typeof trigger === 'string' ? trigger : animation;
|
||||
bumpGeneration();
|
||||
if (delayRef.current) {
|
||||
clearTimeout(delayRef.current);
|
||||
delayRef.current = null;
|
||||
}
|
||||
setCurrentAnimation(next);
|
||||
if (delay > 0) {
|
||||
setLocalAnimate(false);
|
||||
delayRef.current = setTimeout(() => {
|
||||
setLocalAnimate(true);
|
||||
}, delay);
|
||||
} else {
|
||||
setLocalAnimate(true);
|
||||
}
|
||||
},
|
||||
[animation, delay, bumpGeneration],
|
||||
);
|
||||
|
||||
const stopAnimation = React.useCallback(() => {
|
||||
bumpGeneration();
|
||||
if (delayRef.current) {
|
||||
clearTimeout(delayRef.current);
|
||||
delayRef.current = null;
|
||||
}
|
||||
if (loopDelayRef.current) {
|
||||
clearTimeout(loopDelayRef.current);
|
||||
loopDelayRef.current = null;
|
||||
}
|
||||
setLocalAnimate(false);
|
||||
}, [bumpGeneration]);
|
||||
|
||||
React.useEffect(() => {
|
||||
activeRef.current = localAnimate;
|
||||
}, [localAnimate]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (animate === undefined) return;
|
||||
setCurrentAnimation(typeof animate === 'string' ? animate : animation);
|
||||
if (animate) startAnimation(animate as TriggerProp);
|
||||
else stopAnimation();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [animate]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (delayRef.current) clearTimeout(delayRef.current);
|
||||
if (loopDelayRef.current) clearTimeout(loopDelayRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const viewOuterRef = React.useRef<HTMLElement>(null);
|
||||
const { ref: inViewRef, isInView } = useIsInView(viewOuterRef, {
|
||||
inView: !!animateOnView,
|
||||
inViewOnce: animateOnViewOnce,
|
||||
inViewMargin: animateOnViewMargin,
|
||||
});
|
||||
|
||||
const startAnim = React.useCallback(
|
||||
async (anim: 'initial' | 'animate', method: 'start' | 'set' = 'start') => {
|
||||
try {
|
||||
await controls[method](anim);
|
||||
setStatus(anim);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
},
|
||||
[controls],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!animateOnView) return;
|
||||
if (isInView) startAnimation(animateOnView);
|
||||
else stopAnimation();
|
||||
}, [isInView, animateOnView, startAnimation, stopAnimation]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const gen = ++runGenRef.current;
|
||||
cancelledRef.current = false;
|
||||
|
||||
async function run() {
|
||||
if (cancelledRef.current || gen !== runGenRef.current) {
|
||||
await startAnim('initial');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!localAnimate) {
|
||||
if (
|
||||
completeOnStop &&
|
||||
isAnimateInProgressRef.current &&
|
||||
animateEndPromiseRef.current
|
||||
) {
|
||||
try {
|
||||
await animateEndPromiseRef.current;
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
if (!persistOnAnimateEnd) {
|
||||
if (cancelledRef.current || gen !== runGenRef.current) {
|
||||
await startAnim('initial');
|
||||
return;
|
||||
}
|
||||
await startAnim('initial');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (loop) {
|
||||
if (cancelledRef.current || gen !== runGenRef.current) {
|
||||
await startAnim('initial');
|
||||
return;
|
||||
}
|
||||
await startAnim('initial', 'set');
|
||||
}
|
||||
|
||||
isAnimateInProgressRef.current = true;
|
||||
animateEndPromiseRef.current = new Promise<void>((resolve) => {
|
||||
resolveAnimateEndRef.current = resolve;
|
||||
});
|
||||
|
||||
if (cancelledRef.current || gen !== runGenRef.current) {
|
||||
isAnimateInProgressRef.current = false;
|
||||
resolveAnimateEndRef.current?.();
|
||||
resolveAnimateEndRef.current = null;
|
||||
animateEndPromiseRef.current = null;
|
||||
await startAnim('initial');
|
||||
return;
|
||||
}
|
||||
|
||||
await startAnim('animate');
|
||||
|
||||
if (cancelledRef.current || gen !== runGenRef.current) {
|
||||
isAnimateInProgressRef.current = false;
|
||||
resolveAnimateEndRef.current?.();
|
||||
resolveAnimateEndRef.current = null;
|
||||
animateEndPromiseRef.current = null;
|
||||
await startAnim('initial');
|
||||
return;
|
||||
}
|
||||
|
||||
isAnimateInProgressRef.current = false;
|
||||
resolveAnimateEndRef.current?.();
|
||||
resolveAnimateEndRef.current = null;
|
||||
animateEndPromiseRef.current = null;
|
||||
|
||||
if (initialOnAnimateEnd) {
|
||||
if (cancelledRef.current || gen !== runGenRef.current) {
|
||||
await startAnim('initial');
|
||||
return;
|
||||
}
|
||||
await startAnim('initial', 'set');
|
||||
}
|
||||
|
||||
if (loop) {
|
||||
if (loopDelay > 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
loopDelayRef.current = setTimeout(() => {
|
||||
loopDelayRef.current = null;
|
||||
resolve();
|
||||
}, loopDelay);
|
||||
});
|
||||
|
||||
if (cancelledRef.current || gen !== runGenRef.current) {
|
||||
await startAnim('initial');
|
||||
return;
|
||||
}
|
||||
if (!activeRef.current) {
|
||||
if (status !== 'initial' && !persistOnAnimateEnd)
|
||||
await startAnim('initial');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!activeRef.current) {
|
||||
if (status !== 'initial' && !persistOnAnimateEnd)
|
||||
await startAnim('initial');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (cancelledRef.current || gen !== runGenRef.current) {
|
||||
await startAnim('initial');
|
||||
return;
|
||||
}
|
||||
await run();
|
||||
}
|
||||
}
|
||||
|
||||
void run();
|
||||
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
if (delayRef.current) {
|
||||
clearTimeout(delayRef.current);
|
||||
delayRef.current = null;
|
||||
}
|
||||
if (loopDelayRef.current) {
|
||||
clearTimeout(loopDelayRef.current);
|
||||
loopDelayRef.current = null;
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localAnimate, controls]);
|
||||
|
||||
const childProps = (
|
||||
React.isValidElement(children) ? (children as React.ReactElement).props : {}
|
||||
) as AnyProps;
|
||||
|
||||
const handleMouseEnter = composeEventHandlers<React.MouseEvent<HTMLElement>>(
|
||||
childProps.onMouseEnter,
|
||||
() => {
|
||||
if (animateOnHover) startAnimation(animateOnHover);
|
||||
},
|
||||
);
|
||||
|
||||
const handleMouseLeave = composeEventHandlers<React.MouseEvent<HTMLElement>>(
|
||||
childProps.onMouseLeave,
|
||||
() => {
|
||||
if (animateOnHover || animateOnTap) stopAnimation();
|
||||
},
|
||||
);
|
||||
|
||||
const handlePointerDown = composeEventHandlers<
|
||||
React.PointerEvent<HTMLElement>
|
||||
>(childProps.onPointerDown, () => {
|
||||
if (animateOnTap) startAnimation(animateOnTap);
|
||||
});
|
||||
|
||||
const handlePointerUp = composeEventHandlers<React.PointerEvent<HTMLElement>>(
|
||||
childProps.onPointerUp,
|
||||
() => {
|
||||
if (animateOnTap) stopAnimation();
|
||||
},
|
||||
);
|
||||
|
||||
const content = asChild ? (
|
||||
<Slot
|
||||
ref={inViewRef}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerUp={handlePointerUp}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Slot>
|
||||
) : (
|
||||
<motion.span
|
||||
ref={inViewRef}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerUp={handlePointerUp}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.span>
|
||||
);
|
||||
|
||||
return (
|
||||
<AnimateIconContext.Provider
|
||||
value={{
|
||||
controls,
|
||||
animation: currentAnimation,
|
||||
loop,
|
||||
loopDelay,
|
||||
active: localAnimate,
|
||||
animate,
|
||||
initialOnAnimateEnd,
|
||||
completeOnStop,
|
||||
delay,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</AnimateIconContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const pathClassName =
|
||||
"[&_[stroke-dasharray='1px_1px']]:![stroke-dasharray:1px_0px]";
|
||||
|
||||
function IconWrapper<T extends string>({
|
||||
size = 28,
|
||||
animation: animationProp,
|
||||
animate,
|
||||
animateOnHover,
|
||||
animateOnTap,
|
||||
animateOnView,
|
||||
animateOnViewMargin,
|
||||
animateOnViewOnce,
|
||||
icon: IconComponent,
|
||||
loop,
|
||||
loopDelay,
|
||||
persistOnAnimateEnd,
|
||||
initialOnAnimateEnd,
|
||||
delay,
|
||||
completeOnStop,
|
||||
className,
|
||||
...props
|
||||
}: IconWrapperProps<T>) {
|
||||
const context = React.useContext(AnimateIconContext);
|
||||
|
||||
if (context) {
|
||||
const {
|
||||
controls,
|
||||
animation: parentAnimation,
|
||||
loop: parentLoop,
|
||||
loopDelay: parentLoopDelay,
|
||||
active: parentActive,
|
||||
animate: parentAnimate,
|
||||
persistOnAnimateEnd: parentPersistOnAnimateEnd,
|
||||
initialOnAnimateEnd: parentInitialOnAnimateEnd,
|
||||
delay: parentDelay,
|
||||
completeOnStop: parentCompleteOnStop,
|
||||
} = context;
|
||||
|
||||
const hasOverrides =
|
||||
animate !== undefined ||
|
||||
animateOnHover !== undefined ||
|
||||
animateOnTap !== undefined ||
|
||||
animateOnView !== undefined ||
|
||||
loop !== undefined ||
|
||||
loopDelay !== undefined ||
|
||||
initialOnAnimateEnd !== undefined ||
|
||||
persistOnAnimateEnd !== undefined ||
|
||||
delay !== undefined ||
|
||||
completeOnStop !== undefined;
|
||||
|
||||
if (hasOverrides) {
|
||||
const inheritedAnimate: Trigger = parentActive
|
||||
? (animationProp ?? parentAnimation ?? 'default')
|
||||
: false;
|
||||
|
||||
const finalAnimate: Trigger = (animate ??
|
||||
parentAnimate ??
|
||||
inheritedAnimate) as Trigger;
|
||||
|
||||
return (
|
||||
<AnimateIcon
|
||||
animate={finalAnimate}
|
||||
animateOnHover={animateOnHover}
|
||||
animateOnTap={animateOnTap}
|
||||
animateOnView={animateOnView}
|
||||
animateOnViewMargin={animateOnViewMargin}
|
||||
animateOnViewOnce={animateOnViewOnce}
|
||||
animation={animationProp ?? parentAnimation}
|
||||
loop={loop ?? parentLoop}
|
||||
loopDelay={loopDelay ?? parentLoopDelay}
|
||||
persistOnAnimateEnd={persistOnAnimateEnd ?? parentPersistOnAnimateEnd}
|
||||
initialOnAnimateEnd={initialOnAnimateEnd ?? parentInitialOnAnimateEnd}
|
||||
delay={delay ?? parentDelay}
|
||||
completeOnStop={completeOnStop ?? parentCompleteOnStop}
|
||||
asChild
|
||||
>
|
||||
<IconComponent
|
||||
size={size}
|
||||
className={cn(
|
||||
className,
|
||||
((animationProp ?? parentAnimation) === 'path' ||
|
||||
(animationProp ?? parentAnimation) === 'path-loop') &&
|
||||
pathClassName,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AnimateIcon>
|
||||
);
|
||||
}
|
||||
|
||||
const animationToUse = animationProp ?? parentAnimation;
|
||||
const loopToUse = parentLoop;
|
||||
const loopDelayToUse = parentLoopDelay;
|
||||
|
||||
return (
|
||||
<AnimateIconContext.Provider
|
||||
value={{
|
||||
controls,
|
||||
animation: animationToUse,
|
||||
loop: loopToUse,
|
||||
loopDelay: loopDelayToUse,
|
||||
active: parentActive,
|
||||
animate: parentAnimate,
|
||||
initialOnAnimateEnd: parentInitialOnAnimateEnd,
|
||||
delay: parentDelay,
|
||||
completeOnStop: parentCompleteOnStop,
|
||||
}}
|
||||
>
|
||||
<IconComponent
|
||||
size={size}
|
||||
className={cn(
|
||||
className,
|
||||
(animationToUse === 'path' || animationToUse === 'path-loop') &&
|
||||
pathClassName,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AnimateIconContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
animate !== undefined ||
|
||||
animateOnHover !== undefined ||
|
||||
animateOnTap !== undefined ||
|
||||
animateOnView !== undefined ||
|
||||
animationProp !== undefined
|
||||
) {
|
||||
return (
|
||||
<AnimateIcon
|
||||
animate={animate}
|
||||
animateOnHover={animateOnHover}
|
||||
animateOnTap={animateOnTap}
|
||||
animateOnView={animateOnView}
|
||||
animateOnViewMargin={animateOnViewMargin}
|
||||
animateOnViewOnce={animateOnViewOnce}
|
||||
animation={animationProp}
|
||||
loop={loop}
|
||||
loopDelay={loopDelay}
|
||||
delay={delay}
|
||||
completeOnStop={completeOnStop}
|
||||
asChild
|
||||
>
|
||||
<IconComponent
|
||||
size={size}
|
||||
className={cn(
|
||||
className,
|
||||
(animationProp === 'path' || animationProp === 'path-loop') &&
|
||||
pathClassName,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AnimateIcon>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IconComponent
|
||||
size={size}
|
||||
className={cn(
|
||||
className,
|
||||
(animationProp === 'path' || animationProp === 'path-loop') &&
|
||||
pathClassName,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function getVariants<
|
||||
V extends { default: T; [key: string]: T },
|
||||
T extends Record<string, Variants>,
|
||||
>(animations: V): T {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const { animation: animationType } = useAnimateIconContext();
|
||||
|
||||
let result: T;
|
||||
|
||||
if (animationType in staticAnimations) {
|
||||
const variant = staticAnimations[animationType as StaticAnimations];
|
||||
result = {} as T;
|
||||
for (const key in animations.default) {
|
||||
if (
|
||||
(animationType === 'path' || animationType === 'path-loop') &&
|
||||
key.includes('group')
|
||||
)
|
||||
continue;
|
||||
result[key] = variant as T[Extract<keyof T, string>];
|
||||
}
|
||||
} else {
|
||||
result = (animations[animationType as keyof V] as T) ?? animations.default;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export {
|
||||
pathClassName,
|
||||
staticAnimations,
|
||||
AnimateIcon,
|
||||
IconWrapper,
|
||||
useAnimateIconContext,
|
||||
getVariants,
|
||||
type IconProps,
|
||||
type IconWrapperProps,
|
||||
type AnimateIconProps,
|
||||
type AnimateIconContextValue,
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {motion, type Variants} from 'motion/react';
|
||||
|
||||
import {getVariants, type IconProps, IconWrapper, useAnimateIconContext,} from '@/components/animate-ui/icons/icon';
|
||||
|
||||
type SendProps = IconProps<keyof typeof animations>;
|
||||
|
||||
const animations = {
|
||||
default: {
|
||||
group: {
|
||||
initial: {
|
||||
scale: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
animate: {
|
||||
scale: [1, 0.8, 1, 1, 1],
|
||||
x: [0, '-10%', '100%', '-125%', 0],
|
||||
y: [0, '10%', '-100%', '125%', 0],
|
||||
transition: {
|
||||
default: { ease: 'easeInOut', duration: 1.2 },
|
||||
x: {
|
||||
ease: 'easeInOut',
|
||||
duration: 1.2,
|
||||
times: [0, 0.25, 0.5, 0.5, 1],
|
||||
},
|
||||
y: {
|
||||
ease: 'easeInOut',
|
||||
duration: 1.2,
|
||||
times: [0, 0.25, 0.5, 0.5, 1],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
path1: {},
|
||||
path2: {},
|
||||
} satisfies Record<string, Variants>,
|
||||
} as const;
|
||||
|
||||
function IconComponent({ size, ...props }: SendProps) {
|
||||
const { controls } = useAnimateIconContext();
|
||||
const variants = getVariants(animations);
|
||||
|
||||
return (
|
||||
<motion.svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<motion.g variants={variants.group} initial="initial" animate={controls}>
|
||||
<motion.path
|
||||
d="M14.5,21.7c.1.3.4.4.7.3.1,0,.2-.2.3-.3L22,2.7c0-.3,0-.5-.3-.6-.1,0-.2,0-.3,0L2.3,8.5c-.3,0-.4.4-.3.6,0,.1.2.2.3.3l7.9,3.2c.5.2.9.6,1.1,1.1l3.2,7.9Z"
|
||||
variants={variants.path1}
|
||||
initial="initial"
|
||||
animate={controls}
|
||||
/>
|
||||
<motion.path
|
||||
d="M21.9,2.1l-10.9,10.9"
|
||||
variants={variants.path2}
|
||||
initial="initial"
|
||||
animate={controls}
|
||||
/>
|
||||
</motion.g>
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Send(props: SendProps) {
|
||||
return <IconWrapper icon={IconComponent} {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
animations,
|
||||
Send,
|
||||
Send as SendIcon,
|
||||
type SendProps,
|
||||
type SendProps as SendIconProps,
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {type HTMLMotionProps, isMotionComponent, motion} from 'motion/react';
|
||||
import {cn} from '@/lib/utils';
|
||||
|
||||
type AnyProps = Record<string, unknown>;
|
||||
|
||||
type DOMMotionProps<T extends HTMLElement = HTMLElement> = Omit<
|
||||
HTMLMotionProps<keyof HTMLElementTagNameMap>,
|
||||
'ref'
|
||||
> & { ref?: React.Ref<T> };
|
||||
|
||||
type WithAsChild<Base extends object> =
|
||||
| (Base & { asChild: true; children: React.ReactElement })
|
||||
| (Base & { asChild?: false | undefined });
|
||||
|
||||
type SlotProps<T extends HTMLElement = HTMLElement> = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
children?: any;
|
||||
} & DOMMotionProps<T>;
|
||||
|
||||
function mergeRefs<T>(
|
||||
...refs: (React.Ref<T> | undefined)[]
|
||||
): React.RefCallback<T> {
|
||||
return (node) => {
|
||||
refs.forEach((ref) => {
|
||||
if (!ref) return;
|
||||
if (typeof ref === 'function') {
|
||||
ref(node);
|
||||
} else {
|
||||
(ref as React.RefObject<T | null>).current = node;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function mergeProps<T extends HTMLElement>(
|
||||
childProps: AnyProps,
|
||||
slotProps: DOMMotionProps<T>,
|
||||
): AnyProps {
|
||||
const merged: AnyProps = { ...childProps, ...slotProps };
|
||||
|
||||
if (childProps.className || slotProps.className) {
|
||||
merged.className = cn(
|
||||
childProps.className as string,
|
||||
slotProps.className as string,
|
||||
);
|
||||
}
|
||||
|
||||
if (childProps.style || slotProps.style) {
|
||||
merged.style = {
|
||||
...(childProps.style as React.CSSProperties),
|
||||
...(slotProps.style as React.CSSProperties),
|
||||
};
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function Slot<T extends HTMLElement = HTMLElement>({
|
||||
children,
|
||||
ref,
|
||||
...props
|
||||
}: SlotProps<T>) {
|
||||
const isAlreadyMotion =
|
||||
typeof children.type === 'object' &&
|
||||
children.type !== null &&
|
||||
isMotionComponent(children.type);
|
||||
|
||||
const Base = React.useMemo(
|
||||
() =>
|
||||
isAlreadyMotion
|
||||
? (children.type as React.ElementType)
|
||||
: motion.create(children.type as React.ElementType),
|
||||
[isAlreadyMotion, children.type],
|
||||
);
|
||||
|
||||
if (!React.isValidElement(children)) return null;
|
||||
|
||||
const { ref: childRef, ...childProps } = children.props as AnyProps;
|
||||
|
||||
const mergedProps = mergeProps(childProps, props);
|
||||
|
||||
return (
|
||||
<Base {...mergedProps} ref={mergeRefs(childRef as React.Ref<T>, ref)} />
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Slot,
|
||||
type SlotProps,
|
||||
type WithAsChild,
|
||||
type DOMMotionProps,
|
||||
type AnyProps,
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {type HTMLMotionProps, motion} from 'motion/react';
|
||||
|
||||
import {Slot, type WithAsChild} from '@/components/animate-ui/primitives/animate/slot';
|
||||
|
||||
type ButtonProps = WithAsChild<
|
||||
HTMLMotionProps<'button'> & {
|
||||
hoverScale?: number;
|
||||
tapScale?: number;
|
||||
}
|
||||
>;
|
||||
|
||||
function Button({
|
||||
hoverScale = 1.05,
|
||||
tapScale = 0.95,
|
||||
asChild = false,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const Component = asChild ? Slot : motion.button;
|
||||
|
||||
return (
|
||||
<Component
|
||||
whileTap={{ scale: tapScale }}
|
||||
whileHover={{ scale: hoverScale }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, type ButtonProps };
|
||||
@@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {type HTMLMotionProps, motion} from 'motion/react';
|
||||
|
||||
import {getStrictContext} from '@/lib/utils/get-strict-context';
|
||||
import {Slot, type WithAsChild} from '@/components/animate-ui/primitives/animate/slot';
|
||||
|
||||
type Ripple = {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type RippleButtonContextType = {
|
||||
ripples: Ripple[];
|
||||
setRipples: (ripples: Ripple[]) => void;
|
||||
};
|
||||
|
||||
const [RippleButtonProvider, useRippleButton] =
|
||||
getStrictContext<RippleButtonContextType>('RippleButtonContext');
|
||||
|
||||
type RippleButtonProps = WithAsChild<
|
||||
HTMLMotionProps<'button'> & {
|
||||
hoverScale?: number;
|
||||
tapScale?: number;
|
||||
}
|
||||
>;
|
||||
|
||||
function RippleButton({
|
||||
ref,
|
||||
onClick,
|
||||
hoverScale = 1,
|
||||
tapScale = 0.95,
|
||||
asChild = false,
|
||||
style,
|
||||
...props
|
||||
}: RippleButtonProps) {
|
||||
const [ripples, setRipples] = React.useState<Ripple[]>([]);
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||
React.useImperativeHandle(ref, () => buttonRef.current as HTMLButtonElement);
|
||||
|
||||
const createRipple = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const button = buttonRef.current;
|
||||
if (!button) return;
|
||||
|
||||
const rect = button.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
|
||||
const newRipple: Ripple = {
|
||||
id: Date.now(),
|
||||
x,
|
||||
y,
|
||||
};
|
||||
|
||||
setRipples((prev) => [...prev, newRipple]);
|
||||
|
||||
setTimeout(() => {
|
||||
setRipples((prev) => prev.filter((r) => r.id !== newRipple.id));
|
||||
}, 600);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleClick = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
createRipple(event);
|
||||
if (onClick) {
|
||||
onClick(event);
|
||||
}
|
||||
},
|
||||
[createRipple, onClick],
|
||||
);
|
||||
|
||||
const Component = asChild ? Slot : motion.button;
|
||||
|
||||
return (
|
||||
<RippleButtonProvider value={{ ripples, setRipples }}>
|
||||
<Component
|
||||
ref={buttonRef}
|
||||
data-slot="ripple-button"
|
||||
onClick={handleClick}
|
||||
whileTap={{ scale: tapScale }}
|
||||
whileHover={{ scale: hoverScale }}
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</RippleButtonProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type RippleButtonRipplesProps = WithAsChild<
|
||||
HTMLMotionProps<'span'> & {
|
||||
color?: string;
|
||||
scale?: number;
|
||||
}
|
||||
>;
|
||||
|
||||
function RippleButtonRipples({
|
||||
color = 'var(--ripple-button-ripple-color)',
|
||||
scale = 10,
|
||||
transition = { duration: 0.6, ease: 'easeOut' },
|
||||
asChild = false,
|
||||
style,
|
||||
...props
|
||||
}: RippleButtonRipplesProps) {
|
||||
const { ripples } = useRippleButton();
|
||||
|
||||
const Component = asChild ? Slot : motion.span;
|
||||
|
||||
return ripples.map((ripple) => (
|
||||
<Component
|
||||
key={ripple.id}
|
||||
initial={{ scale: 0, opacity: 0.5 }}
|
||||
animate={{ scale, opacity: 0 }}
|
||||
transition={transition}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
borderRadius: '50%',
|
||||
pointerEvents: 'none',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
backgroundColor: color,
|
||||
top: ripple.y - 10,
|
||||
left: ripple.x - 10,
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
export {
|
||||
RippleButton,
|
||||
RippleButtonRipples,
|
||||
type RippleButtonProps,
|
||||
type RippleButtonRipplesProps,
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {Collapsible as CollapsiblePrimitive} from 'radix-ui';
|
||||
import {AnimatePresence, type HTMLMotionProps, motion} from 'motion/react';
|
||||
|
||||
import {getStrictContext} from '@/lib/utils/get-strict-context';
|
||||
import {useControlledState} from '@/hooks/use-controlled-state';
|
||||
|
||||
type CollapsibleContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
const [CollapsibleProvider, useCollapsible] =
|
||||
getStrictContext<CollapsibleContextType>('CollapsibleContext');
|
||||
|
||||
type CollapsibleProps = React.ComponentProps<typeof CollapsiblePrimitive.Root>;
|
||||
|
||||
function Collapsible(props: CollapsibleProps) {
|
||||
const [isOpen, setIsOpen] = useControlledState({
|
||||
value: props?.open,
|
||||
defaultValue: props?.defaultOpen,
|
||||
onChange: props?.onOpenChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<CollapsibleProvider value={{ isOpen, setIsOpen }}>
|
||||
<CollapsiblePrimitive.Root
|
||||
data-slot="collapsible"
|
||||
{...props}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
</CollapsibleProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type CollapsibleTriggerProps = React.ComponentProps<
|
||||
typeof CollapsiblePrimitive.Trigger
|
||||
>;
|
||||
|
||||
function CollapsibleTrigger(props: CollapsibleTriggerProps) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
type CollapsibleContentProps = Omit<
|
||||
React.ComponentProps<typeof CollapsiblePrimitive.Content>,
|
||||
'asChild' | 'forceMount'
|
||||
> &
|
||||
HTMLMotionProps<'div'> & {
|
||||
keepRendered?: boolean;
|
||||
};
|
||||
|
||||
function CollapsibleContent({
|
||||
keepRendered = false,
|
||||
transition = { duration: 0.35, ease: 'easeInOut' },
|
||||
...props
|
||||
}: CollapsibleContentProps) {
|
||||
const { isOpen } = useCollapsible();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{keepRendered ? (
|
||||
<CollapsiblePrimitive.Content asChild forceMount>
|
||||
<motion.div
|
||||
key="collapsible-content"
|
||||
data-slot="collapsible-content"
|
||||
layout
|
||||
initial={{ opacity: 0, height: 0, overflow: 'hidden', y: 20 }}
|
||||
animate={
|
||||
isOpen
|
||||
? { opacity: 1, height: 'auto', overflow: 'hidden', y: 0 }
|
||||
: { opacity: 0, height: 0, overflow: 'hidden', y: 20 }
|
||||
}
|
||||
transition={transition}
|
||||
{...props}
|
||||
/>
|
||||
</CollapsiblePrimitive.Content>
|
||||
) : (
|
||||
isOpen && (
|
||||
<CollapsiblePrimitive.Content asChild forceMount>
|
||||
<motion.div
|
||||
key="collapsible-content"
|
||||
data-slot="collapsible-content"
|
||||
layout
|
||||
initial={{ opacity: 0, height: 0, overflow: 'hidden', y: 20 }}
|
||||
animate={{ opacity: 1, height: 'auto', overflow: 'hidden', y: 0 }}
|
||||
exit={{ opacity: 0, height: 0, overflow: 'hidden', y: 20 }}
|
||||
transition={transition}
|
||||
{...props}
|
||||
/>
|
||||
</CollapsiblePrimitive.Content>
|
||||
)
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Collapsible,
|
||||
CollapsibleTrigger,
|
||||
CollapsibleContent,
|
||||
useCollapsible,
|
||||
type CollapsibleProps,
|
||||
type CollapsibleTriggerProps,
|
||||
type CollapsibleContentProps,
|
||||
type CollapsibleContextType,
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {type SpringOptions, useMotionValue, useSpring} from 'motion/react';
|
||||
|
||||
import {useIsInView, type UseIsInViewOptions,} from '@/hooks/use-is-in-view';
|
||||
|
||||
type CountingNumberProps = Omit<React.ComponentProps<'span'>, 'children'> & {
|
||||
number: number;
|
||||
fromNumber?: number;
|
||||
padStart?: boolean;
|
||||
decimalSeparator?: string;
|
||||
decimalPlaces?: number;
|
||||
transition?: SpringOptions;
|
||||
delay?: number;
|
||||
initiallyStable?: boolean;
|
||||
thousandSeparator?: string;
|
||||
} & UseIsInViewOptions;
|
||||
|
||||
function CountingNumber({
|
||||
ref,
|
||||
number,
|
||||
fromNumber = 0,
|
||||
padStart = false,
|
||||
inView = false,
|
||||
inViewMargin = '0px',
|
||||
inViewOnce = true,
|
||||
decimalSeparator = '.',
|
||||
transition = { stiffness: 200, damping: 30 },
|
||||
decimalPlaces = 0,
|
||||
delay = 0,
|
||||
initiallyStable = false,
|
||||
thousandSeparator = '',
|
||||
...props
|
||||
}: CountingNumberProps) {
|
||||
const { ref: localRef, isInView } = useIsInView(
|
||||
ref as React.Ref<HTMLElement>,
|
||||
{
|
||||
inView,
|
||||
inViewOnce,
|
||||
inViewMargin,
|
||||
},
|
||||
);
|
||||
|
||||
const numberStr = number.toString();
|
||||
const decimals =
|
||||
typeof decimalPlaces === 'number'
|
||||
? decimalPlaces
|
||||
: numberStr.includes('.')
|
||||
? (numberStr.split('.')[1]?.length ?? 0)
|
||||
: 0;
|
||||
|
||||
const motionVal = useMotionValue(initiallyStable ? number : fromNumber);
|
||||
const springVal = useSpring(motionVal, transition);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (isInView) motionVal.set(number);
|
||||
}, delay);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [isInView, number, motionVal, delay]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const unsubscribe = springVal.on('change', (latest) => {
|
||||
if (localRef.current) {
|
||||
let formatted =
|
||||
decimals > 0
|
||||
? latest.toFixed(decimals)
|
||||
: Math.round(latest).toString();
|
||||
|
||||
if (decimals > 0) {
|
||||
formatted = formatted.replace('.', decimalSeparator);
|
||||
}
|
||||
|
||||
if (padStart) {
|
||||
const finalIntLength = Math.floor(Math.abs(number)).toString().length;
|
||||
const [intPart, fracPart] = formatted.split(decimalSeparator);
|
||||
const paddedInt = intPart?.padStart(finalIntLength, '0') ?? '';
|
||||
formatted = fracPart
|
||||
? `${ paddedInt }${ decimalSeparator }${ fracPart }`
|
||||
: paddedInt;
|
||||
}
|
||||
|
||||
if (thousandSeparator) {
|
||||
const [intPart, fracPart] = formatted.split(decimalSeparator);
|
||||
const regex = /\B(?=(\d{3})+(?!\d))/g;
|
||||
const separatedInt = intPart.replace(regex, thousandSeparator);
|
||||
formatted = fracPart
|
||||
? `${ separatedInt }${ decimalSeparator }${ fracPart }`
|
||||
: separatedInt;
|
||||
}
|
||||
|
||||
localRef.current.textContent = formatted;
|
||||
}
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}, [springVal, decimals, padStart, number, decimalSeparator, localRef, thousandSeparator]);
|
||||
|
||||
const finalIntLength = Math.floor(Math.abs(number)).toString().length;
|
||||
|
||||
const formatValue = (val: number) => {
|
||||
let out = decimals > 0 ? val.toFixed(decimals) : Math.round(val).toString();
|
||||
if (decimals > 0) out = out.replace('.', decimalSeparator);
|
||||
if (padStart) {
|
||||
const [intPart, fracPart] = out.split(decimalSeparator);
|
||||
const paddedInt = (intPart ?? '').padStart(finalIntLength, '0');
|
||||
out = fracPart ? `${ paddedInt }${ decimalSeparator }${ fracPart }` : paddedInt;
|
||||
}
|
||||
if (thousandSeparator) {
|
||||
const [intPart, fracPart] = out.split(decimalSeparator);
|
||||
const regex = /\B(?=(\d{3})+(?!\d))/g;
|
||||
const separatedInt = intPart.replace(regex, thousandSeparator);
|
||||
out = fracPart ? `${ separatedInt }${ decimalSeparator }${ fracPart }` : separatedInt;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const zeroText = padStart
|
||||
? '0'.padStart(finalIntLength, '0') +
|
||||
(decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : '')
|
||||
: '0' + (decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : '');
|
||||
|
||||
const initialText = initiallyStable ? formatValue(number) : zeroText;
|
||||
|
||||
return (
|
||||
<span ref={localRef} data-slot="counting-number" {...props}>
|
||||
{initialText}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export { CountingNumber, type CountingNumberProps };
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client"
|
||||
|
||||
import type {ReactNode} from "react"
|
||||
import {motion} from "motion/react"
|
||||
import {WavesIcon} from "lucide-react"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
interface AuthShellProps {
|
||||
children: ReactNode
|
||||
wide?: boolean
|
||||
}
|
||||
|
||||
export function AuthShell({children, wide = false}: AuthShellProps) {
|
||||
return (
|
||||
<main className="relative flex min-h-screen w-full items-center justify-center overflow-x-hidden bg-background px-4 py-6 sm:px-8 sm:py-8 [@media(max-height:700px)]:py-3">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_right,var(--border)_1px,transparent_1px),linear-gradient(to_bottom,var(--border)_1px,transparent_1px)] bg-[size:56px_56px] opacity-45 [mask-image:linear-gradient(to_bottom,transparent,black_12%,black_88%,transparent)]"
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border/70"
|
||||
/>
|
||||
<motion.section
|
||||
initial={{opacity: 0, y: 18}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.45, ease: "easeOut"}}
|
||||
className={cn(
|
||||
"relative w-full border-x border-dashed border-border/80 bg-background/95 px-6 py-8 backdrop-blur-sm sm:px-10 sm:py-10 [@media(max-height:700px)]:py-5",
|
||||
wide ? "max-w-2xl" : "max-w-xl",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</motion.section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
interface AuthHeadingProps {
|
||||
title: string
|
||||
description: string
|
||||
siteName?: string
|
||||
}
|
||||
|
||||
export function AuthHeading({
|
||||
title,
|
||||
description,
|
||||
siteName = "OpenFlare",
|
||||
}: AuthHeadingProps) {
|
||||
return (
|
||||
<header className="flex flex-col gap-6 [@media(max-height:700px)]:gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex size-9 items-center justify-center rounded-full bg-foreground text-background [@media(max-height:700px)]:size-8">
|
||||
<WavesIcon aria-hidden="true" />
|
||||
</span>
|
||||
<span className="font-semibold tracking-tight [@media(min-height:761px)]:text-lg">{siteName}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 [@media(max-height:700px)]:gap-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground sm:text-3xl [@media(min-height:900px)]:sm:text-4xl">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground [@media(min-height:900px)]:text-base">{description}</p>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
|
||||
import {useEffect, useRef, useState} from 'react'
|
||||
import {CheckCircle2, Loader2, ShieldAlert, ShieldCheck, ShieldQuestion} from 'lucide-react'
|
||||
import {getCapToken} from '@/lib/cap-solver'
|
||||
|
||||
type CapStatus = 'idle' | 'solving' | 'solved' | 'error'
|
||||
|
||||
interface CapWidgetProps {
|
||||
/** Called with the one-time token once the challenge is solved */
|
||||
onToken: (token: string) => void
|
||||
/** Called when the challenge fails or encounters an error */
|
||||
onError?: (err: Error) => void
|
||||
/** Whether to auto-start solving when the component mounts. Defaults to true. */
|
||||
autoStart?: boolean
|
||||
/** PoW scope sent to the backend */
|
||||
scope?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Cap 人机验证小部件
|
||||
*
|
||||
* autoStart=true(默认):挂载后立即在后台运行 PoW 求解,完成后回调 onToken。
|
||||
* autoStart=false:显示「点击开始验证」按钮,用户手动触发后才开始求解。
|
||||
*/
|
||||
export function CapWidget({ onToken, onError, autoStart = true, scope = 'login' }: CapWidgetProps) {
|
||||
const [status, setStatus] = useState<CapStatus>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const solving = useRef(false)
|
||||
|
||||
const solve = async () => {
|
||||
if (solving.current) return
|
||||
solving.current = true
|
||||
setStatus('solving')
|
||||
setErrorMsg('')
|
||||
try {
|
||||
const token = await getCapToken(scope)
|
||||
setStatus('solved')
|
||||
onToken(token)
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err))
|
||||
setStatus('error')
|
||||
setErrorMsg(error.message)
|
||||
onError?.(error)
|
||||
} finally {
|
||||
solving.current = false
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-start on mount (only when autoStart is enabled)
|
||||
useEffect(() => {
|
||||
if (autoStart) {
|
||||
void solve()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border/60 bg-muted/30 px-3 py-2 text-sm">
|
||||
{/* idle + autoStart=false: 手动触发按钮 */}
|
||||
{status === 'idle' && !autoStart && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 text-left"
|
||||
onClick={() => void solve()}
|
||||
>
|
||||
<ShieldQuestion className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-muted-foreground">点击开始人机验证</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* idle + autoStart=true: 等待自动开始(极短暂状态) */}
|
||||
{status === 'idle' && autoStart && (
|
||||
<>
|
||||
<ShieldAlert className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">等待人机验证…</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'solving' && (
|
||||
<>
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-primary" />
|
||||
<span className="text-muted-foreground">正在完成人机验证…</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'solved' && (
|
||||
<>
|
||||
<CheckCircle2 className="size-4 shrink-0 text-green-500" />
|
||||
<span className="text-green-600 dark:text-green-400">人机验证通过</span>
|
||||
<ShieldCheck className="ml-auto size-4 shrink-0 text-green-500" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 text-left"
|
||||
onClick={() => void solve()}
|
||||
>
|
||||
<ShieldAlert className="size-4 shrink-0 text-destructive" />
|
||||
<span className="flex-1 text-destructive">
|
||||
{errorMsg || '人机验证失败'}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground underline">重试</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
"use client"
|
||||
|
||||
import {useEffect, useRef, useState} from "react"
|
||||
import {useMutation, useQuery} from "@tanstack/react-query"
|
||||
import {useSearchParams} from "next/navigation"
|
||||
import {EyeIcon, EyeOffIcon} from "lucide-react"
|
||||
import {toast} from "sonner"
|
||||
import Link from "next/link"
|
||||
|
||||
import {useAuth} from "@/components/providers/auth-provider"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Separator} from "@/components/ui/separator"
|
||||
import {Spinner} from "@/components/ui/spinner"
|
||||
import {Field, FieldGroup, FieldLabel} from "@/components/ui/field"
|
||||
import {CapWidget} from "@/components/auth/cap-widget"
|
||||
import {AuthHeading} from "@/components/auth/auth-shell"
|
||||
import {OTPForm} from "./otp-form"
|
||||
import {AuthService} from "@/lib/services/auth"
|
||||
import {ConfigService} from "@/lib/services/config"
|
||||
import type {LoginRequest} from "@/lib/services/auth/types"
|
||||
import {safeRedirectTarget} from "@/lib/utils"
|
||||
|
||||
function persistRedirectTarget(searchParams: ReturnType<typeof useSearchParams>) {
|
||||
const callbackUrl = searchParams.get("callbackUrl")
|
||||
if (callbackUrl && typeof window !== "undefined") {
|
||||
sessionStorage.setItem("redirect_after_login", safeRedirectTarget(callbackUrl))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function configBool(value: string | undefined, fallback: boolean) {
|
||||
if (value === undefined) return fallback
|
||||
return value === "true"
|
||||
}
|
||||
|
||||
export function LoginForm({ onOTPStateChange }: { onOTPStateChange?: (show: boolean) => void }) {
|
||||
const searchParams = useSearchParams()
|
||||
const { setUser } = useAuth()
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [code, setCode] = useState("")
|
||||
const [showLoginCodeInput, setShowLoginCodeInput] = useState(false)
|
||||
const [loginCooldown, setLoginCooldown] = useState(0)
|
||||
const [errorMessage, setErrorMessage] = useState("")
|
||||
const [loginCodeTip, setLoginCodeTip] = useState<React.ReactNode>(null)
|
||||
|
||||
useEffect(() => {
|
||||
onOTPStateChange?.(showLoginCodeInput)
|
||||
}, [showLoginCodeInput, onOTPStateChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (loginCooldown > 0) {
|
||||
const timer = setTimeout(() => setLoginCooldown(loginCooldown - 1), 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [loginCooldown])
|
||||
|
||||
// Cap token management — ref to hold latest token without triggering re-render
|
||||
const capTokenRef = useRef<string | null>(null)
|
||||
const [capReady, setCapReady] = useState(false)
|
||||
const [capError, setCapError] = useState(false)
|
||||
const [capResetKey, setCapResetKey] = useState(0)
|
||||
|
||||
const publicConfigQuery = useQuery({
|
||||
queryKey: ["public-config"],
|
||||
queryFn: () => ConfigService.getPublicConfig(),
|
||||
})
|
||||
|
||||
const authSourcesQuery = useQuery({
|
||||
queryKey: ["auth-sources"],
|
||||
queryFn: () => AuthService.getAuthSources(),
|
||||
})
|
||||
|
||||
const capEnabled = configBool(publicConfigQuery.data?.cap_login_enabled, true)
|
||||
const capAutoSolve = configBool(publicConfigQuery.data?.cap_auto_solve, true)
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: (req: LoginRequest) => {
|
||||
const headers: Record<string, string> = {}
|
||||
if (capEnabled && capTokenRef.current) {
|
||||
headers["X-Cap-Token"] = capTokenRef.current
|
||||
// Consume the token — next login attempt will need a new one
|
||||
capTokenRef.current = null
|
||||
setCapReady(false)
|
||||
}
|
||||
return AuthService.login(req, Object.keys(headers).length ? headers : undefined)
|
||||
},
|
||||
onSuccess: (user) => {
|
||||
setUser(user)
|
||||
toast.success("登录成功")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
const errorMsg = error.message || ""
|
||||
if (errorMsg.startsWith("need_email_code:")) {
|
||||
const emailMasked = errorMsg.substring("need_email_code:".length)
|
||||
setLoginCodeTip(
|
||||
<>
|
||||
已向您的安全邮箱 <span className="font-medium text-foreground">{emailMasked}</span> 发送了登录验证码。
|
||||
</>
|
||||
)
|
||||
setShowLoginCodeInput(true)
|
||||
setLoginCooldown(60)
|
||||
toast.success("登录验证码已发送至您的邮箱,请注意查收")
|
||||
if (capEnabled) {
|
||||
capTokenRef.current = null
|
||||
setCapReady(false)
|
||||
setCapResetKey((key) => key + 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (errorMsg.startsWith("smtp_invalid:")) {
|
||||
const tip = errorMsg.substring("smtp_invalid:".length)
|
||||
setLoginCodeTip("请输入您的登录验证码。")
|
||||
setShowLoginCodeInput(true)
|
||||
setLoginCooldown(0)
|
||||
toast.warning(tip)
|
||||
if (capEnabled) {
|
||||
capTokenRef.current = null
|
||||
setCapReady(false)
|
||||
setCapResetKey((key) => key + 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setErrorMessage(errorMsg || "登录失败,请重试")
|
||||
if (capEnabled) {
|
||||
capTokenRef.current = null
|
||||
setCapReady(false)
|
||||
setCapResetKey((key) => key + 1)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const handlePasswordLogin = () => {
|
||||
setErrorMessage("")
|
||||
const trimmedUsername = username.trim()
|
||||
if (!trimmedUsername || !password) {
|
||||
toast.error("邮箱/用户名或密码未填写完整", {
|
||||
description: "请先输入邮箱/用户名和密码后再登录",
|
||||
})
|
||||
return
|
||||
}
|
||||
if (capEnabled && !capReady) {
|
||||
toast.error(
|
||||
capAutoSolve
|
||||
? "人机验证尚未完成,请稍候…"
|
||||
: "请先点击「开始验证」完成人机验证",
|
||||
)
|
||||
return
|
||||
}
|
||||
loginMutation.mutate({
|
||||
username: trimmedUsername,
|
||||
password,
|
||||
code: showLoginCodeInput ? code.trim() : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const handleResendLoginCode = () => {
|
||||
setCode("")
|
||||
loginMutation.mutate({
|
||||
username: username.trim(),
|
||||
password,
|
||||
})
|
||||
}
|
||||
|
||||
const handleOAuthLogin = async (sourceName: string) => {
|
||||
try {
|
||||
setErrorMessage("")
|
||||
persistRedirectTarget(searchParams)
|
||||
const { authorize_url } = await AuthService.getAuthorizeUrl(sourceName)
|
||||
window.location.href = authorize_url
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "第三方登录失败")
|
||||
}
|
||||
}
|
||||
|
||||
const handleCapToken = (token: string) => {
|
||||
capTokenRef.current = token
|
||||
setCapReady(true)
|
||||
setCapError(false)
|
||||
}
|
||||
|
||||
const handleCapError = () => {
|
||||
capTokenRef.current = null
|
||||
setCapReady(false)
|
||||
setCapError(true)
|
||||
}
|
||||
|
||||
const registrationEnabled =
|
||||
configBool(publicConfigQuery.data?.registration_enabled, false) &&
|
||||
configBool(publicConfigQuery.data?.password_register_enabled, false)
|
||||
|
||||
const passwordLoginEnabled = configBool(publicConfigQuery.data?.password_login_enabled, true)
|
||||
const oidcLoginEnabled = configBool(publicConfigQuery.data?.oidc_login_enabled, true)
|
||||
const authSources = oidcLoginEnabled ? (authSourcesQuery.data ?? []) : []
|
||||
|
||||
const loginDisabled =
|
||||
!passwordLoginEnabled ||
|
||||
loginMutation.isPending ||
|
||||
(capEnabled && capAutoSolve && !capReady && !capError)
|
||||
|
||||
if (publicConfigQuery.isPending) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Spinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (showLoginCodeInput) {
|
||||
return (
|
||||
<OTPForm
|
||||
code={code}
|
||||
setCode={setCode}
|
||||
loginCodeTip={loginCodeTip}
|
||||
loginCooldown={loginCooldown}
|
||||
isPending={loginMutation.isPending}
|
||||
onResend={handleResendLoginCode}
|
||||
onSubmit={handlePasswordLogin}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 [@media(max-height:700px)]:gap-4">
|
||||
<AuthHeading
|
||||
siteName={publicConfigQuery.data?.site_name}
|
||||
title="登录到您的账号"
|
||||
description="欢迎回来,请输入您的账号信息继续。"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-5 [@media(max-height:700px)]:gap-3">
|
||||
<FieldGroup className="gap-4 [@media(max-height:700px)]:gap-3">
|
||||
<Field className="gap-1.5">
|
||||
<FieldLabel htmlFor="username">
|
||||
邮箱或用户名 <span className="text-destructive">*</span>
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="输入邮箱或用户名"
|
||||
autoComplete="username"
|
||||
className="h-10 text-sm [@media(max-height:700px)]:h-9"
|
||||
onKeyDown={(e) => e.key === "Enter" && handlePasswordLogin()}
|
||||
/>
|
||||
</Field>
|
||||
<Field className="gap-1.5">
|
||||
<FieldLabel htmlFor="password">
|
||||
密码 <span className="text-destructive">*</span>
|
||||
</FieldLabel>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="输入您的密码"
|
||||
autoComplete="current-password"
|
||||
className="h-10 pr-11 text-sm [@media(max-height:700px)]:h-9"
|
||||
onKeyDown={(e) => e.key === "Enter" && handlePasswordLogin()}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={showPassword ? "隐藏密码" : "显示密码"}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
onClick={() => setShowPassword((visible) => !visible)}
|
||||
>
|
||||
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
{/* Cap 人机验证 */}
|
||||
{capEnabled && (
|
||||
<CapWidget
|
||||
key={capResetKey}
|
||||
onToken={handleCapToken}
|
||||
onError={handleCapError}
|
||||
autoStart={capAutoSolve}
|
||||
/>
|
||||
)}
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="h-10 w-full [@media(max-height:700px)]:h-9"
|
||||
variant="auth"
|
||||
onClick={handlePasswordLogin}
|
||||
disabled={loginDisabled}
|
||||
>
|
||||
{loginMutation.isPending ? (
|
||||
<>
|
||||
<Spinner />
|
||||
登录中...
|
||||
</>
|
||||
) : (
|
||||
"登录"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{authSources.length > 0 ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-xs text-muted-foreground">或者使用</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{authSources.map((source) => (
|
||||
<Button
|
||||
key={source.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-10 [@media(max-height:700px)]:h-9"
|
||||
onClick={() => void handleOAuthLogin(source.name)}
|
||||
>
|
||||
{source.display_name || source.name} 登录
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{registrationEnabled && (
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
还没有账号?{" "}
|
||||
<Link href="/register" className="font-medium text-foreground underline underline-offset-4">
|
||||
立即注册
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
"use client"
|
||||
|
||||
import {useCallback, useEffect, useRef, useState} from "react"
|
||||
import {AnimatePresence, motion} from "motion/react"
|
||||
import {useRouter, useSearchParams} from "next/navigation"
|
||||
import {toast} from "sonner"
|
||||
import {Spinner} from "@/components/ui/spinner"
|
||||
import {LoginForm} from "@/components/auth/login-form"
|
||||
import {AuthShell} from "@/components/auth/auth-shell"
|
||||
import {Check} from "lucide-react"
|
||||
|
||||
import {AuthService} from "@/lib/services/auth"
|
||||
import {useAuth} from "@/components/providers/auth-provider"
|
||||
import {safeRedirectTarget} from "@/lib/utils"
|
||||
|
||||
|
||||
/**
|
||||
* 登录页面组件
|
||||
* 显示登录表单和登录按钮
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <LoginPage />
|
||||
* ```
|
||||
* @returns {React.ReactNode} 登录页面组件
|
||||
*/
|
||||
export function LoginPage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { user, loading, setUser } = useAuth()
|
||||
const [showOTP, setShowOTP] = useState(false)
|
||||
|
||||
/* 处理OAuth回调 */
|
||||
const isOAuthCallback = !!(searchParams.get('state') && searchParams.get('code'))
|
||||
const [isProcessingCallback, setIsProcessingCallback] = useState(isOAuthCallback)
|
||||
const isCheckingSession = !isOAuthCallback && loading
|
||||
|
||||
const [loginSuccess, setLoginSuccess] = useState(false)
|
||||
const redirectedRef = useRef(false)
|
||||
const callbackProcessedRef = useRef(false)
|
||||
|
||||
const resolveRedirectTarget = useCallback(() => {
|
||||
const callbackUrl = searchParams.get('callbackUrl')
|
||||
const storedRedirect = sessionStorage.getItem('redirect_after_login')
|
||||
const target = callbackUrl || storedRedirect || '/'
|
||||
|
||||
if (storedRedirect) {
|
||||
sessionStorage.removeItem('redirect_after_login')
|
||||
}
|
||||
|
||||
return safeRedirectTarget(target)
|
||||
}, [searchParams])
|
||||
|
||||
const resolveRedirectTargetRef = useRef(resolveRedirectTarget)
|
||||
useEffect(() => {
|
||||
resolveRedirectTargetRef.current = resolveRedirectTarget
|
||||
}, [resolveRedirectTarget])
|
||||
|
||||
|
||||
/* 登录页兜底:已登录用户直接跳转 */
|
||||
useEffect(() => {
|
||||
const state = searchParams.get('state')
|
||||
const code = searchParams.get('code')
|
||||
|
||||
if ((state && code) || loading || !user) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!redirectedRef.current) {
|
||||
redirectedRef.current = true
|
||||
router.replace(resolveRedirectTargetRef.current())
|
||||
}
|
||||
}, [loading, router, searchParams, user])
|
||||
|
||||
/* 回调逻辑 */
|
||||
useEffect(() => {
|
||||
const handleOAuthCallback = async () => {
|
||||
const state = searchParams.get('state')
|
||||
const code = searchParams.get('code')
|
||||
|
||||
if (state && code) {
|
||||
if (callbackProcessedRef.current) return
|
||||
callbackProcessedRef.current = true
|
||||
|
||||
setIsProcessingCallback(true)
|
||||
try {
|
||||
const result = await AuthService.handleCallback({ state, code })
|
||||
if (result.status === "need_bind") {
|
||||
toast.info("您的第三方账号未绑定本地账号,系统已关闭注册。请登录已有本地账号进行绑定。")
|
||||
setIsProcessingCallback(false)
|
||||
router.replace('/login')
|
||||
return
|
||||
}
|
||||
if (result.user) {
|
||||
setUser(result.user)
|
||||
}
|
||||
setLoginSuccess(true)
|
||||
toast.success(result.status === "bound" ? "绑定成功" : "登录成功")
|
||||
|
||||
setTimeout(() => {
|
||||
if (!redirectedRef.current) {
|
||||
redirectedRef.current = true
|
||||
router.replace(resolveRedirectTargetRef.current())
|
||||
}
|
||||
}, 1500)
|
||||
} catch (error) {
|
||||
console.error('OAuth callback error:', error)
|
||||
toast.error(error instanceof Error ? error.message : "登录失败,请重试")
|
||||
setIsProcessingCallback(false)
|
||||
router.replace('/login')
|
||||
}
|
||||
}
|
||||
}
|
||||
handleOAuthCallback()
|
||||
}, [router, searchParams, setUser])
|
||||
|
||||
return (
|
||||
<AuthShell wide={showOTP}>
|
||||
<div className="w-full">
|
||||
<AnimatePresence mode="wait">
|
||||
{isProcessingCallback || isCheckingSession ? (
|
||||
<motion.div
|
||||
key={isProcessingCallback ? "processing" : "session-check"}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="w-full"
|
||||
>
|
||||
{isCheckingSession ? (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-16">
|
||||
<div className="relative">
|
||||
<Spinner className="size-8" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<h3 className="font-semibold tracking-tight text-foreground">正在检查登录状态</h3>
|
||||
<p className="text-xs text-muted-foreground">请稍候,我们正在确认当前会话...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : loginSuccess ? (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-16">
|
||||
<motion.div
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 20 }}
|
||||
className="flex size-8 items-center justify-center rounded-full bg-primary/10 text-primary ring-1 ring-primary/20"
|
||||
>
|
||||
<Check className="size-6" strokeWidth={3} />
|
||||
</motion.div>
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<h3 className="font-semibold tracking-tight text-foreground">登录成功</h3>
|
||||
<p className="text-xs text-muted-foreground">正在跳转至控制台...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-16">
|
||||
<div className="relative">
|
||||
<Spinner className="size-8" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 text-center">
|
||||
<h3 className="font-semibold tracking-tight text-foreground">正在验证凭据</h3>
|
||||
<p className="text-xs text-muted-foreground">请稍候,我们正在为您建立安全会话...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="login-form-wrapper"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="w-full"
|
||||
>
|
||||
<LoginForm onOTPStateChange={setShowOTP} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {RefreshCwIcon} from "lucide-react"
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Field, FieldLabel} from "@/components/ui/field"
|
||||
import {InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot,} from "@/components/ui/input-otp"
|
||||
import {AuthHeading} from "@/components/auth/auth-shell"
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
interface OTPFormProps {
|
||||
code: string
|
||||
setCode: (val: string) => void
|
||||
loginCodeTip: React.ReactNode
|
||||
loginCooldown: number
|
||||
isPending: boolean
|
||||
onResend: () => void
|
||||
onSubmit: () => void
|
||||
}
|
||||
|
||||
export function OTPForm({
|
||||
code,
|
||||
setCode,
|
||||
loginCodeTip,
|
||||
loginCooldown,
|
||||
isPending,
|
||||
onResend,
|
||||
onSubmit,
|
||||
}: OTPFormProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 [@media(max-height:700px)]:gap-4">
|
||||
<AuthHeading
|
||||
title="验证您的登录"
|
||||
description="输入发送到安全邮箱的 6 位验证码。"
|
||||
/>
|
||||
{loginCodeTip ? (
|
||||
<p className="text-sm leading-6 text-muted-foreground">{loginCodeTip}</p>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-5 [@media(max-height:700px)]:gap-3">
|
||||
<Field className="gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<FieldLabel htmlFor="otp-verification" className="text-sm font-medium">
|
||||
验证码
|
||||
</FieldLabel>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={onResend}
|
||||
disabled={loginCooldown > 0 || isPending}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
<RefreshCwIcon className={cn(isPending && "animate-spin")} />
|
||||
{loginCooldown > 0 ? `${loginCooldown}秒后重发` : "重新发送"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex justify-start">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
id="otp-verification"
|
||||
required
|
||||
value={code}
|
||||
onChange={setCode}
|
||||
onComplete={onSubmit}
|
||||
disabled={isPending}
|
||||
>
|
||||
<InputOTPGroup className="*:data-[slot=input-otp-slot]:h-12 *:data-[slot=input-otp-slot]:w-11 *:data-[slot=input-otp-slot]:text-xl">
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator className="mx-2" />
|
||||
<InputOTPGroup className="*:data-[slot=input-otp-slot]:h-12 *:data-[slot=input-otp-slot]:w-11 *:data-[slot=input-otp-slot]:text-xl">
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</Field>
|
||||
<Button
|
||||
type="button"
|
||||
className="h-10 w-full [@media(max-height:700px)]:h-9"
|
||||
variant="auth"
|
||||
onClick={onSubmit}
|
||||
disabled={isPending || code.length < 6}
|
||||
>
|
||||
{isPending ? "验证中..." : "验证"}
|
||||
</Button>
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
遇到登录问题?{" "}
|
||||
<a
|
||||
href="#"
|
||||
className="underline underline-offset-4 transition-colors hover:text-primary"
|
||||
>
|
||||
联系客服
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
"use client"
|
||||
|
||||
import {useEffect, useMemo, useRef, useState} from "react"
|
||||
import {useMutation, useQuery} from "@tanstack/react-query"
|
||||
import {useRouter, useSearchParams} from "next/navigation"
|
||||
import {toast} from "sonner"
|
||||
import Link from "next/link"
|
||||
|
||||
import {useAuth} from "@/components/providers/auth-provider"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Spinner} from "@/components/ui/spinner"
|
||||
import {Field, FieldGroup, FieldLabel} from "@/components/ui/field"
|
||||
import {AuthHeading} from "@/components/auth/auth-shell"
|
||||
import {CapWidget} from "@/components/auth/cap-widget"
|
||||
import {AuthService} from "@/lib/services/auth"
|
||||
import {ConfigService} from "@/lib/services/config"
|
||||
import type {RegisterRequest} from "@/lib/services/auth/types"
|
||||
import {safeRedirectTarget} from "@/lib/utils"
|
||||
|
||||
function getRedirectTarget(searchParams: ReturnType<typeof useSearchParams>) {
|
||||
const callbackUrl = searchParams.get("callbackUrl")
|
||||
const storedRedirect =
|
||||
typeof window === "undefined"
|
||||
? null
|
||||
: sessionStorage.getItem("redirect_after_login")
|
||||
return safeRedirectTarget(callbackUrl || storedRedirect || "/")
|
||||
}
|
||||
|
||||
|
||||
function configBool(value: string | undefined, fallback: boolean) {
|
||||
if (value === undefined) return fallback
|
||||
return value === "true"
|
||||
}
|
||||
|
||||
export function RegisterForm() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { setUser } = useAuth()
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [nickname, setNickname] = useState("")
|
||||
const [email, setEmail] = useState("")
|
||||
const [code, setCode] = useState("")
|
||||
const [registerCooldown, setRegisterCooldown] = useState(0)
|
||||
const [errorMessage, setErrorMessage] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (registerCooldown > 0) {
|
||||
const timer = setTimeout(() => setRegisterCooldown(registerCooldown - 1), 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [registerCooldown])
|
||||
|
||||
const publicConfigQuery = useQuery({
|
||||
queryKey: ["public-config"],
|
||||
queryFn: () => ConfigService.getPublicConfig(),
|
||||
})
|
||||
|
||||
const redirectTarget = useMemo(
|
||||
() => getRedirectTarget(searchParams),
|
||||
[searchParams],
|
||||
)
|
||||
|
||||
const registrationEnabled =
|
||||
configBool(publicConfigQuery.data?.registration_enabled, false) &&
|
||||
configBool(publicConfigQuery.data?.password_register_enabled, false)
|
||||
|
||||
const emailRegisterEnabled = configBool(publicConfigQuery.data?.email_register_verification_enabled, false)
|
||||
|
||||
const capEnabled = configBool(publicConfigQuery.data?.cap_login_enabled, true)
|
||||
const capAutoSolve = configBool(publicConfigQuery.data?.cap_auto_solve, true)
|
||||
|
||||
const [capScope, setCapScope] = useState<'send_email_code' | 'register'>('send_email_code')
|
||||
|
||||
// 监听 emailRegisterEnabled 改变初始 scope
|
||||
useEffect(() => {
|
||||
setCapScope(emailRegisterEnabled ? 'send_email_code' : 'register')
|
||||
}, [emailRegisterEnabled])
|
||||
|
||||
const capTokenRef = useRef<string | null>(null)
|
||||
const [capReady, setCapReady] = useState(false)
|
||||
const [capError, setCapError] = useState(false)
|
||||
const [capResetKey, setCapResetKey] = useState(0)
|
||||
|
||||
const handleCapToken = (token: string) => {
|
||||
capTokenRef.current = token
|
||||
setCapReady(true)
|
||||
setCapError(false)
|
||||
}
|
||||
|
||||
const handleCapError = () => {
|
||||
capTokenRef.current = null
|
||||
setCapReady(false)
|
||||
setCapError(true)
|
||||
}
|
||||
|
||||
// Redirect to login if registration is closed
|
||||
useEffect(() => {
|
||||
if (publicConfigQuery.isSuccess && !registrationEnabled) {
|
||||
toast.error("系统注册功能已关闭")
|
||||
router.replace("/login")
|
||||
}
|
||||
}, [publicConfigQuery.isSuccess, registrationEnabled, router])
|
||||
|
||||
const registerMutation = useMutation({
|
||||
mutationFn: (req: RegisterRequest) => {
|
||||
const headers: Record<string, string> = {}
|
||||
if (capEnabled && capTokenRef.current) {
|
||||
headers["X-Cap-Token"] = capTokenRef.current
|
||||
capTokenRef.current = null
|
||||
setCapReady(false)
|
||||
}
|
||||
return AuthService.register(req, Object.keys(headers).length ? headers : undefined)
|
||||
},
|
||||
onSuccess: (user) => {
|
||||
setUser(user)
|
||||
router.replace(redirectTarget)
|
||||
toast.success("注册并登录成功")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setErrorMessage(error.message || "注册失败,请重试")
|
||||
if (capEnabled) {
|
||||
capTokenRef.current = null
|
||||
setCapReady(false)
|
||||
setCapResetKey((key) => key + 1)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const sendRegisterCodeMutation = useMutation({
|
||||
mutationFn: (targetEmail: string) => {
|
||||
const headers: Record<string, string> = {}
|
||||
if (capEnabled && capTokenRef.current) {
|
||||
headers["X-Cap-Token"] = capTokenRef.current
|
||||
capTokenRef.current = null
|
||||
setCapReady(false)
|
||||
}
|
||||
return AuthService.sendEmailCode(targetEmail, "register", Object.keys(headers).length ? headers : undefined)
|
||||
},
|
||||
onSuccess: () => {
|
||||
setRegisterCooldown(60)
|
||||
toast.success("验证码已发送至您的邮箱,请查收")
|
||||
if (capEnabled) {
|
||||
setCapScope('register')
|
||||
capTokenRef.current = null
|
||||
setCapReady(false)
|
||||
setCapResetKey((key) => key + 1)
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "发送验证码失败,请重试")
|
||||
if (capEnabled) {
|
||||
capTokenRef.current = null
|
||||
setCapReady(false)
|
||||
setCapResetKey((key) => key + 1)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const registerDisabled =
|
||||
registerMutation.isPending ||
|
||||
(capEnabled && capScope === 'register' && capAutoSolve && !capReady && !capError)
|
||||
|
||||
const sendCodeDisabled =
|
||||
registerCooldown > 0 ||
|
||||
sendRegisterCodeMutation.isPending ||
|
||||
(capEnabled && capScope === 'send_email_code' && capAutoSolve && !capReady && !capError)
|
||||
|
||||
const handleSendRegisterCode = () => {
|
||||
const trimmedEmail = email.trim()
|
||||
if (!trimmedEmail) {
|
||||
toast.error("请先输入邮箱地址")
|
||||
return
|
||||
}
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(trimmedEmail)) {
|
||||
toast.error("请输入有效的邮箱地址")
|
||||
return
|
||||
}
|
||||
if (capEnabled && capScope === 'send_email_code' && !capReady) {
|
||||
toast.error(
|
||||
capAutoSolve
|
||||
? "人机验证尚未完成,请稍候…"
|
||||
: "请先点击「开始验证」完成人机验证",
|
||||
)
|
||||
return
|
||||
}
|
||||
sendRegisterCodeMutation.mutate(trimmedEmail)
|
||||
}
|
||||
|
||||
const handleRegister = () => {
|
||||
setErrorMessage("")
|
||||
if (!username.trim() || !password) {
|
||||
toast.error("用户名和密码不能为空")
|
||||
return
|
||||
}
|
||||
if (password.length < 8) {
|
||||
toast.error("密码长度不能少于 8 位")
|
||||
return
|
||||
}
|
||||
const trimmedEmail = email.trim()
|
||||
if (!trimmedEmail) {
|
||||
toast.error("邮箱不能为空")
|
||||
return
|
||||
}
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(trimmedEmail)) {
|
||||
toast.error("请输入有效的邮箱地址")
|
||||
return
|
||||
}
|
||||
if (emailRegisterEnabled && !code.trim()) {
|
||||
toast.error("验证码不能为空")
|
||||
return
|
||||
}
|
||||
if (capEnabled && capScope === 'register' && !capReady) {
|
||||
toast.error(
|
||||
capAutoSolve
|
||||
? "人机验证尚未完成,请稍候…"
|
||||
: "请先点击「开始验证」完成人机验证",
|
||||
)
|
||||
return
|
||||
}
|
||||
registerMutation.mutate({
|
||||
username: username.trim(),
|
||||
password,
|
||||
nickname: nickname.trim() || undefined,
|
||||
email: trimmedEmail,
|
||||
code: code.trim() || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
if (publicConfigQuery.isPending) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Spinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!registrationEnabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 [@media(max-height:700px)]:gap-4">
|
||||
<AuthHeading
|
||||
siteName={publicConfigQuery.data?.site_name}
|
||||
title="创建您的账号"
|
||||
description="填写以下信息,开始使用平台服务。"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-5 [@media(max-height:700px)]:gap-3">
|
||||
<FieldGroup className="gap-4 [@media(min-width:500px)]:grid [@media(min-width:500px)]:grid-cols-2 [@media(max-height:700px)]:gap-3">
|
||||
<Field className="gap-1.5">
|
||||
<FieldLabel htmlFor="username">用户名 <span className="text-destructive">*</span></FieldLabel>
|
||||
<Input
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="请输入用户名"
|
||||
autoComplete="username"
|
||||
className="h-10 text-sm [@media(max-height:700px)]:h-9"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleRegister()}
|
||||
/>
|
||||
</Field>
|
||||
<Field className="gap-1.5">
|
||||
<FieldLabel htmlFor="nickname">
|
||||
昵称
|
||||
<span className="ml-1 text-xs font-normal text-muted-foreground">(可选)</span>
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="nickname"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
placeholder="请输入昵称"
|
||||
autoComplete="nickname"
|
||||
className="h-10 text-sm [@media(max-height:700px)]:h-9"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleRegister()}
|
||||
/>
|
||||
</Field>
|
||||
<Field className="gap-1.5">
|
||||
<FieldLabel htmlFor="email">电子邮箱 <span className="text-destructive">*</span></FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="请输入电子邮箱"
|
||||
autoComplete="email"
|
||||
className="h-10 text-sm [@media(max-height:700px)]:h-9"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleRegister()}
|
||||
/>
|
||||
</Field>
|
||||
<Field className="gap-1.5">
|
||||
<FieldLabel htmlFor="password">密码 <span className="text-destructive">*</span></FieldLabel>
|
||||
<Input
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
type="password"
|
||||
placeholder="请输入密码(至少 8 位)"
|
||||
autoComplete="new-password"
|
||||
className="h-10 text-sm [@media(max-height:700px)]:h-9"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleRegister()}
|
||||
/>
|
||||
</Field>
|
||||
{emailRegisterEnabled && (
|
||||
<Field className="gap-1.5 [@media(min-width:500px)]:col-span-2">
|
||||
<FieldLabel htmlFor="code">邮箱验证码 <span className="text-destructive">*</span></FieldLabel>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="请输入 6 位邮箱验证码"
|
||||
maxLength={6}
|
||||
className="h-10 flex-1 text-sm [@media(max-height:700px)]:h-9"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleRegister()}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleSendRegisterCode}
|
||||
disabled={sendCodeDisabled}
|
||||
className="h-10 w-[120px] text-xs [@media(max-height:700px)]:h-9"
|
||||
>
|
||||
{registerCooldown > 0 ? `${registerCooldown}秒后重发` : "获取验证码"}
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
</FieldGroup>
|
||||
|
||||
{capEnabled && (
|
||||
<CapWidget
|
||||
key={capResetKey}
|
||||
scope={capScope}
|
||||
onToken={handleCapToken}
|
||||
onError={handleCapError}
|
||||
autoStart={capAutoSolve}
|
||||
/>
|
||||
)}
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="h-10 w-full [@media(max-height:700px)]:h-9"
|
||||
variant="auth"
|
||||
onClick={handleRegister}
|
||||
disabled={registerDisabled}
|
||||
>
|
||||
{registerMutation.isPending ? (
|
||||
<>
|
||||
<Spinner />
|
||||
注册中...
|
||||
</>
|
||||
) : (
|
||||
"创建账号"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
已经有账号?{" "}
|
||||
<Link href="/login" className="font-medium text-foreground underline underline-offset-4">
|
||||
返回登录
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import {RegisterForm} from "@/components/auth/register-form"
|
||||
import {AuthShell} from "@/components/auth/auth-shell"
|
||||
|
||||
export function RegisterPage() {
|
||||
return (
|
||||
<AuthShell wide>
|
||||
<RegisterForm />
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client"
|
||||
|
||||
import type {ReactNode} from "react"
|
||||
import {Loader2} from "lucide-react"
|
||||
|
||||
import {ErrorPage} from "@/components/layout/error"
|
||||
import {useUser} from "@/contexts/user-context"
|
||||
|
||||
type RequireAuthProps = {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
minHeightClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards page content until the session user is available.
|
||||
* Redirect to login is handled by the parent layout; this only covers the content slot.
|
||||
*/
|
||||
export function RequireAuth({
|
||||
children,
|
||||
fallback,
|
||||
minHeightClassName = "min-h-[400px]",
|
||||
}: RequireAuthProps) {
|
||||
const {user, loading} = useUser()
|
||||
|
||||
if (loading) {
|
||||
return fallback ?? (
|
||||
<div className={`flex items-center justify-center ${minHeightClassName}`}>
|
||||
<Loader2 className="size-6 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
type RequireAdminAuthProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/** Guards admin routes after the shared shell has rendered. */
|
||||
export function RequireAdminAuth({children}: RequireAdminAuthProps) {
|
||||
const {user, loading} = useUser()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-[400px] items-center justify-center">
|
||||
<Loader2 className="size-6 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user?.is_admin) {
|
||||
return (
|
||||
<ErrorPage
|
||||
title="访问被拒绝"
|
||||
message="您没有权限访问此页面"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import {type PolicySection} from "./types"
|
||||
import {CodeBlock} from "@/components/ui/code-block"
|
||||
import {
|
||||
DocsTable,
|
||||
DocsTableBody,
|
||||
DocsTableCell,
|
||||
DocsTableHead,
|
||||
DocsTableHeader,
|
||||
DocsTableRow,
|
||||
} from "@/components/ui/docs-table"
|
||||
|
||||
export const DOCS_LAST_UPDATED = "2026-06-07"
|
||||
|
||||
/**
|
||||
* ------------------------------------------------------------------
|
||||
* API 文档
|
||||
* ------------------------------------------------------------------
|
||||
*/
|
||||
export const apiSections: PolicySection[] = [
|
||||
{
|
||||
value: "api-specs",
|
||||
title: "1. 接口规范与鉴权说明",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<div className="bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6">
|
||||
<p className="text-muted-foreground m-0">平台统一接口调用格式规范以及开发者访问令牌鉴权方式说明</p>
|
||||
</div>
|
||||
|
||||
<h3 id="1-1-response-format" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-3">1.1 统一响应格式</h3>
|
||||
<p>系统所有 API 接口均遵循标准 JSON 响应结构:</p>
|
||||
<DocsTable>
|
||||
<DocsTableHeader>
|
||||
<DocsTableRow>
|
||||
<DocsTableHead className="w-[120px]">字段</DocsTableHead>
|
||||
<DocsTableHead className="w-[100px]">类型</DocsTableHead>
|
||||
<DocsTableHead>说明</DocsTableHead>
|
||||
</DocsTableRow>
|
||||
</DocsTableHeader>
|
||||
<DocsTableBody>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">error_msg</DocsTableCell>
|
||||
<DocsTableCell>string</DocsTableCell>
|
||||
<DocsTableCell>错误信息。请求成功时为空字符串 `""`,失败时包含错误详情描述。</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">data</DocsTableCell>
|
||||
<DocsTableCell>any</DocsTableCell>
|
||||
<DocsTableCell>接口返回的具体数据内容。请求失败或无数据返回时为 `null`。</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
</DocsTableBody>
|
||||
</DocsTable>
|
||||
|
||||
<p className="mt-2">成功响应示例:</p>
|
||||
<CodeBlock
|
||||
code={`{
|
||||
"error_msg": "",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"username": "ryan",
|
||||
"nickname": "Ryan"
|
||||
}
|
||||
}`}
|
||||
language="json"
|
||||
/>
|
||||
|
||||
<p className="mt-2">失败响应示例:</p>
|
||||
<CodeBlock
|
||||
code={`{
|
||||
"error_msg": "用户密码错误",
|
||||
"data": null
|
||||
}`}
|
||||
language="json"
|
||||
/>
|
||||
|
||||
<h3 id="1-2-authentication" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-3">1.2 鉴权方式</h3>
|
||||
<p>除了公共公开接口(如登录、注册、配置)外,受保护的接口需要携带凭证才能正常访问:</p>
|
||||
<ul className="list-disc pl-5 space-y-2">
|
||||
<li><strong>Session 凭证:</strong>浏览器环境下支持利用常规 Session Cookie 会话保持登录。</li>
|
||||
<li><strong>AccessToken 令牌:</strong>供后台调用或第三方应用集成使用。客户端生成 API 访问令牌后,需要在请求头(Request Header)中携带以进行身份校验。</li>
|
||||
</ul>
|
||||
<div className="bg-muted border rounded-xl p-4 mt-2 space-y-2">
|
||||
<p className="font-bold text-xs">支持携带令牌的请求头格式(二选一):</p>
|
||||
<ul className="list-disc pl-5 text-xs text-muted-foreground space-y-1">
|
||||
<li><code className="bg-muted-foreground/10 px-1 rounded text-[11px] font-mono">Authorization: Bearer at_xxx</code></li>
|
||||
<li><code className="bg-muted-foreground/10 px-1 rounded text-[11px] font-mono">X-Access-Token: at_xxx</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
children: [
|
||||
{ value: "1-1-response-format", title: "1.1 统一响应格式" },
|
||||
{ value: "1-2-authentication", title: "1.2 鉴权方式" },
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "auth-apis",
|
||||
title: "2. 用户与认证接口",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<h3 id="2-1-register" className="text-base md:text-lg font-semibold text-foreground mt-4 mb-2">2.1 用户注册</h3>
|
||||
<p><strong>接口:</strong>POST <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">/api/v1/user/register</code></p>
|
||||
<p><strong>说明:</strong>注册本地账户(在后台注册开关开启状态下)。</p>
|
||||
<DocsTable>
|
||||
<DocsTableHeader>
|
||||
<DocsTableRow>
|
||||
<DocsTableHead className="w-[120px]">参数</DocsTableHead>
|
||||
<DocsTableHead className="w-[80px]">必填</DocsTableHead>
|
||||
<DocsTableHead>类型</DocsTableHead>
|
||||
<DocsTableHead>说明</DocsTableHead>
|
||||
</DocsTableRow>
|
||||
</DocsTableHeader>
|
||||
<DocsTableBody>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">username</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>string</DocsTableCell>
|
||||
<DocsTableCell>用户名,必须唯一且无空格。</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">password</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>string</DocsTableCell>
|
||||
<DocsTableCell>密码,长度必须大于等于 8 位。</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">nickname</DocsTableCell>
|
||||
<DocsTableCell>否</DocsTableCell>
|
||||
<DocsTableCell>string</DocsTableCell>
|
||||
<DocsTableCell>昵称。未传时默认与用户名一致。</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
</DocsTableBody>
|
||||
</DocsTable>
|
||||
|
||||
<h3 id="2-2-login" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-2">2.2 密码登录</h3>
|
||||
<p><strong>接口:</strong>POST <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">/api/v1/user/login</code></p>
|
||||
<p><strong>说明:</strong>通过常规用户名密码进行登录校验,成功后建立 Session Cookie 会话。</p>
|
||||
<DocsTable>
|
||||
<DocsTableHeader>
|
||||
<DocsTableRow>
|
||||
<DocsTableHead className="w-[120px]">参数</DocsTableHead>
|
||||
<DocsTableHead className="w-[80px]">必填</DocsTableHead>
|
||||
<DocsTableHead>类型</DocsTableHead>
|
||||
<DocsTableHead>说明</DocsTableHead>
|
||||
</DocsTableRow>
|
||||
</DocsTableHeader>
|
||||
<DocsTableBody>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">username</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>string</DocsTableCell>
|
||||
<DocsTableCell>用户名</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">password</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>string</DocsTableCell>
|
||||
<DocsTableCell>密码</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
</DocsTableBody>
|
||||
</DocsTable>
|
||||
|
||||
<h3 id="2-3-logout" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-2">2.3 退出登录</h3>
|
||||
<p><strong>接口:</strong>GET <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">/api/v1/user/logout</code></p>
|
||||
<p><strong>说明:</strong>销毁当前会话 Cookie 并退出登录状态。</p>
|
||||
|
||||
<h3 id="2-4-profile" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-2">2.4 获取个人资料</h3>
|
||||
<p><strong>接口:</strong>GET <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">/api/v1/user/self</code></p>
|
||||
<p><strong>说明:</strong>获取当前登录账户的基本数据模型(包含 ID、角色、昵称等)。</p>
|
||||
</div>
|
||||
),
|
||||
children: [
|
||||
{ value: "2-1-register", title: "2.1 用户注册" },
|
||||
{ value: "2-2-login", title: "2.2 密码登录" },
|
||||
{ value: "2-3-logout", title: "2.3 退出登录" },
|
||||
{ value: "2-4-profile", title: "2.4 获取个人资料" },
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "token-apis",
|
||||
title: "3. 个人访问令牌 (AccessToken) 接口",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<div className="bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-4">
|
||||
<p className="text-muted-foreground m-0">AccessToken 管理相关接口均要求通过 Session 登录后调用,支持普通用户权限。</p>
|
||||
</div>
|
||||
|
||||
<h3 id="3-1-list-token" className="text-base md:text-lg font-semibold text-foreground mt-4 mb-2">3.1 获取令牌列表</h3>
|
||||
<p><strong>接口:</strong>GET <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">/api/v1/user/access-tokens</code></p>
|
||||
<p><strong>说明:</strong>查询当前用户已创建的所有令牌详情(令牌明文已被脱敏)。</p>
|
||||
|
||||
<h3 id="3-2-create-token" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-2">3.2 新建访问令牌</h3>
|
||||
<p><strong>接口:</strong>POST <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">/api/v1/user/access-tokens</code></p>
|
||||
<p><strong>参数:</strong>JSON Body <code className="bg-muted px-1.5 rounded text-xs font-mono">{`{"name": "token名称", "is_admin": false}`}</code></p>
|
||||
<p><strong>说明:</strong>生成一个全新访问令牌。返回体中包含一次性明文 Token,切勿遗失。</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground"><code className="bg-muted px-1 rounded">is_admin</code>(可选,默认 <code className="bg-muted px-1 rounded">false</code>):是否赋予令牌管理员权限,仅管理员用户可设置。非管理员令牌无法访问 <code className="bg-muted px-1 rounded">/admin/**</code> 端点。</p>
|
||||
<p className="mt-2">成功返回样例:</p>
|
||||
<CodeBlock
|
||||
code={`{
|
||||
"error_msg": "",
|
||||
"data": {
|
||||
"token": "at_628d022b7a95e26bcd8b29c9...",
|
||||
"record": {
|
||||
"id": 5,
|
||||
"user_id": 1,
|
||||
"name": "my-dev-key",
|
||||
"masked_token": "at_628d...29c9",
|
||||
"is_admin": false,
|
||||
"created_at": "2026-06-07T21:30:00+08:00"
|
||||
}
|
||||
}
|
||||
}`}
|
||||
language="json"
|
||||
/>
|
||||
|
||||
<h3 id="3-3-delete-token" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-2">3.3 撤销/删除令牌</h3>
|
||||
<p><strong>接口:</strong>DELETE <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">/api/v1/user/access-tokens/:id</code></p>
|
||||
<p><strong>说明:</strong>通过 ID 物理删除对应访问令牌,该令牌将立即失效。</p>
|
||||
|
||||
<h3 id="3-4-rotate-token" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-2">3.4 轮换令牌密钥</h3>
|
||||
<p><strong>接口:</strong>POST <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">/api/v1/user/access-tokens/:id/rotate</code></p>
|
||||
<p><strong>说明:</strong>轮换指定令牌的物理密钥值。系统将废弃原有密钥,返回新生成的明文 Token,令牌名称与 ID 保持一致。</p>
|
||||
</div>
|
||||
),
|
||||
children: [
|
||||
{ value: "3-1-list-token", title: "3.1 获取令牌列表" },
|
||||
{ value: "3-2-create-token", title: "3.2 新建访问令牌" },
|
||||
{ value: "3-3-delete-token", title: "3.3 撤销/删除令牌" },
|
||||
{ value: "3-4-rotate-token", title: "3.4 轮换令牌密钥" },
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "config-apis",
|
||||
title: "4. 公共配置与管理接口",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<h3 id="4-1-public-config" className="text-base md:text-lg font-semibold text-foreground mt-4 mb-2">4.1 公共系统配置</h3>
|
||||
<p><strong>接口:</strong>GET <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">/api/v1/config/public</code></p>
|
||||
<p><strong>说明:</strong>无感获取当前系统配置表中公共可见的键值集合。供前端页面动态渲染使用。</p>
|
||||
<p className="mt-2">返回数据结构样例:</p>
|
||||
<CodeBlock
|
||||
code={`{
|
||||
"error_msg": "",
|
||||
"data": {
|
||||
"site_name": "OpenFlare",
|
||||
"registration_enabled": "false",
|
||||
"password_login_enabled": "true",
|
||||
"password_register_enabled": "false",
|
||||
"cap_login_enabled": "true",
|
||||
"oidc_login_enabled": "true"
|
||||
}
|
||||
}`}
|
||||
language="json"
|
||||
/>
|
||||
|
||||
<h3 id="4-2-admin-configs" className="text-base md:text-lg font-semibold text-foreground mt-6 mb-2">4.2 系统配置项 CRUD (管理员)</h3>
|
||||
<p><strong>说明:</strong>用于在后台对 `system_configs` 配置进行动态变更,要求管理员权限会话调用。</p>
|
||||
<ul className="list-disc pl-5 space-y-2">
|
||||
<li><strong>获取配置列表:</strong>GET <code className="bg-muted px-1 rounded text-xs font-mono">/api/v1/admin/system-configs?type=system</code></li>
|
||||
<li><strong>新建配置项:</strong>POST <code className="bg-muted px-1 rounded text-xs font-mono">/api/v1/admin/system-configs</code></li>
|
||||
<li><strong>修改指定配置值:</strong>PUT <code className="bg-muted px-1 rounded text-xs font-mono">/api/v1/admin/system-configs/:key</code></li>
|
||||
<li><strong>删除配置项:</strong>DELETE <code className="bg-muted px-1 rounded text-xs font-mono">/api/v1/admin/system-configs/:key</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
children: [
|
||||
{ value: "4-1-public-config", title: "4.1 公共系统配置" },
|
||||
{ value: "4-2-admin-configs", title: "4.2 系统配置项 CRUD (管理员)" },
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,138 @@
|
||||
import {type PolicySection} from "./types"
|
||||
import {CodeBlock} from "@/components/ui/code-block"
|
||||
|
||||
/**
|
||||
* ------------------------------------------------------------------
|
||||
* 使用指南 (How To Use)
|
||||
* ------------------------------------------------------------------
|
||||
*/
|
||||
export const howToUseSections: PolicySection[] = [
|
||||
{
|
||||
value: "quick-start",
|
||||
title: "1. 快速开始",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<div className="bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6">
|
||||
<p className="text-muted-foreground m-0">为开发者提供通用全栈开发脚手架 (Boilerplate) 平台使用说明</p>
|
||||
</div>
|
||||
<ul className="list-disc pl-5 space-y-2">
|
||||
<li><strong>架构底座:</strong>Go (Gin + GORM + Redis + Asynq) 后端 + React (Next.js 16 + Tailwind CSS 4 + Shadcn UI) 前端</li>
|
||||
<li><strong>认证体系:</strong>支持本地常规账号密码注册登录 + 第三方自定义 OIDC (OAuth2) 认证源绑定</li>
|
||||
<li><strong>访问令牌:</strong>提供开发者个人 AccessToken (API Key),用于通过 Http Header 鉴权直接调用系统 API</li>
|
||||
<li><strong>可观测性:</strong>集成 Zap 结构化日志与 OpenTelemetry 全链路 Tracing 追踪</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: "auth-security",
|
||||
title: "2. 身份认证与安全设置",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p>平台采用混合式身份认证,满足不同的部署和业务场景需求:</p>
|
||||
<h3 id="2-1-login" className="text-base md:text-lg font-semibold text-foreground mt-4 mb-2">2.1 常规账号密码认证</h3>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2">
|
||||
<li><strong>自主注册与密码登录:</strong>支持普通用户通过用户名及密码直接进行注册与会话建立,密码在后端采用 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">bcrypt</code> 高强度加盐哈希存储。</li>
|
||||
<li><strong>系统开关控制:</strong>管理员可在后台配置动态开关,随时禁用自主密码注册或密码登录,以转为纯第三方认证模式。</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="2-2-oidc" className="text-base md:text-lg font-semibold text-foreground mt-4 mb-2">2.2 第三方 OIDC 认证源</h3>
|
||||
<p>用户可以在个人资料页面关联绑定外部授权账户:</p>
|
||||
<ol className="list-decimal pl-4 md:pl-5 space-y-1">
|
||||
<li>进入 <strong>设置 / 个人资料</strong> 页面。</li>
|
||||
<li>在“第三方账号绑定”栏目下查看当前绑定的账号,或点击未绑定的可用 OIDC 认证源直接触发 OAuth2 绑定流。</li>
|
||||
<li>绑定成功后,用户在登录界面可直接点击 OIDC 登录按钮实现快捷跳转。</li>
|
||||
</ol>
|
||||
</div>
|
||||
),
|
||||
children: [
|
||||
{ value: "2-1-login", title: "2.1 常规账号密码认证" },
|
||||
{ value: "2-2-oidc", title: "2.2 第三方 OIDC 认证源" },
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "access-token",
|
||||
title: "3. 个人访问令牌 (AccessToken) 接口对接",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p>为便于开发者或第三方工具直接调用系统 API,平台提供个人访问令牌管理功能。</p>
|
||||
<h3 id="3-1-generation" className="text-base md:text-lg font-semibold text-foreground mt-4 mb-2">3.1 令牌生成与存储规范</h3>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2">
|
||||
<li><strong>一次性明文展示:</strong>创建令牌时生成的随机明文 Token 值 (形如 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono">at_xxx</code>) 仅会在弹窗中展示一次。请立即复制保存,关闭弹窗后系统将无法重新获取。</li>
|
||||
<li><strong>安全哈希存储:</strong>数据库仅存储 Token 的 SHA-256 哈希指纹,即使数据库泄漏,攻击者也无法通过摘要逆向恢复令牌原文。</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="3-2-usage" className="text-base md:text-lg font-semibold text-foreground mt-4 mb-2">3.2 携带 Header 进行 API 调用</h3>
|
||||
<p>您可以凭借保存的明文 Token 随时调用系统开放接口,系统认证支持以下两种 Http 请求头携带方式之一:</p>
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold text-xs text-muted-foreground">方式一:Authorization Bearer 头</p>
|
||||
<CodeBlock
|
||||
code={`GET /api/v1/user/self HTTP/1.1
|
||||
Host: localhost:3000
|
||||
Authorization: Bearer at_628d022b7a95e26b...`}
|
||||
language="http"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold text-xs text-muted-foreground">方式二:X-Access-Token 自定义头</p>
|
||||
<CodeBlock
|
||||
code={`GET /api/v1/user/self HTTP/1.1
|
||||
Host: localhost:3000
|
||||
X-Access-Token: at_628d022b7a95e26b...`}
|
||||
language="http"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
children: [
|
||||
{ value: "3-1-generation", title: "3.1 令牌生成与存储规范" },
|
||||
{ value: "3-2-usage", title: "3.2 携带 Header 进行 API 调用" },
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "config-system",
|
||||
title: "4. 动态系统配置项",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p>平台内置了完备的 KV 配置管理模块,允许管理员动态调整系统运行状态:</p>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2">
|
||||
<li><strong>缓存读取加速:</strong>配置加载基于 GORM 读取数据库,并辅以 Redis Hash 结构进行多层缓存加速,大幅降低配置查询耗时。</li>
|
||||
<li><strong>核心系统配置项说明:</strong>
|
||||
<ul className="list-[circle] pl-5 mt-1 space-y-1 text-muted-foreground">
|
||||
<li><code className="bg-muted px-1 rounded text-xs font-mono">site_name</code>:平台展示名称</li>
|
||||
<li><code className="bg-muted px-1 rounded text-xs font-mono">password_login_enabled</code>:密码登录开关</li>
|
||||
<li><code className="bg-muted px-1 rounded text-xs font-mono">registration_enabled</code>:用户自主注册开关</li>
|
||||
<li><code className="bg-muted px-1 rounded text-xs font-mono">max_api_keys_per_user</code>:普通用户创建令牌的最大数限制 (默认5)</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: "worker-scheduler",
|
||||
title: "5. 异步任务与定时调度",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p>项目借助 Cobra 实现多命令入口分发,通过独立部署 Worker 服务解耦复杂计算或高延迟IO:</p>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2">
|
||||
<li><strong>定时任务派发 (CMD scheduler):</strong>负责按 Cron 表达式配置,定时将待执行任务推送到 Redis 队列中。</li>
|
||||
<li><strong>多优先级 Worker (CMD worker):</strong>基于 Asynq 驱动,按优先级拉取任务并并发调度执行(如定时清理临时上传目录、同步外部系统日志等)。</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: "tracing-metrics",
|
||||
title: "6. 链路追踪与结构化日志",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p>为了保障分布式微服务架构下的可观测性,平台接入了高级监控组件:</p>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2">
|
||||
<li><strong>OpenTelemetry Tracing:</strong>自动传递 Tracing 上下文,所有经由 Gin 中间件、外部请求或 GORM 数据库的事务操作都将带有全局唯一的 Span,用于排查链路耗时或调用异常。</li>
|
||||
<li><strong>Zap 结构化日志:</strong>将后端控制台或日志输出格式统一规范化为 JSON,方便与 ELK、Loki 等日志收集分析工具无缝对接。</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,411 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import {ArrowLeft, Calendar, Check, Copy, FileText} from "lucide-react"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {cn} from "@/lib/utils"
|
||||
import {motion, useScroll, useSpring} from "motion/react"
|
||||
import type {PolicySection} from "./types"
|
||||
|
||||
interface LegalPageLayoutProps {
|
||||
title: string
|
||||
lastUpdated: string
|
||||
sections: PolicySection[]
|
||||
description?: React.ReactNode
|
||||
}
|
||||
|
||||
export function LegalPageLayout({
|
||||
title,
|
||||
lastUpdated,
|
||||
sections,
|
||||
description,
|
||||
}: LegalPageLayoutProps) {
|
||||
const [activeSection, setActiveSection] = React.useState<string>(sections[0]?.value || "")
|
||||
const [hasCopied, setHasCopied] = React.useState(false)
|
||||
const { scrollYProgress } = useScroll()
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const scaleX = useSpring(scrollYProgress, {
|
||||
stiffness: 100,
|
||||
damping: 30,
|
||||
restDelta: 0.001
|
||||
})
|
||||
|
||||
const allSectionIds = React.useMemo(() => {
|
||||
return sections.flatMap(section => [
|
||||
section.value,
|
||||
...(section.children?.map(child => child.value) || [])
|
||||
])
|
||||
}, [sections])
|
||||
|
||||
React.useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setActiveSection(entry.target.id)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ rootMargin: "-20% 0px -35% 0px" }
|
||||
)
|
||||
|
||||
allSectionIds.forEach((id) => {
|
||||
const element = document.getElementById(id)
|
||||
if (element) observer.observe(element)
|
||||
})
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [sections, allSectionIds])
|
||||
|
||||
const convertInlineNodes = (nodes: ChildNode[]): string => {
|
||||
return nodes.map((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return node.textContent || ""
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
return ""
|
||||
}
|
||||
const element = node as HTMLElement
|
||||
const tagName = element.tagName.toLowerCase()
|
||||
if (tagName === "code") {
|
||||
return `\`${element.textContent?.trim() || ""}\``
|
||||
}
|
||||
if (tagName === "strong" || tagName === "b") {
|
||||
return `**${convertInlineNodes(Array.from(element.childNodes))}**`
|
||||
}
|
||||
if (tagName === "em" || tagName === "i") {
|
||||
return `*${convertInlineNodes(Array.from(element.childNodes))}*`
|
||||
}
|
||||
if (tagName === "a") {
|
||||
const text = convertInlineNodes(Array.from(element.childNodes)) || element.textContent || ""
|
||||
const href = element.getAttribute("href") || ""
|
||||
return href ? `[${text}](${href})` : text
|
||||
}
|
||||
if (tagName === "br") {
|
||||
return "\n"
|
||||
}
|
||||
return convertInlineNodes(Array.from(element.childNodes))
|
||||
}).join("")
|
||||
}
|
||||
|
||||
const convertList = (
|
||||
list: HTMLOListElement | HTMLUListElement,
|
||||
indentLevel: number
|
||||
): string => {
|
||||
const isOrdered = list.tagName.toLowerCase() === "ol"
|
||||
const items = Array.from(list.children).filter((child) => child.tagName.toLowerCase() === "li")
|
||||
return items.map((item, index) => {
|
||||
const prefix = isOrdered ? `${index + 1}. ` : "- "
|
||||
const indent = " ".repeat(indentLevel)
|
||||
const parts: string[] = []
|
||||
const itemElement = item as HTMLElement
|
||||
const childNodes = Array.from(itemElement.childNodes)
|
||||
const nestedLists: HTMLElement[] = []
|
||||
|
||||
childNodes.forEach((child) => {
|
||||
if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
const childElement = child as HTMLElement
|
||||
if (childElement.tagName.toLowerCase() === "ul" || childElement.tagName.toLowerCase() === "ol") {
|
||||
nestedLists.push(childElement)
|
||||
return
|
||||
}
|
||||
}
|
||||
parts.push(convertInlineNodes([child]).trim())
|
||||
})
|
||||
|
||||
const baseLine = `${indent}${prefix}${parts.join("").trim()}`
|
||||
const nestedMarkdown = nestedLists.map((nested) => convertList(nested as HTMLOListElement | HTMLUListElement, indentLevel + 1))
|
||||
return [baseLine, ...nestedMarkdown].filter(Boolean).join("\n")
|
||||
}).join("\n")
|
||||
}
|
||||
|
||||
const convertTable = (table: HTMLTableElement): string => {
|
||||
const rows = Array.from(table.querySelectorAll("tr")).map((row) =>
|
||||
Array.from(row.querySelectorAll("th,td")).map((cell) => (cell.textContent || "").trim())
|
||||
)
|
||||
if (!rows.length) return ""
|
||||
const header = rows[0]
|
||||
const body = rows.slice(1)
|
||||
const headerLine = `| ${header.join(" | ")} |`
|
||||
const separatorLine = `| ${header.map(() => "---").join(" | ")} |`
|
||||
const bodyLines = body.map((row) => `| ${row.join(" | ")} |`)
|
||||
return [headerLine, separatorLine, ...bodyLines].join("\n")
|
||||
}
|
||||
|
||||
const convertBlockNode = (node: HTMLElement, indentLevel: number): string => {
|
||||
const tagName = node.tagName.toLowerCase()
|
||||
if (tagName === "h3") return `### ${node.textContent?.trim() || ""}`
|
||||
if (tagName === "h4") return `#### ${node.textContent?.trim() || ""}`
|
||||
if (tagName === "p") return convertInlineNodes(Array.from(node.childNodes)).trim()
|
||||
if (tagName === "ul" || tagName === "ol") return convertList(node as HTMLOListElement | HTMLUListElement, indentLevel)
|
||||
if (tagName === "pre") {
|
||||
const code = node.textContent || ""
|
||||
return `\`\`\`\n${code.trim()}\n\`\`\``
|
||||
}
|
||||
if (tagName === "table") return convertTable(node as HTMLTableElement)
|
||||
if (tagName === "div") {
|
||||
const hasTable = node.querySelector("table")
|
||||
const hasList = node.querySelector("ul,ol")
|
||||
const hasPre = node.querySelector("pre")
|
||||
if (hasTable || hasList || hasPre) {
|
||||
return Array.from(node.children)
|
||||
.map((child) => convertBlockNode(child as HTMLElement, indentLevel))
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
}
|
||||
const text = convertInlineNodes(Array.from(node.childNodes)).trim()
|
||||
return text
|
||||
}
|
||||
return convertInlineNodes(Array.from(node.childNodes)).trim()
|
||||
}
|
||||
|
||||
const buildMarkdown = (): string => {
|
||||
const contentRoot = contentRef.current
|
||||
if (!contentRoot) return ""
|
||||
const lines: string[] = []
|
||||
lines.push(`# ${title}`)
|
||||
lines.push("")
|
||||
lines.push(`最后更新:${lastUpdated}`)
|
||||
if (description) {
|
||||
const descriptionElement = contentRoot.querySelector("[data-docs-description]")
|
||||
if (descriptionElement) {
|
||||
const descriptionText = convertInlineNodes(Array.from(descriptionElement.childNodes)).trim()
|
||||
if (descriptionText) {
|
||||
lines.push("")
|
||||
lines.push(descriptionText)
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push("")
|
||||
|
||||
const sectionNodes = Array.from(contentRoot.querySelectorAll("[data-docs-section]")) as HTMLElement[]
|
||||
sectionNodes.forEach((section) => {
|
||||
const heading = section.querySelector("h2")
|
||||
if (heading?.textContent) {
|
||||
lines.push(`## ${heading.textContent.trim()}`)
|
||||
lines.push("")
|
||||
}
|
||||
const prose = section.querySelector("[data-docs-content]")
|
||||
if (prose) {
|
||||
const blocks = Array.from(prose.children)
|
||||
.map((child) => convertBlockNode(child as HTMLElement, 0))
|
||||
.filter(Boolean)
|
||||
if (blocks.length) {
|
||||
lines.push(blocks.join("\n\n"))
|
||||
lines.push("")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n"
|
||||
}
|
||||
|
||||
const handleCopyMarkdown = async () => {
|
||||
const markdown = buildMarkdown()
|
||||
if (!markdown) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(markdown)
|
||||
setHasCopied(true)
|
||||
setTimeout(() => setHasCopied(false), 2000)
|
||||
} catch {
|
||||
const textArea = document.createElement("textarea")
|
||||
textArea.value = markdown
|
||||
textArea.style.position = "fixed"
|
||||
textArea.style.opacity = "0"
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
document.execCommand("copy")
|
||||
document.body.removeChild(textArea)
|
||||
setHasCopied(true)
|
||||
setTimeout(() => setHasCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToSection = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
|
||||
e.preventDefault()
|
||||
const element = document.getElementById(id)
|
||||
if (element) {
|
||||
const offset = 80
|
||||
const bodyRect = document.body.getBoundingClientRect().top
|
||||
const elementRect = element.getBoundingClientRect().top
|
||||
const elementPosition = elementRect - bodyRect
|
||||
const offsetPosition = elementPosition - offset
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: "smooth"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background relative selection:bg-primary/20">
|
||||
<motion.div
|
||||
className="fixed top-0 left-0 right-0 h-1 bg-primary origin-left z-50"
|
||||
style={{ scaleX }}
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-primary/5 rounded-full blur-[120px] -translate-y-1/2" />
|
||||
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-blue-500/5 rounded-full blur-[120px] translate-y-1/2" />
|
||||
</div>
|
||||
|
||||
<div ref={contentRef} className="container relative z-10 max-w-7xl mx-auto px-4 md:px-6 py-8 lg:py-16">
|
||||
<header className="mb-8 md:mb-12">
|
||||
<Link href="/" className="inline-block mb-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="gap-2 pl-0 text-muted-foreground hover:text-foreground hover:bg-transparent -ml-2"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回首页
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<h1 className="text-2xl md:text-3xl lg:text-5xl font-bold tracking-tight text-foreground mb-6">
|
||||
{title}
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground mb-8">
|
||||
<span className="flex items-center gap-1.5 bg-muted/50 px-3 py-1 rounded-full border border-border/50">
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
{sections.length} 个内容
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 bg-muted/50 px-3 py-1 rounded-full border border-border/50">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
最后更新:{lastUpdated}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopyMarkdown}
|
||||
className="h-8 gap-2 text-xs"
|
||||
>
|
||||
{hasCopied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
{hasCopied ? "已复制" : "复制 Markdown"}
|
||||
</Button>
|
||||
</div>
|
||||
{description && (
|
||||
<div className="w-full p-4 md:p-6 rounded-2xl bg-muted/30 border border-border/50 backdrop-blur-sm overflow-hidden">
|
||||
<div data-docs-description className="text-sm md:text-base text-muted-foreground leading-relaxed m-0 break-all">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</header>
|
||||
|
||||
<div className="grid lg:grid-cols-12 gap-8 lg:gap-12 relative">
|
||||
<aside className="hidden lg:block lg:col-span-3">
|
||||
<div className="sticky top-24">
|
||||
<div className="space-y-2 max-h-[calc(100vh-8rem)] overflow-y-auto pr-4 scrollbar-thin">
|
||||
<div className="text-sm font-semibold text-foreground mb-4 pl-3">目录</div>
|
||||
<nav className="space-y-1">
|
||||
{sections.map((section) => (
|
||||
<div key={section.value} className="space-y-1">
|
||||
<a
|
||||
href={`#${ section.value }`}
|
||||
onClick={(e) => scrollToSection(e, section.value)}
|
||||
className={cn(
|
||||
"group flex items-center justify-between py-2 px-3 text-sm rounded-lg transition-all duration-200",
|
||||
activeSection === section.value
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{section.title}</span>
|
||||
{activeSection === section.value && (
|
||||
<motion.div
|
||||
layoutId="active-dot"
|
||||
className="w-1.5 h-1.5 rounded-full bg-primary"
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
{section.children && (
|
||||
<div className="pl-4 space-y-1">
|
||||
{section.children.map((child) => (
|
||||
<a
|
||||
key={child.value}
|
||||
href={`#${ child.value }`}
|
||||
onClick={(e) => scrollToSection(e, child.value)}
|
||||
className={cn(
|
||||
"block py-1.5 px-3 text-xs rounded-lg transition-all duration-200 truncate",
|
||||
activeSection === child.value
|
||||
? "text-primary font-medium bg-primary/5"
|
||||
: "text-muted-foreground/80 hover:text-foreground hover:bg-muted/30"
|
||||
)}
|
||||
>
|
||||
{child.title}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="lg:col-span-9 lg:col-start-4 space-y-8 md:space-y-16 pb-20">
|
||||
{sections.map((section, index) => (
|
||||
<motion.section
|
||||
key={section.value}
|
||||
id={section.value}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "100px" }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="scroll-mt-24 group"
|
||||
data-docs-section
|
||||
>
|
||||
<div className="sticky top-0 z-20 flex items-center gap-4 py-6 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 mb-2 -mx-4 px-4 md:-mx-0 md:px-0">
|
||||
<span className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 text-primary text-sm font-bold opacity-50 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
||||
{index + 1}
|
||||
</span>
|
||||
<h2 className="text-xl md:text-2xl font-semibold tracking-tight text-foreground group-hover:text-primary transition-colors duration-300">
|
||||
{section.title.replace(/^\d+\.\s*/, '')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-docs-content
|
||||
className={cn(
|
||||
"prose prose-sm md:prose-base prose-neutral dark:prose-invert max-w-none",
|
||||
"prose-p:text-muted-foreground prose-p:leading-7",
|
||||
"prose-li:text-muted-foreground",
|
||||
"prose-strong:text-foreground",
|
||||
"pl-4 md:pl-12 border-l border-border/30 group-hover:border-primary/20 transition-colors duration-500"
|
||||
)}>
|
||||
{section.content}
|
||||
</div>
|
||||
</motion.section>
|
||||
))}
|
||||
|
||||
<div className="pt-16 border-t border-border mt-20">
|
||||
<div className="bg-muted/30 rounded-2xl p-8 text-center">
|
||||
<p className="text-muted-foreground text-sm mb-4">
|
||||
对这些内容有疑问?
|
||||
</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<Link href="https://github.com/Rain-kl/OpenFlare/issues" target="_blank">
|
||||
<Button variant="outline" className="h-9 text-xs">
|
||||
联系支持
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div >
|
||||
</div >
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {type PolicySection} from "./types"
|
||||
|
||||
/**
|
||||
* ------------------------------------------------------------------
|
||||
* 隐私政策 (PRIVACY POLICY)
|
||||
* ------------------------------------------------------------------
|
||||
*/
|
||||
export const privacySections: PolicySection[] = [
|
||||
{
|
||||
value: "collection-details",
|
||||
title: "1. 详细信息收集说明",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p>我们仅遵循合法、正当、必要的原则,收集为您提供服务所必需的信息。我们的数据收集范围严格限制如下:</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<span className="font-semibold text-foreground">1.1 身份鉴权信息:</span>
|
||||
<p className="mt-1 text-muted-foreground">当您通过本地账号注册或绑定第三方 OIDC 认证源登录时,我们会收集您的用户名、关联的邮箱、加密后的密码哈希指纹及关联的第三方 OpenID 标识符。<strong>我们不强制要求绑定手机号、身份证件或任何真实社会信用实体信息。</strong></p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-foreground">1.2 服务与接口日志信息:</span>
|
||||
<p className="mt-1 text-muted-foreground">为保障系统运行安全及满足安全审计要求,我们会自动收集您的操作日志,包括 IP 地址、访问日期和时间、个人访问令牌 API 调用历史记录、User-Agent(浏览器/设备/请求工具类型)。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "storage-security",
|
||||
title: "2. 数据存储与安全保护",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p>我们深知数据安全的重要性,并采取业界领先的技术措施保护您的数据:</p>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2">
|
||||
<li><strong>存储安全:</strong>用户数据独立存储于专用的云数据库或本地容器化持久层中,仅供授权系统应用挂载读取。</li>
|
||||
<li><strong>加密技术:</strong>敏感的密码指纹和个人访问令牌哈希在数据库中均采用高强度算法加密存储。API 数据传输链路强制使用 SSL/TLS 进行安全加密。</li>
|
||||
<li><strong>访问控制:</strong>我们实行严格的最小权限原则(Least Privilege),平台数据不会对外共享,所有系统级内部运维操作均有审计日志可查。</li>
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "usage-rules",
|
||||
title: "3. 信息使用规范",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p>我们收集的信息将仅用于以下目的:</p>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-1">
|
||||
<li><strong>身份识别:</strong>用于确认您的注册身份,展示您的个人中心及设置页面数据。</li>
|
||||
<li><strong>业务功能:</strong>用于识别并处理您的个人访问令牌 API 鉴权指令。</li>
|
||||
<li><strong>安全风控:</strong>利用 IP 及行为日志进行接口反作弊、反暴力破解分析,保障后台服务稳定性。</li>
|
||||
</ul>
|
||||
<p><strong>禁止用途:</strong>我们承诺<strong>绝不</strong>将您的数据出售给第三方,亦不会向任何机构提供任何用户画像分析或广告推送服务。</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "sharing-disclosure",
|
||||
title: "4. 信息共享与对外披露",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p><strong>4.1 共享原则:</strong>除以下极端情况外,我们不会向任何第三方(包括且不限于关联公司、商业合作伙伴)共享您的个人信息:</p>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-1 text-muted-foreground">
|
||||
<li>事先获得您的明确授权或同意;</li>
|
||||
<li>根据适用的法律法规、法律程序的要求、强制性的行政或司法要求所必须的情况下进行提供。</li>
|
||||
</ul>
|
||||
<p><strong>4.2 转让与公开披露:</strong>我们不会将您的个人信息转让给任何公司、组织和个人,亦不进行任何公开商业披露。</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "user-rights",
|
||||
title: "5. 您的权利与数据管理",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p>依照《中华人民共和国个人信息保护法》,您对您的个人信息享有完整的控制权:</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<span className="font-semibold text-foreground">5.1 查阅与管理权:</span>
|
||||
<p className="mt-1 text-muted-foreground">您可以随时登录本平台,查阅您的基础个人信息,生成、轮换或撤销您的 AccessToken (API 密钥)。</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-foreground">5.2 删除与注销权:</span>
|
||||
<p className="mt-1 text-muted-foreground">若您决定停止使用本服务,您可以申请注销账户。注销后,我们将立即从活跃存储媒介中删除您的所有敏感信息,法律法规要求留存的安全日志除外。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "policy-update",
|
||||
title: "6. 政策更新与通知",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p>随着业务的发展或法律法规的变动,本《隐私政策》条款可能发生变更。</p>
|
||||
<p>当条款发生重大变更时,我们会以显著方式(如平台公告、站内弹窗等)予以通知。如果您继续使用本服务,即表示您同意接受修订后的政策约束。</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,92 @@
|
||||
import {type PolicySection} from "./types"
|
||||
|
||||
export const TERMS_LAST_UPDATED = "2026-06-07"
|
||||
|
||||
/**
|
||||
* ------------------------------------------------------------------
|
||||
* 服务条款 (TERMS OF SERVICE)
|
||||
* ------------------------------------------------------------------
|
||||
*/
|
||||
export const termsSections: PolicySection[] = [
|
||||
{
|
||||
value: "contract-establishment",
|
||||
title: "1. 缔约申明与服务综述",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p><strong>1.1 缔约主体:</strong>本《服务协议》(以下简称“本协议”)是您(以下称“用户”或“开发者”)与本通用开发脚手架平台(以下简称“本系统”或“平台”)运营维护方之间关于使用本系统所订立的契约。</p>
|
||||
<p><strong>1.2 审慎阅读:</strong>本系统作为一个通用的、面向二次开发的全栈软件底座,旨在为用户提供基础的注册、会话、OIDC 接入、API Key 令牌鉴权及后台管理服务。若您使用本系统,请务必仔细阅读本协议各条款。</p>
|
||||
<p><strong>1.3 协议构成:</strong>本协议内容包括协议正文及所有我们已经发布或将来可能发布的各类规则、声明、说明。所有规则为本协议不可分割的组成部分,与协议正文具有同等法律效力。</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "service-definition",
|
||||
title: "2. 服务定义与性质界定",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2">
|
||||
<li><strong>责任自担:</strong>关于用户对本系统进行二次开发并应用于其他生产环境产生的任何业务行为,由二开部署运营主体承担全部合规责任。</li>
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "account-specifications",
|
||||
title: "3. 账号注册与使用规范",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p><strong>3.1 账号体系:</strong>用户可通过本系统的前端注册表单自助创建账户,或通过系统管理员配置并启用的自定义第三方 OIDC 认证源进行登录关联。</p>
|
||||
<p><strong>3.2 密码及令牌安全责任:</strong></p>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2">
|
||||
<li><strong>密码安全:</strong>您应妥善保管您账户的登录密码。</li>
|
||||
<li><strong>API Token 安全:</strong>个人生成的 AccessToken (API Key) 代表您账户的完整调用权限。<strong>因您保管不善导致 Token 泄漏而造成的一切数据丢失或系统损失,均由您自行承担。</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "user-conduct",
|
||||
title: "4. 用户行为准则(负面清单)",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p>您在使用本系统时,必须严格遵守《中华人民共和国网络安全法》、《计算机信息网络国际联网安全保护管理办法》等法律法规。<strong>严禁利用本平台从事以下活动(“红线条款”):</strong></p>
|
||||
<div className="bg-red-500/5 border border-red-500/20 rounded-lg p-3 md:p-4 space-y-3">
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2 text-red-600 dark:text-red-400 font-medium">
|
||||
<li><strong>危害国家安全:</strong>反对宪法所确定的基本原则、危害国家安全、泄露国家秘密、颠覆国家政权、破坏国家统一的;</li>
|
||||
<li><strong>非法信息服务:</strong>黑客攻击工具、DDoS 攻击服务、服务器爆破等其他非法信息服务平台;</li>
|
||||
<li><strong>黄赌毒关联:</strong>制作、复制、发布、传播淫秽、色情、赌博、暴力、凶杀、恐怖或者教唆犯罪的;</li>
|
||||
<li><strong>侵犯知识产权:</strong>销售盗版软件、盗版影视资源、非法游戏外挂、私服、黑号、社工库数据等;</li>
|
||||
<li><strong>其他违法信息:</strong>涉及散布谣言、宣扬邪教/封建迷信、侮辱/诽谤他人、侵害他人合法权益的。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p><strong>处理规则:</strong>一旦发现您违反上述规定,平台运维主体有权不经通知<strong>立即永久封禁您的账号、拦截所有 AccessToken 请求,并依法向公安机关、网安部门移交相关线索。</strong></p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "liability-limitation",
|
||||
title: "5. 免责声明与不可抗力",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p><strong>5.1 基础免责:</strong>本开发底座按“现状”(As-Is)及“现有”(As-Available)状态提供。我们不保证服务一定能满足您的特定开发需求,对服务的及时性、安全性、准确性都不作额外担保。</p>
|
||||
<p><strong>5.2 技术服务中断:</strong>对于因以下不可抗力原因导致的服务中断、数据丢失或账号损坏,平台不承担赔偿责任:</p>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-1 text-muted-foreground">
|
||||
<li>自然灾害(台风、地震、海啸、洪水等);</li>
|
||||
<li>政府行为、网络安全法律法规调整或行政命令;</li>
|
||||
<li>电信部门线路技术故障、机房海底光缆损毁;</li>
|
||||
<li>黑客入侵、勒索病毒感染导致的数据损毁或宕机。</li>
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "governing-law",
|
||||
title: "6. 法律适用与争议解决",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p><strong>6.1 法律适用:</strong>本协议的订立、执行、解释及争议的解决均适用<strong>中华人民共和国法律</strong>(不包括港澳台地区冲突法)。</p>
|
||||
<p><strong>6.2 争议解决:</strong>若发生任何争议或纠纷,首先应友好协商解决;协商不成的,应提交至本系统部署或运营方所在地有管辖权的人民法院管辖。</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
import {type ReactNode} from "react"
|
||||
|
||||
export type PolicySection = {
|
||||
value: string
|
||||
title: string
|
||||
content: ReactNode
|
||||
children?: {
|
||||
value: string
|
||||
title: string
|
||||
}[]
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {ImageOff} from "lucide-react"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
import {getFileUrl, type ImageQuality} from "@/lib/services/upload/utils"
|
||||
|
||||
type FileImagePreviewProps = {
|
||||
fileId: string | number
|
||||
alt: string
|
||||
quality?: ImageQuality
|
||||
className?: string
|
||||
fallbackClassName?: string
|
||||
variant?: "compact" | "default"
|
||||
}
|
||||
|
||||
export function FileImagePreview({
|
||||
fileId,
|
||||
alt,
|
||||
quality = "low",
|
||||
className,
|
||||
fallbackClassName,
|
||||
variant = "default",
|
||||
}: FileImagePreviewProps) {
|
||||
const [failed, setFailed] = React.useState(false)
|
||||
const src = getFileUrl(fileId, quality)
|
||||
|
||||
React.useEffect(() => {
|
||||
setFailed(false)
|
||||
}, [fileId, quality])
|
||||
|
||||
if (!src || failed) {
|
||||
return (
|
||||
<div
|
||||
role="img"
|
||||
aria-label={`${alt} 加载失败`}
|
||||
className={cn(
|
||||
"flex size-full flex-col items-center justify-center gap-1 bg-muted/50 text-muted-foreground",
|
||||
fallbackClassName,
|
||||
)}
|
||||
>
|
||||
<ImageOff className={cn("shrink-0 opacity-70", variant === "compact" ? "size-3.5" : "size-5")} />
|
||||
{variant === "default" && (
|
||||
<span className="text-[10px] leading-none">加载失败</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className={className}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import {useState} from "react"
|
||||
import {toast} from "sonner"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Spinner} from "@/components/ui/spinner"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {Layers, ListRestart, LucideIcon, Trash2} from "lucide-react"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||
import {ScrollArea, ScrollBar} from "@/components/ui/scroll-area"
|
||||
|
||||
|
||||
interface ManagePageProps<T> {
|
||||
title: string
|
||||
icon?: LucideIcon
|
||||
data: T[]
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
onReload: () => void
|
||||
|
||||
/** 获取初始编辑数据 */
|
||||
getInitialEditData: (item: T) => Partial<T>
|
||||
onSave: (item: T, editData: Partial<T>) => Promise<void>
|
||||
onDelete?: (item: T) => Promise<void>
|
||||
|
||||
/** 渲染表格 (Config-Driven) */
|
||||
columns: {
|
||||
header: string
|
||||
cell: (item: T) => React.ReactNode
|
||||
width?: string
|
||||
align?: 'left' | 'center' | 'right'
|
||||
className?: string
|
||||
}[]
|
||||
|
||||
/** 渲染详情 */
|
||||
renderDetail: (props: {
|
||||
selected: T | null
|
||||
hovered: T | null
|
||||
editData: Partial<T>
|
||||
onEditDataChange: (field: keyof T, value: T[keyof T]) => void
|
||||
onSave: () => void
|
||||
saving: boolean
|
||||
}) => React.ReactNode
|
||||
|
||||
/** 空状态图标 */
|
||||
emptyIcon?: LucideIcon
|
||||
emptyDescription?: string
|
||||
loadingDescription?: string
|
||||
getId: (item: T) => string | number
|
||||
headerExtra?: React.ReactNode
|
||||
}
|
||||
|
||||
export function ManagePage<T>({
|
||||
title,
|
||||
icon: Icon,
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
onReload,
|
||||
getInitialEditData,
|
||||
onSave,
|
||||
onDelete,
|
||||
columns,
|
||||
renderDetail,
|
||||
emptyIcon = Layers,
|
||||
emptyDescription = "暂无数据",
|
||||
loadingDescription = "加载中",
|
||||
getId,
|
||||
headerExtra
|
||||
}: ManagePageProps<T>) {
|
||||
/** 悬停状态 */
|
||||
const [hoveredItem, setHoveredItem] = useState<T | null>(null)
|
||||
const [selectedItem, setSelectedItem] = useState<T | null>(null)
|
||||
const [editData, setEditData] = useState<Partial<T>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [deletingItem, setDeletingItem] = useState<T | null>(null)
|
||||
|
||||
/** 悬停处理 */
|
||||
const handleHover = (item: T | null) => {
|
||||
setHoveredItem(item)
|
||||
}
|
||||
|
||||
/** 选择处理 */
|
||||
const handleSelect = (item: T) => {
|
||||
const itemId = getId(item)
|
||||
const selectedId = selectedItem ? getId(selectedItem) : null
|
||||
|
||||
if (itemId === selectedId) {
|
||||
setSelectedItem(null)
|
||||
setEditData({})
|
||||
} else {
|
||||
setSelectedItem(item)
|
||||
setEditData(getInitialEditData(item))
|
||||
}
|
||||
setHoveredItem(null)
|
||||
}
|
||||
|
||||
/** 编辑数据处理 */
|
||||
const handleEditDataChange = (field: keyof T, value: T[keyof T]) => {
|
||||
setEditData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}))
|
||||
}
|
||||
|
||||
/** 保存处理 */
|
||||
const handleSave = async () => {
|
||||
if (!selectedItem) return
|
||||
|
||||
try {
|
||||
setSaving(true)
|
||||
await onSave(selectedItem, editData)
|
||||
toast.success('保存成功')
|
||||
} catch (error) {
|
||||
toast.error('保存失败', {
|
||||
description: error instanceof Error ? error.message : '未知错误'
|
||||
})
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除处理 */
|
||||
const handleDeleteClick = (item: T) => {
|
||||
setDeletingItem(item)
|
||||
}
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deletingItem || !onDelete) return
|
||||
try {
|
||||
await onDelete(deletingItem)
|
||||
toast.success('删除成功')
|
||||
setDeletingItem(null)
|
||||
// 如果删除的是当前选中的项,清除选中状态
|
||||
if (selectedItem && getId(selectedItem) === getId(deletingItem)) {
|
||||
setSelectedItem(null)
|
||||
setEditData({})
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('删除失败', {
|
||||
description: error instanceof Error ? error.message : '未知错误'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** 渲染内容 */
|
||||
const renderContent = () => {
|
||||
if (loading && (!data || data.length === 0)) {
|
||||
return (
|
||||
<LoadingStateWithBorder
|
||||
icon={ListRestart}
|
||||
description={loadingDescription}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-8 border border-dashed rounded-lg">
|
||||
<ErrorInline
|
||||
error={error}
|
||||
onRetry={onReload}
|
||||
className="justify-center"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<EmptyStateWithBorder
|
||||
icon={emptyIcon}
|
||||
description={emptyDescription}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ManageTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
selected={selectedItem}
|
||||
hovered={hoveredItem}
|
||||
onSelect={handleSelect}
|
||||
onHover={handleHover}
|
||||
onDelete={onDelete ? handleDeleteClick : undefined}
|
||||
getId={getId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="py-6">
|
||||
<div className="flex pb-2 mb-6 items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="size-5 text-primary" />}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
</div>
|
||||
</div>
|
||||
{headerExtra}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<div className="font-semibold">配置列表</div>
|
||||
</div>
|
||||
{renderContent()}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{renderDetail({
|
||||
selected: selectedItem,
|
||||
hovered: hoveredItem,
|
||||
editData,
|
||||
onEditDataChange: handleEditDataChange,
|
||||
onSave: handleSave,
|
||||
saving
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={!!deletingItem} onOpenChange={(open) => !open && setDeletingItem(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
此操作无法撤销。这将永久删除该配置。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmDelete} className="bg-red-600 hover:bg-red-700">
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 详情面板 props */
|
||||
interface ManageDetailPanelProps {
|
||||
title?: string
|
||||
isEmpty: boolean
|
||||
emptyDescription?: string
|
||||
onSave?: () => void
|
||||
saving?: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/** 详情面板 */
|
||||
export function ManageDetailPanel({
|
||||
title = "配置信息",
|
||||
isEmpty,
|
||||
emptyDescription = "请选择配置查看详情",
|
||||
onSave,
|
||||
saving = false,
|
||||
children
|
||||
}: ManageDetailPanelProps) {
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="font-semibold mb-4">{title}</div>
|
||||
<EmptyStateWithBorder
|
||||
icon={Layers}
|
||||
description={emptyDescription}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sticky top-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="font-semibold">{title}</div>
|
||||
{onSave && (
|
||||
<Button
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
size="sm"
|
||||
className="px-3 h-7 text-xs"
|
||||
>
|
||||
{saving ? (<><Spinner /> 更新中</>) : '更新'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 表格面板 */
|
||||
export function ManageTable<T>({
|
||||
data,
|
||||
columns,
|
||||
selected,
|
||||
hovered,
|
||||
onSelect,
|
||||
onHover,
|
||||
onDelete,
|
||||
getId
|
||||
}: {
|
||||
data: T[]
|
||||
columns: {
|
||||
header: string
|
||||
cell: (item: T) => React.ReactNode
|
||||
width?: string
|
||||
align?: 'left' | 'center' | 'right'
|
||||
className?: string
|
||||
}[]
|
||||
selected: T | null
|
||||
hovered: T | null
|
||||
onSelect: (item: T) => void
|
||||
onHover: (item: T | null) => void
|
||||
onDelete?: (item: T) => void
|
||||
getId: (item: T) => string | number
|
||||
}) {
|
||||
return (
|
||||
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
|
||||
<ScrollArea className="w-full">
|
||||
<div className="relative w-full">
|
||||
<table className="w-full caption-bottom text-sm">
|
||||
<TableHeader>
|
||||
<TableRow className="border-b border-dashed">
|
||||
{columns.map((col, index) => (
|
||||
<TableHead
|
||||
key={index}
|
||||
className={`whitespace-nowrap ${ col.width ? `w-[${ col.width }]` : '' } ${ col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : '' } ${ col.className || '' }`}
|
||||
>
|
||||
{col.header}
|
||||
</TableHead>
|
||||
))}
|
||||
{onDelete && <TableHead className="whitespace-nowrap text-center w-[120px]">操作</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="animate-in fade-in duration-200">
|
||||
{data.map((item) => {
|
||||
const id = getId(item)
|
||||
const isSelected = selected && getId(selected) === id
|
||||
const isHovered = hovered && getId(hovered) === id
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={id}
|
||||
className={`border-b border-dashed cursor-pointer transition-colors ${ isSelected
|
||||
? 'bg-primary/5 hover:bg-primary/10'
|
||||
: isHovered
|
||||
? 'bg-gray-100 hover:bg-muted/50'
|
||||
: 'hover:bg-gray-100'
|
||||
}`}
|
||||
onMouseEnter={() => onHover(item)}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
{columns.map((col, index) => (
|
||||
<TableCell
|
||||
key={index}
|
||||
className={`text-xs py-1 ${ col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : '' } ${ col.className || '' }`}
|
||||
>
|
||||
{col.cell(item)}
|
||||
</TableCell>
|
||||
))}
|
||||
{onDelete && (
|
||||
<TableCell className="text-xs py-1 text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-red-600 hover:bg-red-50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete(item)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</table>
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useState} from "react"
|
||||
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,} from "@/components/ui/dialog"
|
||||
import {InputOTP, InputOTPGroup, InputOTPSlot,} from "@/components/ui/input-otp"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Spinner} from "@/components/ui/spinner"
|
||||
|
||||
interface PasswordDialogProps {
|
||||
isOpen: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: (password: string) => void
|
||||
loading?: boolean
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 密码输入对话框
|
||||
*
|
||||
* 用于敏感操作前的二次确认
|
||||
*/
|
||||
export function PasswordDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
loading = false,
|
||||
title = "请输入密码",
|
||||
description = "为了您的账户安全,请输入6位认证密码进行验证"
|
||||
}: PasswordDialogProps) {
|
||||
const [password, setPassword] = useState("")
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (password.length === 6) {
|
||||
onConfirm(password)
|
||||
}
|
||||
}
|
||||
|
||||
/* 当对话框关闭时重置密码*/
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setPassword("")
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-xs sm:w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-2 flex justify-center">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
disabled={loading}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && password.length === 6 && !loading) {
|
||||
handleConfirm()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<InputOTPGroup className="gap-2">
|
||||
<InputOTPSlot index={0} className="w-10 h-10 text-base" />
|
||||
<InputOTPSlot index={1} className="w-10 h-10 text-base" />
|
||||
<InputOTPSlot index={2} className="w-10 h-10 text-base" />
|
||||
<InputOTPSlot index={3} className="w-10 h-10 text-base" />
|
||||
<InputOTPSlot index={4} className="w-10 h-10 text-base" />
|
||||
<InputOTPSlot index={5} className="w-10 h-10 text-base" />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-primary hover:bg-primary/90 h-8 text-xs"
|
||||
onClick={handleConfirm}
|
||||
disabled={password.length !== 6 || loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Spinner className="mr-2 h-4 w-4" />
|
||||
验证中...
|
||||
</>
|
||||
) : (
|
||||
"确认"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {AnimatePresence, motion} from "motion/react"
|
||||
import {useUser} from "@/contexts/user-context"
|
||||
import {Card, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {ArrowRight, ExternalLink, FileText, HelpCircle, Layers, Shield, ShieldCheck, Terminal, User} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
|
||||
export function HomeMain() {
|
||||
const { user } = useUser()
|
||||
const [isBannerVisible, setIsBannerVisible] = React.useState(true)
|
||||
|
||||
const quickLinks = [
|
||||
{
|
||||
title: "个人资料",
|
||||
description: "管理您的个人账户信息及个性化配置",
|
||||
icon: User,
|
||||
url: "/settings/profile",
|
||||
color: "text-blue-500",
|
||||
bgColor: "bg-blue-500/10",
|
||||
borderColor: "hover:border-blue-500/30",
|
||||
},
|
||||
{
|
||||
title: "开发接口文档",
|
||||
description: "查看开放平台的 RESTful 接口规格说明",
|
||||
icon: FileText,
|
||||
url: "/docs/api",
|
||||
color: "text-emerald-500",
|
||||
bgColor: "bg-emerald-500/10",
|
||||
borderColor: "hover:border-emerald-500/30",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: "使用文档",
|
||||
description: "学习如何集成 API 及日常操作帮助指南",
|
||||
icon: HelpCircle,
|
||||
url: "/docs/how-to-use",
|
||||
color: "text-purple-500",
|
||||
bgColor: "bg-purple-500/10",
|
||||
borderColor: "hover:border-purple-500/30",
|
||||
external: true,
|
||||
},
|
||||
]
|
||||
|
||||
const adminLinks = [
|
||||
{
|
||||
title: "系统设置",
|
||||
description: "管理登录注册开关与认证源安全策略",
|
||||
icon: Shield,
|
||||
url: "/admin/settings",
|
||||
color: "text-amber-500",
|
||||
bgColor: "bg-amber-500/10",
|
||||
borderColor: "hover:border-amber-500/30",
|
||||
},
|
||||
{
|
||||
title: "全局系统配置",
|
||||
description: "动态管理平台运行时核心配置参数",
|
||||
icon: ShieldCheck,
|
||||
url: "/admin/system",
|
||||
color: "text-indigo-500",
|
||||
bgColor: "bg-indigo-500/10",
|
||||
borderColor: "hover:border-indigo-500/30",
|
||||
},
|
||||
{
|
||||
title: "用户权限管理",
|
||||
description: "集中查询并管理系统注册用户的启用状态",
|
||||
icon: User,
|
||||
url: "/admin/users",
|
||||
color: "text-rose-500",
|
||||
bgColor: "bg-rose-500/10",
|
||||
borderColor: "hover:border-rose-500/30",
|
||||
},
|
||||
{
|
||||
title: "后台任务调度",
|
||||
description: "分发与观测系统异步定时任务的执行情况",
|
||||
icon: Layers,
|
||||
url: "/admin/tasks",
|
||||
color: "text-teal-500",
|
||||
bgColor: "bg-teal-500/10",
|
||||
borderColor: "hover:border-teal-500/30",
|
||||
},
|
||||
{
|
||||
title: "系统日志",
|
||||
description: "查看异步任务执行日志与系统运行状态详情",
|
||||
icon: Terminal,
|
||||
url: "/admin/logs",
|
||||
color: "text-cyan-500",
|
||||
bgColor: "bg-cyan-500/10",
|
||||
borderColor: "hover:border-cyan-500/30",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="py-6 space-y-8 max-w-6xl mx-auto">
|
||||
{/* 积分收益提示 banner */}
|
||||
<AnimatePresence>
|
||||
{isBannerVisible && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="bg-gradient-to-r from-indigo-500/10 via-purple-500/10 to-pink-500/10 border border-dashed border-indigo-500/20 rounded-xl p-6 relative">
|
||||
<div className="max-w-xl space-y-3">
|
||||
<h3 className="text-lg font-bold text-indigo-600 dark:text-indigo-400">Modern Platform</h3>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
通用的、现代化的后台管理系统脚手架, 开箱即用、完整基建、极易扩展,助您快速构建企业级应用。
|
||||
</p>
|
||||
<div className="flex gap-2.5">
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-xs font-semibold px-4 shadow-sm h-7"
|
||||
asChild
|
||||
>
|
||||
<Link href="/">开始使用</Link>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-xs font-medium hover:bg-black/5 dark:hover:bg-white/5 h-7"
|
||||
onClick={() => setIsBannerVisible(false)}
|
||||
>
|
||||
隐藏提示
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 快捷导航 */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold tracking-tight">常用功能导航</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{quickLinks.map((link, idx) => (
|
||||
<motion.div
|
||||
key={link.title}
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: idx * 0.05 }}
|
||||
whileHover={{ y: -4 }}
|
||||
className="h-full"
|
||||
>
|
||||
<Card className={`h-full border border-dashed flex flex-col justify-between hover:bg-muted/40 transition-all ${link.borderColor} duration-300 shadow-none`}>
|
||||
<CardHeader className="p-5">
|
||||
<div className={`size-10 rounded-lg flex items-center justify-center ${link.bgColor} ${link.color} mb-3`}>
|
||||
<link.icon className="size-5" />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-semibold mb-1 flex items-center gap-1">
|
||||
{link.title}
|
||||
{link.external && <ExternalLink className="size-3 text-muted-foreground" />}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground leading-normal font-normal">
|
||||
{link.description}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<div className="px-5 pb-5">
|
||||
{link.external ? (
|
||||
<Button variant="link" className="p-0 h-auto text-xs text-indigo-500 font-medium" asChild>
|
||||
<Link href={link.url} target="_blank" rel="noopener noreferrer">
|
||||
立即跳转 <ArrowRight className="size-3 ml-1" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="link" className="p-0 h-auto text-xs text-indigo-500 font-medium" asChild>
|
||||
<Link href={link.url}>
|
||||
立即进入 <ArrowRight className="size-3 ml-1" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 管理面板 (仅管理员可见) */}
|
||||
{user?.is_admin && (
|
||||
<div className="space-y-4 pt-2">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-rose-500 flex items-center gap-1.5">
|
||||
<Shield className="size-5" />
|
||||
后台管理控制台
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{adminLinks.map((link, idx) => (
|
||||
<motion.div
|
||||
key={link.title}
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: (idx + 4) * 0.05 }}
|
||||
whileHover={{ y: -4 }}
|
||||
className="h-full"
|
||||
>
|
||||
<Card className={`h-full border border-dashed flex flex-col justify-between hover:bg-muted/40 transition-all ${link.borderColor} duration-300 shadow-none`}>
|
||||
<CardHeader className="p-5">
|
||||
<div className={`size-10 rounded-lg flex items-center justify-center ${link.bgColor} ${link.color} mb-3`}>
|
||||
<link.icon className="size-5" />
|
||||
</div>
|
||||
<CardTitle className="text-sm font-semibold mb-1">
|
||||
{link.title}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground leading-normal font-normal">
|
||||
{link.description}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<div className="px-5 pb-5">
|
||||
<Button variant="link" className="p-0 h-auto text-xs text-indigo-500 font-medium" asChild>
|
||||
<Link href={link.url}>
|
||||
立即进入 <ArrowRight className="size-3 ml-1" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {motion} from "motion/react"
|
||||
import {AlertTriangle, Check, Copy, Info, Key, Loader2, Plus, RefreshCw, Shield, Trash2} from "lucide-react"
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb"
|
||||
import type {CreateTokenResponse} from "@/lib/services/user"
|
||||
import {UserService} from "@/lib/services/user"
|
||||
import {useAuth} from "@/components/providers/auth-provider"
|
||||
import {toast} from "sonner"
|
||||
|
||||
export function AccessTokenMain() {
|
||||
const { user } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
const [createDialogOpen, setCreateDialogOpen] = React.useState(false)
|
||||
const [viewDialogOpen, setViewDialogOpen] = React.useState(false)
|
||||
const [tokenName, setTokenName] = React.useState("")
|
||||
const [tokenIsAdmin, setTokenIsAdmin] = React.useState(false)
|
||||
const [copiedId, setCopiedId] = React.useState<number | null>(null)
|
||||
const [newCreatedToken, setNewCreatedToken] = React.useState<CreateTokenResponse | null>(null)
|
||||
|
||||
// 获取 Token 列表
|
||||
const accessTokensQuery = useQuery({
|
||||
queryKey: ["user", "access-tokens"],
|
||||
queryFn: () => UserService.getAccessTokens(),
|
||||
})
|
||||
|
||||
// 创建 Token
|
||||
const createTokenMutation = useMutation({
|
||||
mutationFn: ({ name, isAdmin }: { name: string; isAdmin: boolean }) => UserService.createAccessToken(name, isAdmin),
|
||||
onSuccess: (data) => {
|
||||
setNewCreatedToken(data)
|
||||
setTokenName("")
|
||||
setTokenIsAdmin(false)
|
||||
setCreateDialogOpen(false)
|
||||
setViewDialogOpen(true)
|
||||
void queryClient.invalidateQueries({ queryKey: ["user", "access-tokens"] })
|
||||
toast.success("访问令牌创建成功")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "创建访问令牌失败")
|
||||
},
|
||||
})
|
||||
|
||||
// 删除 Token
|
||||
const deleteTokenMutation = useMutation({
|
||||
mutationFn: (id: number) => UserService.deleteAccessToken(id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["user", "access-tokens"] })
|
||||
toast.success("访问令牌已撤销")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "删除访问令牌失败")
|
||||
},
|
||||
})
|
||||
|
||||
// 轮换 Token
|
||||
const rotateTokenMutation = useMutation({
|
||||
mutationFn: (id: number) => UserService.rotateAccessToken(id),
|
||||
onSuccess: (data) => {
|
||||
setNewCreatedToken(data)
|
||||
setViewDialogOpen(true)
|
||||
void queryClient.invalidateQueries({ queryKey: ["user", "access-tokens"] })
|
||||
toast.success("访问令牌轮换成功,旧密钥已失效")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "轮换访问令牌失败")
|
||||
},
|
||||
})
|
||||
|
||||
const handleCreateToken = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!tokenName.trim()) {
|
||||
toast.error("请输入令牌名称")
|
||||
return
|
||||
}
|
||||
createTokenMutation.mutate({ name: tokenName.trim(), isAdmin: tokenIsAdmin })
|
||||
}
|
||||
|
||||
const handleDeleteToken = (id: number, name: string) => {
|
||||
if (window.confirm(`确定要删除并撤销令牌「${name}」吗?删除后此令牌将立即失效且不可恢复。`)) {
|
||||
deleteTokenMutation.mutate(id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRotateToken = (id: number, name: string) => {
|
||||
if (window.confirm(`确定要轮换令牌「${name}」的密钥吗?轮换后系统将生成全新密钥,原令牌密钥将立即失效。`)) {
|
||||
rotateTokenMutation.mutate(id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyText = async (text: string, id: number) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopiedId(id)
|
||||
toast.success("复制成功")
|
||||
setTimeout(() => setCopiedId(null), 2000)
|
||||
} catch {
|
||||
toast.error("复制失败")
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return "未使用"
|
||||
return new Date(dateStr).toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||
className="py-6 space-y-6 max-w-4xl mx-auto px-4"
|
||||
>
|
||||
<div className="font-semibold">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/settings" className="text-base text-primary">设置</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="text-base font-semibold">访问令牌</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 border-b pb-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground/90 to-muted-foreground bg-clip-text text-transparent">个人访问令牌 (AccessToken)</h1>
|
||||
<p className="text-sm text-muted-foreground">管理您的 API 访问密钥,用于开发或第三方工具直接调用系统 API</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
variant={'secondary'}
|
||||
>
|
||||
<Plus className="mr-1.5 size-4" />
|
||||
生成新令牌
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 安全警告提示 */}
|
||||
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 p-4 flex gap-3 text-amber-600 text-xs leading-relaxed">
|
||||
<AlertTriangle className="size-4 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<span className="font-bold">安全提示:</span>
|
||||
<p className="text-muted-foreground">
|
||||
访问令牌具有您账户的完整接口调用权限。为了您的账户与资产安全,请切勿通过任何代码库提交、即时通讯工具或公共媒介泄露此令牌。推荐按需创建,不使用时及时撤销。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<CardTitle className="text-base font-semibold">活动令牌列表</CardTitle>
|
||||
<CardDescription className="text-xs">当前可用的所有访问令牌</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
{accessTokensQuery.isPending ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin text-indigo-500" />
|
||||
</div>
|
||||
) : (accessTokensQuery.data ?? []).length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{(accessTokensQuery.data ?? []).map((token) => (
|
||||
<div
|
||||
key={token.id}
|
||||
className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 transition-all duration-300 shadow-sm"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm text-foreground">{token.name}</span>
|
||||
{token.is_admin ? (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 border-rose-500/40 text-rose-500 bg-rose-500/5 font-semibold">
|
||||
<Shield className="size-2.5 mr-0.5" />
|
||||
管理员
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 border-border/50 text-muted-foreground bg-muted/10 font-semibold">
|
||||
用户令牌
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
<div className="font-mono bg-muted/30 px-2 py-0.5 rounded border border-border/50 w-fit select-all">
|
||||
{token.masked_token}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-0.5 pt-1">
|
||||
<span>创建于: {formatDate(token.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 sm:self-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-dashed text-muted-foreground hover:text-indigo-500 hover:bg-indigo-500/5 rounded-lg h-8 px-2.5"
|
||||
onClick={() => handleCopyText(token.masked_token, token.id)}
|
||||
>
|
||||
{copiedId === token.id ? (
|
||||
<Check className="size-3.5 mr-1 text-emerald-500" />
|
||||
) : (
|
||||
<Copy className="size-3.5 mr-1" />
|
||||
)}
|
||||
复制
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-dashed text-muted-foreground hover:text-indigo-500 hover:bg-indigo-500/5 rounded-lg h-8 px-2.5"
|
||||
onClick={() => handleRotateToken(token.id, token.name)}
|
||||
disabled={rotateTokenMutation.isPending}
|
||||
>
|
||||
<RefreshCw className={`size-3.5 mr-1 ${rotateTokenMutation.isPending && rotateTokenMutation.variables === token.id ? 'animate-spin' : ''}`} />
|
||||
轮换
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-dashed text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 hover:border-rose-500/20 rounded-lg h-8 px-2.5"
|
||||
onClick={() => handleDeleteToken(token.id, token.name)}
|
||||
disabled={deleteTokenMutation.isPending}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-1" />
|
||||
撤销
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border/50 px-4 py-10 text-center text-xs text-muted-foreground bg-muted/5 flex flex-col items-center justify-center gap-3">
|
||||
<Key className="size-8 text-muted-foreground/30" />
|
||||
<span>您当前暂无生成任何访问令牌</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-dashed"
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
生成第一个令牌
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 创建令牌 Dialog */}
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<form onSubmit={handleCreateToken}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-semibold">生成新令牌</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
请为新的访问令牌设置一个易于识别的名称,以便将来管理。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token-name" className="text-xs font-semibold">令牌名称</Label>
|
||||
<Input
|
||||
id="token-name"
|
||||
placeholder="例如:my-development-key"
|
||||
value={tokenName}
|
||||
onChange={(e) => setTokenName(e.target.value)}
|
||||
disabled={createTokenMutation.isPending}
|
||||
className="rounded-xl border border-dashed focus:border-indigo-500 focus:ring-0 focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
{user?.is_admin && (
|
||||
<div className="flex items-center justify-between rounded-xl border border-dashed p-3 bg-muted/5">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="token-admin" className="text-xs font-semibold flex items-center gap-1.5">
|
||||
<Shield className="size-3.5 text-rose-500" />
|
||||
管理员权限
|
||||
</Label>
|
||||
<p className="text-[11px] text-muted-foreground leading-normal">
|
||||
开启后此令牌可访问 /admin/** 管理端点,默认关闭
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="token-admin"
|
||||
checked={tokenIsAdmin}
|
||||
onCheckedChange={setTokenIsAdmin}
|
||||
disabled={createTokenMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setCreateDialogOpen(false)}
|
||||
disabled={createTokenMutation.isPending}
|
||||
className="rounded-xl text-xs h-9 border-dashed"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createTokenMutation.isPending}
|
||||
variant={'secondary'}
|
||||
>
|
||||
{createTokenMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
正在生成...
|
||||
</>
|
||||
) : (
|
||||
"生成令牌"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 明文 Token 显示 Dialog (仅显示一次) */}
|
||||
<Dialog open={viewDialogOpen} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setNewCreatedToken(null)
|
||||
setViewDialogOpen(false)
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-bold text-foreground flex items-center gap-1.5">
|
||||
<Check className="size-5 text-emerald-500 border border-emerald-500 rounded-full p-0.5" />
|
||||
令牌密钥已就绪
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{newCreatedToken && (
|
||||
<div className="space-y-4 py-2">
|
||||
{/* 明文 Token 文本框 */}
|
||||
<div className="flex items-center gap-2 rounded-xl bg-indigo-500/5 border border-dashed border-indigo-500/30 p-3">
|
||||
<span className="font-mono text-xs select-all break-all flex-1 text-indigo-600 font-semibold leading-relaxed">
|
||||
{newCreatedToken.token}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="size-8 rounded-lg shrink-0 border-dashed text-indigo-500 hover:bg-indigo-500/10 hover:border-indigo-500/30 transition-colors"
|
||||
onClick={() => handleCopyText(newCreatedToken.token, 9999)}
|
||||
>
|
||||
{copiedId === 9999 ? (
|
||||
<Check className="size-3.5 text-emerald-500" />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 强提示 */}
|
||||
<div className="rounded-xl border border-rose-500/20 bg-rose-500/5 p-4 flex gap-3 text-rose-600 text-xs leading-relaxed">
|
||||
<Info className="size-4 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<span className="font-bold">重要提示:</span>
|
||||
<p className="text-muted-foreground">
|
||||
这是您唯一一次能够查看此访问令牌明文密钥的机会。请立即将其复制并安全地保存。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNewCreatedToken(null)
|
||||
setViewDialogOpen(false)
|
||||
}}
|
||||
className="rounded-xl w-full"
|
||||
>
|
||||
我已经复制并妥善保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import {Check, Loader2, Monitor, Moon, Sun} from "lucide-react"
|
||||
import {useTheme} from "next-themes"
|
||||
import {toast} from "sonner"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {useCustomTheme} from "@/lib/theme"
|
||||
import {cn} from "@/lib/utils"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator
|
||||
} from "@/components/ui/breadcrumb"
|
||||
|
||||
function ThemeModeSection() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const modes = [
|
||||
{ id: "light", name: "明亮", icon: Sun },
|
||||
{ id: "dark", name: "黑暗", icon: Moon },
|
||||
{ id: "system", name: "自动", icon: Monitor }
|
||||
]
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="font-medium text-sm text-foreground">主题模式</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
选择应用程序的整体明暗主题
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{modes.map((mode) => {
|
||||
const Icon = mode.icon
|
||||
return (
|
||||
<Button
|
||||
key={mode.id}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
>
|
||||
{Icon && <Icon className="size-3 mr-1" />}
|
||||
{mode.name}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="font-medium text-sm text-foreground">主题模式</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
选择应用程序的整体明暗主题
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{modes.map((mode) => {
|
||||
const Icon = mode.icon
|
||||
const isActive = theme === mode.id
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={mode.id}
|
||||
variant={isActive ? "default" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => setTheme(mode.id)}
|
||||
className="flex-1 text-xs"
|
||||
>
|
||||
{Icon && <Icon className="size-3 mr-1" />}
|
||||
{mode.name}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InterfaceAppearanceSection() {
|
||||
const { themes, currentThemeId, setTheme: setCustomTheme, isLoading } = useCustomTheme()
|
||||
const { theme: mode } = useTheme()
|
||||
const [mounted, setMounted] = React.useState(false)
|
||||
const [switching, setSwitching] = React.useState<string | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const handleSetTheme = (themeId: string) => {
|
||||
if (switching === themeId) return
|
||||
setSwitching(themeId)
|
||||
try {
|
||||
setCustomTheme(themeId)
|
||||
const themeName = themes.find(t => t.id === themeId)?.name || themeId
|
||||
toast.success(`外观已切换为 ${ themeName }`)
|
||||
} catch {
|
||||
toast.error("切换失败")
|
||||
} finally {
|
||||
setSwitching(null)
|
||||
}
|
||||
}
|
||||
|
||||
const isDark = mounted && (mode === 'dark' || (mode === 'system' && typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="font-medium text-sm text-foreground">界面外观</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
选择界面的配色方案和视觉风格
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground/50" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 xs:grid-cols-3 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-6">
|
||||
{themes.map((theme) => {
|
||||
const isActive = currentThemeId === theme.id || (!currentThemeId && theme.id === "default.css")
|
||||
const isSwitching = switching === theme.id
|
||||
const colors = isDark ? theme.colors.dark : theme.colors.light
|
||||
|
||||
return (
|
||||
<div
|
||||
key={theme.id}
|
||||
onClick={() => handleSetTheme(theme.id)}
|
||||
className={cn(
|
||||
"group relative cursor-pointer rounded-xl border transition-all overflow-hidden",
|
||||
isActive
|
||||
? "border-primary ring-2 ring-primary/20"
|
||||
: "border-border hover:border-border/50 hover:shadow-md"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="aspect-[4/3] w-full relative"
|
||||
style={{ backgroundColor: colors.background || colors['background'] }}
|
||||
>
|
||||
<div className="flex h-full p-2 gap-2">
|
||||
<div
|
||||
className="w-1/4 h-full rounded-sm opacity-80"
|
||||
style={{ backgroundColor: colors.sidebar || colors['sidebar'] }}
|
||||
/>
|
||||
<div className="flex-1 space-y-1.5 pt-1">
|
||||
<div
|
||||
className="h-1.5 w-1/3 rounded-full opacity-90"
|
||||
style={{ backgroundColor: colors.primary || colors['primary'] }}
|
||||
/>
|
||||
<div
|
||||
className="h-8 w-full rounded-sm opacity-40"
|
||||
style={{ backgroundColor: colors.sidebar || colors['sidebar'] }}
|
||||
/>
|
||||
<div
|
||||
className="h-4 w-1/2 rounded-sm opacity-30"
|
||||
style={{ backgroundColor: colors.sidebar || colors['sidebar'] }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isActive && !isSwitching && (
|
||||
<div className="absolute top-2 right-2 z-10 bg-primary text-primary-foreground rounded-full p-0.5 shadow-sm">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-2 border-t border-border/50 bg-card">
|
||||
<div className={cn("text-[10px] font-medium text-center truncate transition-colors", isActive ? "text-primary" : "text-muted-foreground")}>
|
||||
{theme.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppearanceMain() {
|
||||
return (
|
||||
<div className="py-6 space-y-6">
|
||||
<div className="font-semibold">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/settings" className="text-base text-primary">设置</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="text-base font-semibold">外观设置</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
<ThemeModeSection />
|
||||
|
||||
<InterfaceAppearanceSection />
|
||||
|
||||
<div className="space-y-4 opacity-50 pointer-events-none grayscale">
|
||||
<h2 className="font-medium text-sm text-muted-foreground">即将推出</h2>
|
||||
<div className="bg-muted/30 rounded-xl p-6 h-32 flex items-center justify-center border border-border/50">
|
||||
<span className="text-sm text-muted-foreground">更多自定义选项正在开发中...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client"
|
||||
|
||||
import {useEffect, useState} from "react"
|
||||
import {toast} from "sonner"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import services from "@/lib/services"
|
||||
import type {AuthSource, AuthSourceRequest} from "@/lib/services/admin"
|
||||
|
||||
const emptyForm: AuthSourceRequest = {
|
||||
name: "",
|
||||
type: "oidc",
|
||||
display_name: "",
|
||||
is_active: false,
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
openid_discovery_url: "",
|
||||
scopes: "openid profile email",
|
||||
icon_url: "",
|
||||
}
|
||||
|
||||
export function AuthSourceModal({
|
||||
isOpen,
|
||||
source,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
source: AuthSource | null
|
||||
onClose: () => void
|
||||
onChanged: () => Promise<void>
|
||||
}) {
|
||||
const [form, setForm] = useState<AuthSourceRequest>(emptyForm)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (source) {
|
||||
setForm({
|
||||
name: source.name,
|
||||
type: source.type,
|
||||
display_name: source.display_name,
|
||||
is_active: source.is_active,
|
||||
client_id: source.client_id,
|
||||
client_secret: "",
|
||||
openid_discovery_url: source.openid_discovery_url,
|
||||
scopes: source.scopes || "openid profile email",
|
||||
icon_url: source.icon_url,
|
||||
})
|
||||
} else {
|
||||
setForm(emptyForm)
|
||||
}
|
||||
} else {
|
||||
setForm(emptyForm)
|
||||
setSaving(false)
|
||||
}
|
||||
}, [isOpen, source])
|
||||
|
||||
const saveSource = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
if (source) {
|
||||
await services.adminAuthSource.updateAuthSource(source.id, form)
|
||||
} else {
|
||||
await services.adminAuthSource.createAuthSource(form)
|
||||
}
|
||||
await onChanged()
|
||||
toast.success("认证源已保存")
|
||||
onClose()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存认证源失败")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{source ? "编辑认证源" : "新增认证源"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{source ? "修改系统自定义的 OIDC 认证源参数。" : "配置新的自定义 OIDC 认证源。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 pt-2">
|
||||
<div className="space-y-2">
|
||||
<Label>标识符 (英文名称)</Label>
|
||||
<Input
|
||||
value={form.name}
|
||||
disabled={!!source}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="例如: github"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>展示名称</Label>
|
||||
<Input
|
||||
value={form.display_name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, display_name: e.target.value }))}
|
||||
placeholder="例如: GitHub 登录"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Client ID</Label>
|
||||
<Input
|
||||
value={form.client_id}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, client_id: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Client Secret</Label>
|
||||
<Input
|
||||
value={form.client_secret}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, client_secret: e.target.value }))}
|
||||
placeholder={source ? "留空则保留原值" : ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>Discovery URL (OIDC 发行方 URL)</Label>
|
||||
<Input
|
||||
value={form.openid_discovery_url}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, openid_discovery_url: e.target.value }))}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Scopes</Label>
|
||||
<Input
|
||||
value={form.scopes}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, scopes: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>图标 URL (可选)</Label>
|
||||
<Input
|
||||
value={form.icon_url}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, icon_url: e.target.value }))}
|
||||
placeholder="https://... 或留空"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border border-dashed p-3 md:col-span-2 bg-muted/10">
|
||||
<div>
|
||||
<div className="font-medium text-sm">启用认证源</div>
|
||||
<div className="text-xs text-muted-foreground">启用后会立即显示在登录页和账号绑定中。</div>
|
||||
</div>
|
||||
<Switch checked={form.is_active} onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_active: checked }))} />
|
||||
</div>
|
||||
<div className="md:col-span-2 flex justify-end gap-2 pt-2 border-t mt-2">
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" onClick={saveSource} disabled={saving} variant="secondary">
|
||||
{saving ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client"
|
||||
|
||||
import {useMutation, useQuery} from "@tanstack/react-query"
|
||||
import {ExternalLink, RefreshCw, Sparkles} from "lucide-react"
|
||||
import {toast} from "sonner"
|
||||
|
||||
import services from "@/lib/services"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Spinner} from "@/components/ui/spinner"
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 border-b border-dashed py-2 last:border-b-0">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-right text-xs font-medium text-foreground break-all">{value || "-"}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function InfoTab() {
|
||||
const updateQuery = useQuery({
|
||||
queryKey: ["admin", "update"],
|
||||
queryFn: () => services.adminStatus.getUpdateStatus(),
|
||||
refetchInterval: 30 * 60 * 1000,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const applyUpdateMutation = useMutation({
|
||||
mutationFn: () => services.adminStatus.applyUpdate(),
|
||||
onSuccess: () => {
|
||||
toast.success("升级包已校验完成,服务正在重启")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "应用升级失败")
|
||||
},
|
||||
})
|
||||
|
||||
const update = updateQuery.data
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-lg bg-muted p-1.5 text-muted-foreground">
|
||||
<Sparkles className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">应用更新</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
检查上游 GitHub Actions Release 并升级当前服务
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{update?.update_available ? (
|
||||
<Badge>发现新版本</Badge>
|
||||
) : update ? (
|
||||
<Badge variant="secondary">已是最新</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 pt-4">
|
||||
{updateQuery.isLoading ? (
|
||||
<div className="flex min-h-32 items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : updateQuery.isError ? (
|
||||
<div className="flex min-h-32 flex-col items-center justify-center gap-3 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{updateQuery.error.message || "无法获取上游版本信息"}
|
||||
</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => updateQuery.refetch()}>
|
||||
<RefreshCw data-icon="inline-start" />
|
||||
重新检查
|
||||
</Button>
|
||||
</div>
|
||||
) : update ? (
|
||||
<>
|
||||
<div>
|
||||
<InfoRow label="当前版本" value={update.current_version} />
|
||||
<InfoRow label="最新版本" value={update.latest_version} />
|
||||
{update.build_time && <InfoRow label="构建时间" value={update.build_time} />}
|
||||
<InfoRow label="运行平台" value={update.platform} />
|
||||
<InfoRow label="上游仓库" value={update.upstream_repository} />
|
||||
<InfoRow label="Release 资产" value={update.asset_name} />
|
||||
</div>
|
||||
|
||||
{update.release_notes && (
|
||||
<div className="flex flex-col gap-2 rounded-md border border-dashed p-3">
|
||||
<p className="text-xs font-medium">更新说明</p>
|
||||
<p className="max-h-40 overflow-y-auto whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground">
|
||||
{update.release_notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => updateQuery.refetch()} disabled={updateQuery.isFetching}>
|
||||
{updateQuery.isFetching ? <Spinner data-icon="inline-start" /> : <RefreshCw data-icon="inline-start" />}
|
||||
检查更新
|
||||
</Button>
|
||||
{update.release_url && (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a href={update.release_url} target="_blank" rel="noreferrer">
|
||||
<ExternalLink data-icon="inline-start" />
|
||||
查看 Release
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button type="button" size="sm" disabled={!update.can_upgrade || applyUpdateMutation.isPending}>
|
||||
{applyUpdateMutation.isPending && <Spinner data-icon="inline-start" />}
|
||||
立即升级
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>升级到 {update.latest_version}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
服务将下载并校验 {update.asset_name},随后替换当前二进制并重启。请确保安装目录可写,且服务允许原地重启。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => applyUpdateMutation.mutate()}>
|
||||
确认升级
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
{!update.can_upgrade && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{update.current_version === "dev"
|
||||
? "开发构建没有可比较的 Release 版本,不能执行自动升级。"
|
||||
: update.update_available
|
||||
? "当前平台暂不支持自动替换二进制,请从 Release 页面手动升级。"
|
||||
: "当前版本无需升级。"}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import {Bell} from "lucide-react"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {useNotificationSettings} from "@/contexts/notification-settings-context"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator
|
||||
} from "@/components/ui/breadcrumb"
|
||||
|
||||
export function NotificationsMain() {
|
||||
const { showBell, setShowBell } = useNotificationSettings()
|
||||
|
||||
return (
|
||||
<div className="py-6 space-y-6">
|
||||
<div className="font-semibold">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/settings" className="text-base text-primary">设置</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="text-base font-semibold">通知设置</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="font-medium text-sm text-foreground">通知显示</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
设置通知相关的显示选项
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bell className="size-5 text-primary" />
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="show-bell" className="text-sm font-medium cursor-pointer">
|
||||
显示通知铃铛
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
在顶部导航栏中显示通知铃铛图标
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="show-bell"
|
||||
checked={showBell}
|
||||
onCheckedChange={setShowBell}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client"
|
||||
|
||||
import {useMemo} from "react"
|
||||
import {useMutation, useQuery, useQueryClient, type UseQueryResult} from "@tanstack/react-query"
|
||||
import {KeyRound, ShieldAlert, X} from "lucide-react"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
|
||||
import services from "@/lib/services"
|
||||
import type {SystemConfig} from "@/lib/services/admin"
|
||||
import {TemplatesManager} from "./templates"
|
||||
import {toast} from "sonner"
|
||||
|
||||
interface OperationTabProps {
|
||||
configs: Record<string, SystemConfig>
|
||||
systemConfigsQuery: UseQueryResult<SystemConfig[], Error>
|
||||
}
|
||||
|
||||
export function OperationTab({ configs, systemConfigsQuery }: OperationTabProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const uploadTypesQuery = useQuery({
|
||||
queryKey: ["admin", "upload-types"],
|
||||
queryFn: () => services.adminSystemConfig.listUploadTypes(),
|
||||
})
|
||||
|
||||
const updateWhitelistMutation = useMutation({
|
||||
mutationFn: async (newValue: string) => {
|
||||
const config = configs["file_access_whitelist"]
|
||||
if (!config) {
|
||||
throw new Error("缺少配置项: file_access_whitelist")
|
||||
}
|
||||
await services.adminSystemConfig.updateSystemConfig("file_access_whitelist", {
|
||||
value: newValue,
|
||||
description: config.description,
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
|
||||
await queryClient.invalidateQueries({ queryKey: ["public-config"] })
|
||||
toast.success("文件访问白名单已更新")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "更新白名单失败")
|
||||
},
|
||||
})
|
||||
|
||||
const whitelistConfig = configs["file_access_whitelist"]
|
||||
const currentWhitelist = useMemo<string[]>(() => {
|
||||
if (!whitelistConfig?.value) return ["avatar"]
|
||||
try {
|
||||
const parsed = JSON.parse(whitelistConfig.value)
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
} catch {
|
||||
// 降级支持逗号分隔解析
|
||||
return whitelistConfig.value.split(",").map(s => s.trim()).filter(Boolean)
|
||||
}
|
||||
return ["avatar"]
|
||||
}, [whitelistConfig?.value])
|
||||
|
||||
const handleAddType = (type: string) => {
|
||||
if (!type || currentWhitelist.includes(type)) return
|
||||
const newWhitelist = [...currentWhitelist, type]
|
||||
updateWhitelistMutation.mutate(JSON.stringify(newWhitelist))
|
||||
}
|
||||
|
||||
const handleRemoveType = (typeToRemove: string) => {
|
||||
const newWhitelist = currentWhitelist.filter(t => t !== typeToRemove)
|
||||
updateWhitelistMutation.mutate(JSON.stringify(newWhitelist))
|
||||
}
|
||||
|
||||
const availableTypes = useMemo(() => {
|
||||
const types = uploadTypesQuery.data ?? []
|
||||
return types.map(t => {
|
||||
let label = t
|
||||
if (t === "avatar") label = "头像 (avatar)"
|
||||
else if (t === "attachment") label = "附件 (attachment)"
|
||||
else if (t === "doc") label = "文档 (doc)"
|
||||
else if (t === "generic") label = "通用 (generic)"
|
||||
return { value: t, label }
|
||||
})
|
||||
}, [uploadTypesQuery.data])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* 文件访问白名单设置 */}
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<KeyRound className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">文件访问权限控制</CardTitle>
|
||||
<CardDescription className="text-xs">配置免登录直接访问的文件业务类型。不在白名单内的文件将要求登录鉴权。</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-muted-foreground">添加免鉴权类型:</span>
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={handleAddType}
|
||||
disabled={updateWhitelistMutation.isPending || systemConfigsQuery.isPending || uploadTypesQuery.isPending}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]" size="sm">
|
||||
<SelectValue placeholder="选择业务类型..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTypes
|
||||
.filter(t => !currentWhitelist.includes(t.value))
|
||||
.map(t => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{availableTypes.filter(t => !currentWhitelist.includes(t.value)).length === 0 && (
|
||||
<div className="text-xs text-muted-foreground p-2 text-center">所有类型已添加</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 当前白名单列表 */}
|
||||
<div className="rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert className="size-4 text-indigo-500" />
|
||||
<span className="font-medium text-sm text-foreground">当前免鉴权列表</span>
|
||||
</div>
|
||||
|
||||
{currentWhitelist.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{currentWhitelist.map(type => (
|
||||
<Badge
|
||||
key={type}
|
||||
variant="secondary"
|
||||
className="px-2.5 py-1 text-xs gap-1.5 flex items-center bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 dark:bg-indigo-500/20 border border-indigo-500/20"
|
||||
>
|
||||
{availableTypes.find(t => t.value === type)?.label || type}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveType(type)}
|
||||
disabled={updateWhitelistMutation.isPending || systemConfigsQuery.isPending}
|
||||
className="rounded-full outline-hidden hover:bg-indigo-500/20 p-0.5 text-indigo-600 dark:text-indigo-400 cursor-pointer disabled:cursor-not-allowed"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
白名单已空,所有类型文件的访问都将需要登录。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 通知模板管理 */}
|
||||
<TemplatesManager />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client"
|
||||
|
||||
import {ComponentType, useMemo} from "react"
|
||||
import {useMutation, useQueryClient} from "@tanstack/react-query"
|
||||
import {
|
||||
Bell,
|
||||
Code,
|
||||
CreditCard,
|
||||
Database,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Home,
|
||||
Info,
|
||||
Layers,
|
||||
LayoutList,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Terminal,
|
||||
UserRound
|
||||
} from "lucide-react"
|
||||
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import services from "@/lib/services"
|
||||
import type {SystemConfig} from "@/lib/services/admin"
|
||||
import {toast} from "sonner"
|
||||
|
||||
interface MenuItem {
|
||||
path: string
|
||||
label: string
|
||||
description: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
interface MenuGroup {
|
||||
name: string
|
||||
items: MenuItem[]
|
||||
}
|
||||
|
||||
const MENU_GROUPS: MenuGroup[] = [
|
||||
{
|
||||
name: "基础菜单",
|
||||
items: [
|
||||
{ path: "/", label: "总览", description: "OpenFlare 控制台总览", icon: Home },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "管理菜单",
|
||||
items: [
|
||||
{ path: "/admin/users", label: "用户管理", description: "查看和管理系统用户列表及其状态", icon: UserRound },
|
||||
{ path: "/admin/tasks", label: "任务管理", description: "查看和调度系统异步及定时任务", icon: Layers },
|
||||
{ path: "/admin/files", label: "存储管理", description: "管理系统文件存储和清理无用文件", icon: FolderOpen },
|
||||
{ path: "/admin/database", label: "数据管理", description: "监控物理数据库状态、分页浏览表数据及交互式 SQL 查询", icon: Database },
|
||||
{ path: "/admin/push", label: "通知推送", description: "配置和发送系统通知及推送消息", icon: Bell },
|
||||
{ path: "/admin/logs", label: "系统日志", description: "查看异步任务执行日志和系统运行情况", icon: Terminal },
|
||||
{ path: "/admin/system", label: "系统配置", description: "管理和维护系统基础键值对配置", icon: ShieldCheck },
|
||||
{ path: "/admin/settings", label: "系统设置", description: "配置安全验证、邮箱服务及目录显示", icon: Settings, readOnly: true },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "文档菜单",
|
||||
items: [
|
||||
{ path: "/admin/demo", label: "规范示例", description: "内置 UI 组件与设计规范的展示、调试与参考", icon: Code },
|
||||
{ path: "/docs/api", label: "接口文档", description: "系统 Swagger 交互式 API 接口文档", icon: CreditCard },
|
||||
{ path: "/docs/how-to-use", label: "使用文档", description: "面向开发与运营的部署使用指南", icon: FileText },
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
interface OtherTabProps {
|
||||
configs: Record<string, SystemConfig>
|
||||
}
|
||||
|
||||
export function OtherTab({ configs }: OtherTabProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const menuDisplayConfig = useMemo(() => {
|
||||
const raw = configs["menu_display_config"]?.value
|
||||
if (!raw) return {} as Record<string, boolean>
|
||||
try {
|
||||
return JSON.parse(raw) as Record<string, boolean>
|
||||
} catch {
|
||||
return {} as Record<string, boolean>
|
||||
}
|
||||
}, [configs])
|
||||
|
||||
const updateMenuConfigMutation = useMutation({
|
||||
mutationFn: async ({ path, enabled }: { path: string; enabled: boolean }) => {
|
||||
const newConfig = { ...menuDisplayConfig, [path]: enabled }
|
||||
const currentCfg = configs["menu_display_config"]
|
||||
await services.adminSystemConfig.updateSystemConfig("menu_display_config", {
|
||||
value: JSON.stringify(newConfig),
|
||||
description: currentCfg?.description || "目录显示配置(JSON 字符串,格式为 {url: enabled})",
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
|
||||
await queryClient.invalidateQueries({ queryKey: ["public-config"] })
|
||||
toast.success("目录显示配置已更新")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "更新配置失败")
|
||||
},
|
||||
})
|
||||
|
||||
const handleMenuToggle = (path: string, checked: boolean) => {
|
||||
updateMenuConfigMutation.mutate({ path, enabled: checked })
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<LayoutList className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">目录显示管理</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
配置系统左侧菜单的显示与隐藏状态,适用于所有登录用户。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-6">
|
||||
{MENU_GROUPS.map((group) => (
|
||||
<div key={group.name} className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground tracking-wider uppercase">
|
||||
{group.name}
|
||||
</span>
|
||||
<div className="h-px bg-border/40 flex-1" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isReadOnly = !!item.readOnly
|
||||
const checked = menuDisplayConfig[item.path] !== false
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.path}
|
||||
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm"
|
||||
>
|
||||
<div className="space-y-1.5 flex-1 min-w-0 pr-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="size-4 text-indigo-500 shrink-0" />}
|
||||
<span className="font-medium text-sm text-foreground truncate">{item.label}</span>
|
||||
{isReadOnly && (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground border shrink-0">
|
||||
不可隐藏
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-normal line-clamp-2">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Switch
|
||||
checked={checked}
|
||||
disabled={isReadOnly || updateMenuConfigMutation.isPending}
|
||||
onCheckedChange={(val) => handleMenuToggle(item.path, val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="p-3.5 rounded-lg border border-dashed border-indigo-500/20 bg-indigo-500/5 flex items-start gap-2.5">
|
||||
<Info className="size-4 text-indigo-500 shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-muted-foreground leading-relaxed">
|
||||
<span className="font-semibold text-foreground">安全提示:</span>
|
||||
为了防止管理员在关闭“系统设置”后导致无法重新访问此配置页,系统限制了“系统设置”的关闭权限。其它所有菜单均可自由开关,隐藏后对应的分组标题在为空时也会自动隐藏。
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,749 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import {motion, useAnimation} from "motion/react"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator
|
||||
} from "@/components/ui/breadcrumb"
|
||||
import {useUser} from "@/contexts/user-context"
|
||||
import {
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
Camera,
|
||||
Edit,
|
||||
Globe,
|
||||
Info,
|
||||
Link2,
|
||||
Loader2,
|
||||
Lock,
|
||||
Mail,
|
||||
MapPin,
|
||||
Phone,
|
||||
Shield,
|
||||
Unlink,
|
||||
User as UserIcon
|
||||
} from "lucide-react"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Separator} from "@/components/ui/separator"
|
||||
import {Textarea} from "@/components/ui/textarea"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import type {ChangePasswordRequest, UpdateProfileRequest} from "@/lib/services/auth"
|
||||
import {AuthService} from "@/lib/services/auth"
|
||||
import services from "@/lib/services"
|
||||
import {ImageCrop, ImageCropApply, ImageCropContent, ImageCropReset} from "@/components/ui/image-crop"
|
||||
import {toast} from "sonner"
|
||||
|
||||
export function ProfileMain() {
|
||||
const { user, loading, refetch } = useUser()
|
||||
const controls = useAnimation()
|
||||
const isAnimatingRef = React.useRef(false)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// 修改密码 State
|
||||
const [oldPassword, setOldPassword] = React.useState("")
|
||||
const [newPassword, setNewPassword] = React.useState("")
|
||||
const [confirmPassword, setConfirmPassword] = React.useState("")
|
||||
|
||||
// 编辑个人资料 State
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false)
|
||||
const [nickname, setNickname] = React.useState("")
|
||||
const [email, setEmail] = React.useState("")
|
||||
const [bio, setBio] = React.useState("")
|
||||
const [phone, setPhone] = React.useState("")
|
||||
const [gender, setGender] = React.useState("secret")
|
||||
const [website, setWebsite] = React.useState("")
|
||||
const [location, setLocation] = React.useState("")
|
||||
const [avatarUrl, setAvatarUrl] = React.useState("")
|
||||
|
||||
// 头像裁剪 State
|
||||
const [cropFile, setCropFile] = React.useState<File | null>(null)
|
||||
const [isCropDialogOpen, setIsCropDialogOpen] = React.useState(false)
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
// 初始化编辑表单数据
|
||||
React.useEffect(() => {
|
||||
if (user) {
|
||||
setNickname(user.nickname || "")
|
||||
setEmail(user.email || "")
|
||||
setBio(user.bio || "")
|
||||
setPhone(user.phone || "")
|
||||
setGender(user.gender || "secret")
|
||||
setWebsite(user.website || "")
|
||||
setLocation(user.location || "")
|
||||
setAvatarUrl(user.avatar_url || "")
|
||||
}
|
||||
}, [user, isEditDialogOpen])
|
||||
|
||||
const changePasswordMutation = useMutation({
|
||||
mutationFn: (req: ChangePasswordRequest) => AuthService.changePassword(req),
|
||||
onSuccess: () => {
|
||||
toast.success("密码修改成功")
|
||||
setOldPassword("")
|
||||
setNewPassword("")
|
||||
setConfirmPassword("")
|
||||
void refetch()
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "修改密码失败,请重试")
|
||||
},
|
||||
})
|
||||
|
||||
const handlePasswordChange = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error("两次输入的新密码不一致")
|
||||
return
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
toast.error("新密码长度不能少于 8 位")
|
||||
return
|
||||
}
|
||||
changePasswordMutation.mutate({
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
})
|
||||
}
|
||||
|
||||
const updateProfileMutation = useMutation({
|
||||
mutationFn: (req: UpdateProfileRequest) => AuthService.updateProfile(req),
|
||||
onSuccess: () => {
|
||||
toast.success("个人信息修改成功")
|
||||
setIsEditDialogOpen(false)
|
||||
void refetch()
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "修改个人信息失败,请重试")
|
||||
},
|
||||
})
|
||||
|
||||
const handleProfileSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
updateProfileMutation.mutate({
|
||||
nickname,
|
||||
email,
|
||||
avatar_url: avatarUrl,
|
||||
bio,
|
||||
phone,
|
||||
gender,
|
||||
website,
|
||||
location,
|
||||
})
|
||||
}
|
||||
|
||||
const externalAccountBindingsQuery = useQuery({
|
||||
queryKey: ["auth", "external-accounts"],
|
||||
queryFn: () => AuthService.getExternalAccountBindings(),
|
||||
})
|
||||
|
||||
const publicAuthSourcesQuery = useQuery({
|
||||
queryKey: ["auth", "public-sources"],
|
||||
queryFn: () => AuthService.getAuthSources(),
|
||||
})
|
||||
|
||||
const bindSourceMutation = useMutation({
|
||||
mutationFn: async (sourceName: string) => {
|
||||
const { authorize_url } = await AuthService.getAuthorizeUrl(sourceName, "bind")
|
||||
sessionStorage.setItem("redirect_after_login", `${window.location.pathname}${window.location.search}`)
|
||||
window.location.href = authorize_url
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "绑定认证源失败")
|
||||
},
|
||||
})
|
||||
|
||||
const handleAvatarClick = () => {
|
||||
if (isAnimatingRef.current) return
|
||||
|
||||
isAnimatingRef.current = true
|
||||
controls.start({
|
||||
rotate: [0, -20, 20, -20, 20, 0],
|
||||
transition: { duration: 0.5, ease: "easeInOut" }
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
isAnimatingRef.current = false
|
||||
}, 650)
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
setCropFile(file)
|
||||
setIsCropDialogOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCroppedImage = async (croppedBase64: string) => {
|
||||
try {
|
||||
const res = await services.upload.uploadBase64Image(croppedBase64, "avatar", "avatar.png")
|
||||
setAvatarUrl(`/f/${res.id}`)
|
||||
setIsCropDialogOpen(false)
|
||||
setCropFile(null)
|
||||
toast.success("头像上传成功,点击保存以生效")
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message || "上传头像失败")
|
||||
}
|
||||
}
|
||||
|
||||
const getGenderLabel = (g?: string) => {
|
||||
switch (g) {
|
||||
case "male": return "男"
|
||||
case "female": return "女"
|
||||
case "secret": return "保密"
|
||||
default: return g || "保密"
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="py-6 space-y-4 max-w-4xl mx-auto">
|
||||
<div className="border-b border-border pb-4">
|
||||
<h1 className="text-2xl font-semibold">个人资料</h1>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="py-6 space-y-6 max-w-4xl mx-auto">
|
||||
<div className="text-sm text-muted-foreground">未找到用户信息</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-6 space-y-6 max-w-4xl mx-auto">
|
||||
<div className="font-semibold">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/settings" className="text-base text-primary">设置</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="text-base font-semibold">个人资料</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
{/* 基本资料面板 */}
|
||||
<div className="space-y-6 bg-card border border-dashed rounded-lg p-6">
|
||||
<div className="border-b pb-4 flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">基本资料</h2>
|
||||
<p className="text-xs text-muted-foreground">您的个人账户基本信息</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-dashed"
|
||||
onClick={() => setIsEditDialogOpen(true)}
|
||||
>
|
||||
<Edit className="size-3.5 mr-1.5" />
|
||||
编辑资料
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6 pt-2">
|
||||
<motion.div
|
||||
animate={controls}
|
||||
onClick={handleAvatarClick}
|
||||
className="cursor-pointer origin-center shrink-0"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
<Avatar className="size-20 md:size-24 border-2 border-primary/10 shadow-md">
|
||||
<AvatarImage src={user.avatar_url} alt={user.nickname || user.username} />
|
||||
<AvatarFallback className="text-2xl bg-indigo-600 text-white font-bold">
|
||||
{(user.nickname || user.username).slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</motion.div>
|
||||
|
||||
<div className="flex-1 w-full space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<UserIcon className="size-3 text-muted-foreground/70" />
|
||||
账户
|
||||
</div>
|
||||
<div className="text-sm font-semibold">@{user.username}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<UserIcon className="size-3 text-muted-foreground/70" />
|
||||
昵称
|
||||
</div>
|
||||
<div className="text-sm font-semibold">{user.nickname || '未设置'}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<Info className="size-3 text-muted-foreground/70" />
|
||||
用户ID (UID)
|
||||
</div>
|
||||
<div className="text-sm font-mono font-semibold">{user.id}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<Mail className="size-3 text-muted-foreground/70" />
|
||||
邮箱
|
||||
</div>
|
||||
<div className="text-sm font-semibold truncate max-w-[220px]">{user.email || '未绑定'}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<Phone className="size-3 text-muted-foreground/70" />
|
||||
手机号码
|
||||
</div>
|
||||
<div className="text-sm font-semibold">{user.phone || '未设置'}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<UserIcon className="size-3 text-muted-foreground/70" />
|
||||
性别
|
||||
</div>
|
||||
<div className="text-sm font-semibold">{getGenderLabel(user.gender)}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<Globe className="size-3 text-muted-foreground/70" />
|
||||
个人网站
|
||||
</div>
|
||||
<div className="text-sm font-semibold truncate max-w-[220px]">
|
||||
{user.website ? (
|
||||
<a
|
||||
href={user.website.startsWith("http") ? user.website : `http://${user.website}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-indigo-600 hover:underline"
|
||||
>
|
||||
{user.website}
|
||||
</a>
|
||||
) : (
|
||||
'未设置'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<MapPin className="size-3 text-muted-foreground/70" />
|
||||
所在地
|
||||
</div>
|
||||
<div className="text-sm font-semibold">{user.location || '未设置'}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<Shield className="size-3 text-muted-foreground/70" />
|
||||
管理员身份
|
||||
</div>
|
||||
<div className="text-sm font-semibold flex items-center gap-1">
|
||||
{user.is_admin ? (
|
||||
<span className="text-rose-600 flex items-center gap-1">
|
||||
<Shield className="size-3.5" />
|
||||
是
|
||||
</span>
|
||||
) : (
|
||||
<span>否</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="border-dashed" />
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<BookOpen className="size-3 text-muted-foreground/70" />
|
||||
个人简介
|
||||
</div>
|
||||
<div className="text-sm text-foreground/80 leading-relaxed max-w-2xl bg-muted/20 border border-dashed rounded-lg p-3">
|
||||
{user.bio || '这个人很懒,什么都没有留下。'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 下方左右并排布局容器 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 修改密码面板 */}
|
||||
<div className="space-y-6 bg-card border border-dashed rounded-lg p-6 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="border-b pb-4 flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-amber-500/10 text-amber-500">
|
||||
<Lock className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold tracking-tight">修改密码</h2>
|
||||
<p className="text-[11px] text-muted-foreground">更改您的账号密码以确保安全。密码长度不能少于 8 位。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.need_change_password && (
|
||||
<div className="mt-4 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3.5 py-2.5 text-xs text-amber-500 flex items-start gap-2.5">
|
||||
<Info className="size-4 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-semibold">密码风险提示</p>
|
||||
<p className="mt-0.5 text-amber-500/80 leading-relaxed font-normal">
|
||||
为了账号安全,您必须修改初始密码。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handlePasswordChange} className="space-y-3 pt-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">当前密码</label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="请输入当前密码"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
className="h-8 text-xs rounded-lg"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">新密码</label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="新密码(至少 8 位)"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="h-8 text-xs rounded-lg"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">确认新密码</label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="确认新密码"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="h-8 text-xs rounded-lg"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto h-8 text-xs"
|
||||
disabled={changePasswordMutation.isPending}
|
||||
>
|
||||
{changePasswordMutation.isPending ? "提交中..." : "确认修改"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 账号绑定面板 */}
|
||||
<div className="space-y-6 bg-card border border-dashed rounded-lg p-6 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="border-b pb-4 flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<Link2 className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold tracking-tight">第三方账号绑定</h2>
|
||||
<p className="text-[11px] text-muted-foreground">管理并关联您的第三方授权账户,便于快捷登录与验证</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 已绑定账号列表 */}
|
||||
<div className="space-y-2 pt-4">
|
||||
<h3 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">已绑定账号</h3>
|
||||
{externalAccountBindingsQuery.isPending ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="size-4 animate-spin text-indigo-500" />
|
||||
</div>
|
||||
) : (externalAccountBindingsQuery.data ?? []).length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{(externalAccountBindingsQuery.data ?? []).map((binding) => (
|
||||
<div
|
||||
key={binding.id}
|
||||
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-3 bg-card hover:bg-muted/10 transition-all duration-300"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<span className="font-semibold text-xs text-foreground block">{binding.auth_source_label}</span>
|
||||
<span className="text-[10px] text-muted-foreground font-mono block truncate max-w-[150px]">
|
||||
{binding.external_username || binding.email || "未提供账号标识"}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-[11px] text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 rounded-lg h-7 px-2 transition-colors"
|
||||
onClick={async () => {
|
||||
await AuthService.deleteExternalAccountBinding(binding.id)
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth", "external-accounts"] })
|
||||
toast.success("绑定已移除")
|
||||
}}
|
||||
>
|
||||
<Unlink className="size-3 mr-1" />
|
||||
解绑
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border/50 px-4 py-6 text-center text-[11px] text-muted-foreground bg-muted/5 flex flex-col items-center justify-center">
|
||||
<Link2 className="size-5 text-muted-foreground/30 mb-1" />
|
||||
暂无绑定的第三方账号
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="border-dashed my-4" />
|
||||
|
||||
{/* 绑定新账号列表 */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">绑定新账号</h3>
|
||||
{publicAuthSourcesQuery.isPending ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="size-4 animate-spin text-indigo-500" />
|
||||
</div>
|
||||
) : (publicAuthSourcesQuery.data ?? []).length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{(publicAuthSourcesQuery.data ?? []).map((source) => (
|
||||
<Button
|
||||
key={source.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex items-center justify-between w-full border border-dashed rounded-xl px-3 py-2 text-left font-normal text-xs hover:bg-indigo-500/5 hover:text-indigo-500 hover:border-indigo-500/30 transition-all duration-300 group h-8"
|
||||
onClick={() => {
|
||||
void bindSourceMutation.mutateAsync(source.name)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Link2 className="size-3 text-muted-foreground group-hover:text-indigo-500" />
|
||||
<span>绑定 {source.display_name || source.name}</span>
|
||||
</div>
|
||||
<ArrowRight className="size-3 opacity-0 -translate-x-1 group-hover:opacity-100 group-hover:translate-x-0 transition-all text-indigo-500" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[11px] text-muted-foreground text-center py-2">
|
||||
暂无可用第三方认证源
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 编辑资料 Dialog */}
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑个人资料</DialogTitle>
|
||||
<DialogDescription>修改您的基本信息,保存后立即生效。</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleProfileSubmit} className="space-y-4">
|
||||
{/* 上传头像域 */}
|
||||
<div className="flex flex-col items-center gap-2 py-2">
|
||||
<div
|
||||
className="group relative cursor-pointer rounded-full border border-dashed hover:border-primary/50 transition-all"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Avatar className="size-20 border-2 border-primary/5 shadow-md">
|
||||
<AvatarImage src={avatarUrl} alt={nickname} />
|
||||
<AvatarFallback className="text-xl bg-indigo-600 text-white font-bold">
|
||||
{(nickname || "U").slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="absolute inset-0 bg-black/40 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Camera className="size-5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">点击上传/修改头像</span>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="nickname" className="text-xs">昵称</Label>
|
||||
<Input
|
||||
id="nickname"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
className="h-8 text-xs rounded-lg"
|
||||
placeholder="请输入您的昵称"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="email" className="text-xs">邮箱</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="h-8 text-xs rounded-lg"
|
||||
placeholder="请输入您的邮箱"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="phone" className="text-xs">手机号码</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
className="h-8 text-xs rounded-lg"
|
||||
placeholder="请输入手机号"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="gender" className="text-xs">性别</Label>
|
||||
<Select value={gender} onValueChange={setGender}>
|
||||
<SelectTrigger id="gender" size="sm" className="w-full text-xs h-8">
|
||||
<SelectValue placeholder="选择性别" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="male">男</SelectItem>
|
||||
<SelectItem value="female">女</SelectItem>
|
||||
<SelectItem value="secret">保密</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="website" className="text-xs">个人网站</Label>
|
||||
<Input
|
||||
id="website"
|
||||
value={website}
|
||||
onChange={(e) => setWebsite(e.target.value)}
|
||||
className="h-8 text-xs rounded-lg"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="location" className="text-xs">所在地</Label>
|
||||
<Input
|
||||
id="location"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
className="h-8 text-xs rounded-lg"
|
||||
placeholder="如:北京"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="bio" className="text-xs">个人简介</Label>
|
||||
<Textarea
|
||||
id="bio"
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
className="text-xs min-h-[60px] rounded-lg"
|
||||
placeholder="介绍一下自己吧..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => setIsEditDialogOpen(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
disabled={updateProfileMutation.isPending}
|
||||
>
|
||||
{updateProfileMutation.isPending ? "保存中..." : "保存修改"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 头像裁剪 Dialog */}
|
||||
<Dialog open={isCropDialogOpen} onOpenChange={setIsCropDialogOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>裁剪头像</DialogTitle>
|
||||
<DialogDescription>调整头像位置和大小,使其处于居中圆形区域内。</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{cropFile && (
|
||||
<div className="flex flex-col items-center gap-4 py-2">
|
||||
<ImageCrop file={cropFile} aspect={1} onCrop={handleCroppedImage}>
|
||||
<ImageCropContent className="max-w-sm rounded-lg border border-dashed" />
|
||||
<div className="mt-3 flex justify-center gap-2">
|
||||
<ImageCropReset />
|
||||
<ImageCropApply />
|
||||
</div>
|
||||
</ImageCrop>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => {
|
||||
setIsCropDialogOpen(false)
|
||||
setCropFile(null)
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
"use client"
|
||||
|
||||
import {useEffect, useState} from "react"
|
||||
import {useMutation, useQuery, useQueryClient, type UseQueryResult} from "@tanstack/react-query"
|
||||
import {
|
||||
Clock,
|
||||
Fingerprint,
|
||||
Globe,
|
||||
Loader2,
|
||||
Lock,
|
||||
Mail,
|
||||
Pencil,
|
||||
Plus,
|
||||
Settings,
|
||||
Shield,
|
||||
Trash2,
|
||||
UserPlus
|
||||
} from "lucide-react"
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
|
||||
import {AuthSourceModal} from "@/components/common/settings/auth-source-modal"
|
||||
import services from "@/lib/services"
|
||||
import type {AuthSource, SystemConfig} from "@/lib/services/admin"
|
||||
import {toast} from "sonner"
|
||||
|
||||
const SECURITY_KEYS = [
|
||||
{
|
||||
key: "password_login_enabled",
|
||||
title: "允许密码登录",
|
||||
description: "关闭后仅保留第三方 OIDC 认证源进行系统登录。",
|
||||
icon: Lock,
|
||||
},
|
||||
{
|
||||
key: "registration_enabled",
|
||||
title: "允许注册",
|
||||
description: "关闭后系统将禁止新用户进行自主账号注册。",
|
||||
icon: UserPlus,
|
||||
},
|
||||
{
|
||||
key: "password_register_enabled",
|
||||
title: "允许密码注册",
|
||||
description: "关闭后只能通过管理员创建或第三方认证关联建号。",
|
||||
icon: Fingerprint,
|
||||
},
|
||||
{
|
||||
key: "oidc_login_enabled",
|
||||
title: "允许 OIDC 登录",
|
||||
description: "关闭后所有的第三方 OIDC 认证登录入口都会被隐藏。",
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
key: "email_login_verification_enabled",
|
||||
title: "邮箱登录验证",
|
||||
description: "开启后,使用账号密码登录时需要通过邮箱接收并验证 6 位验证码。",
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
key: "email_register_verification_enabled",
|
||||
title: "邮箱注册验证",
|
||||
description: "开启后,用户注册账号时需要通过邮箱接收并验证 6 位验证码。",
|
||||
icon: Mail,
|
||||
},
|
||||
] as const
|
||||
|
||||
interface SecurityTabProps {
|
||||
configs: Record<string, SystemConfig>
|
||||
systemConfigsQuery: UseQueryResult<SystemConfig[], Error>
|
||||
}
|
||||
|
||||
export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [authSourceModalOpen, setAuthSourceModalOpen] = useState(false)
|
||||
const [selectedSource, setSelectedSource] = useState<AuthSource | null>(null)
|
||||
|
||||
const [capCount, setCapCount] = useState("")
|
||||
const [capDifficulty, setCapDifficulty] = useState("")
|
||||
const [capSize, setCapSize] = useState("")
|
||||
const [capTTL, setCapTTL] = useState("")
|
||||
const [capTokenTTL, setCapTokenTTL] = useState("")
|
||||
const [capAutoSolve, setCapAutoSolve] = useState(true)
|
||||
|
||||
const [sessionTTL, setSessionTTL] = useState("168")
|
||||
const [customHours, setCustomHours] = useState("")
|
||||
|
||||
const authSourcesQuery = useQuery({
|
||||
queryKey: ["auth", "sources"],
|
||||
queryFn: () => services.adminAuthSource.listAuthSources(),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (systemConfigsQuery.data) {
|
||||
const cfgMap = configs
|
||||
setCapCount(cfgMap["cap_challenge_count"]?.value || "1")
|
||||
setCapDifficulty(cfgMap["cap_challenge_difficulty"]?.value || "4")
|
||||
setCapSize(cfgMap["cap_challenge_size"]?.value || "32")
|
||||
setCapTTL(cfgMap["cap_challenge_ttl_seconds"]?.value || "600")
|
||||
setCapTokenTTL(cfgMap["cap_token_ttl_seconds"]?.value || "1200")
|
||||
setCapAutoSolve(cfgMap["cap_auto_solve"]?.value !== "false")
|
||||
|
||||
// 初始化登录保持设置
|
||||
const ttlVal = cfgMap["login_session_ttl_hours"]?.value || "0"
|
||||
if (ttlVal === "0" || ttlVal === "168" || ttlVal === "720" || ttlVal === "-1") {
|
||||
setSessionTTL(ttlVal)
|
||||
setCustomHours("")
|
||||
} else {
|
||||
setSessionTTL("custom")
|
||||
setCustomHours(ttlVal)
|
||||
}
|
||||
}
|
||||
}, [systemConfigsQuery.data, configs])
|
||||
|
||||
const updateTTLMutation = useMutation({
|
||||
mutationFn: async (value: string) => {
|
||||
const config = configs["login_session_ttl_hours"]
|
||||
if (!config) {
|
||||
throw new Error("缺少配置项: login_session_ttl_hours")
|
||||
}
|
||||
await services.adminSystemConfig.updateSystemConfig("login_session_ttl_hours", {
|
||||
value: value,
|
||||
description: config.description,
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
|
||||
toast.success("登录状态保持时间已更新")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "更新配置失败")
|
||||
},
|
||||
})
|
||||
|
||||
const handleTTLChange = (val: string) => {
|
||||
setSessionTTL(val)
|
||||
if (val !== "custom") {
|
||||
updateTTLMutation.mutate(val)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCustomBlur = () => {
|
||||
const parsed = parseInt(customHours, 10)
|
||||
if (isNaN(parsed) || parsed <= 0) {
|
||||
toast.error("请输入有效的过期小时数(正整数)")
|
||||
// 重置为原本的值
|
||||
const originalVal = configs["login_session_ttl_hours"]?.value || "0"
|
||||
setCustomHours(originalVal === "custom" || ["0", "168", "720", "-1"].includes(originalVal) ? "" : originalVal)
|
||||
return
|
||||
}
|
||||
updateTTLMutation.mutate(parsed.toString())
|
||||
}
|
||||
|
||||
const updateConfigMutation = useMutation({
|
||||
mutationFn: async ({ key, value }: { key: string; value: boolean }) => {
|
||||
const config = configs[key]
|
||||
if (!config) {
|
||||
throw new Error(`缺少配置项: ${key}`)
|
||||
}
|
||||
await services.adminSystemConfig.updateSystemConfig(key, {
|
||||
value: value ? "true" : "false",
|
||||
description: config.description,
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
|
||||
await queryClient.invalidateQueries({ queryKey: ["public-config"] })
|
||||
toast.success("系统安全配置已更新")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "更新配置失败")
|
||||
},
|
||||
})
|
||||
|
||||
const toggleSourceMutation = useMutation({
|
||||
mutationFn: async (source: AuthSource) => {
|
||||
await services.adminAuthSource.toggleAuthSource(source.id, { is_active: !source.is_active })
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth", "sources"] })
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth", "public-sources"] })
|
||||
toast.success("认证源状态已更新")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "切换状态失败")
|
||||
},
|
||||
})
|
||||
|
||||
const deleteSourceMutation = useMutation({
|
||||
mutationFn: async (sourceId: string) => {
|
||||
await services.adminAuthSource.deleteAuthSource(sourceId)
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth", "sources"] })
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth", "public-sources"] })
|
||||
toast.success("认证源已删除")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "删除认证源失败")
|
||||
},
|
||||
})
|
||||
|
||||
const handleToggle = (key: string, checked: boolean) => {
|
||||
updateConfigMutation.mutate({ key, value: checked })
|
||||
}
|
||||
|
||||
const saveCapMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const updates = [
|
||||
{ key: "cap_challenge_count", value: capCount },
|
||||
{ key: "cap_challenge_difficulty", value: capDifficulty },
|
||||
{ key: "cap_challenge_size", value: capSize },
|
||||
{ key: "cap_challenge_ttl_seconds", value: capTTL },
|
||||
{ key: "cap_token_ttl_seconds", value: capTokenTTL },
|
||||
{ key: "cap_auto_solve", value: capAutoSolve ? "true" : "false" },
|
||||
]
|
||||
|
||||
for (const update of updates) {
|
||||
const currentCfg = configs[update.key]
|
||||
await services.adminSystemConfig.updateSystemConfig(update.key, {
|
||||
value: update.value,
|
||||
description: currentCfg?.description || "",
|
||||
})
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
|
||||
toast.success("人机验证配置已成功保存")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "保存配置失败")
|
||||
},
|
||||
})
|
||||
|
||||
const handleCapSave = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
saveCapMutation.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 系统登录与注册控制 */}
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<Settings className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">系统登录/注册设置</CardTitle>
|
||||
<CardDescription className="text-xs">配置系统的登录限制与用户自主注册权限</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{SECURITY_KEYS.map((item) => {
|
||||
const config = configs[item.key]
|
||||
const checked = config ? config.value === "true" : false
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="size-4 text-indigo-500" />}
|
||||
<span className="font-medium text-sm text-foreground">{item.title}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed pr-2">{item.description}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={checked}
|
||||
disabled={updateConfigMutation.isPending}
|
||||
onCheckedChange={(value) => handleToggle(item.key, value)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* 登录状态保持时间 (选择后立即更改) */}
|
||||
<div
|
||||
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm md:col-span-2"
|
||||
>
|
||||
<div className="space-y-1 pr-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="size-4 text-indigo-500" />
|
||||
<span className="font-medium text-sm text-foreground">登录状态保持时间</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed pr-2">
|
||||
配置用户登录会话在浏览器中的保持期限。设置为“关闭”则在浏览器关闭后自动退登。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Select
|
||||
value={sessionTTL}
|
||||
disabled={updateTTLMutation.isPending}
|
||||
onValueChange={handleTTLChange}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] bg-card border-dashed text-xs h-8">
|
||||
<SelectValue placeholder="选择保留时间" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">关闭 (浏览器关闭自动退登)</SelectItem>
|
||||
<SelectItem value="168">7 天</SelectItem>
|
||||
<SelectItem value="720">30 天</SelectItem>
|
||||
<SelectItem value="-1">永不过期</SelectItem>
|
||||
<SelectItem value="custom">自定义时长</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{sessionTTL === "custom" && (
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={customHours}
|
||||
onChange={(e) => setCustomHours(e.target.value)}
|
||||
onBlur={handleCustomBlur}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleCustomBlur()
|
||||
}
|
||||
}}
|
||||
placeholder="小时"
|
||||
disabled={updateTTLMutation.isPending}
|
||||
className="w-20 bg-card border-dashed text-xs h-8 px-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 认证源配置管理 */}
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4 flex flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<Globe className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">认证源管理</CardTitle>
|
||||
<CardDescription className="text-xs">添加、修改并启用系统自定义的 OIDC 认证源</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedSource(null)
|
||||
setAuthSourceModalOpen(true)
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
新增认证源
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-3">
|
||||
{authSourcesQuery.isPending ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground/50" />
|
||||
</div>
|
||||
) : (authSourcesQuery.data ?? []).length > 0 ? (
|
||||
(authSourcesQuery.data ?? []).map((source) => (
|
||||
<div
|
||||
key={source.id}
|
||||
className="flex items-center justify-between rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 transition-all duration-300 shadow-sm"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm text-foreground">{source.display_name || source.name}</span>
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full border font-medium ${
|
||||
source.is_active
|
||||
? "bg-emerald-500/10 text-emerald-500 border-emerald-500/20"
|
||||
: "bg-amber-500/10 text-amber-500 border-amber-500/20"
|
||||
}`}>
|
||||
{source.is_active ? "已启用" : "已禁用"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
标识符: {source.name} · 类型: {source.type.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={`text-xs px-2.5 py-1 rounded-lg border font-medium hidden sm:inline-block ${
|
||||
source.client_secret_configured
|
||||
? "bg-indigo-500/5 text-indigo-500 border-indigo-500/10"
|
||||
: "bg-rose-500/5 text-rose-500 border-rose-500/10"
|
||||
}`}>
|
||||
{source.client_secret_configured ? "Secret 已配置" : "Secret 未配置"}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={source.is_active}
|
||||
disabled={toggleSourceMutation.isPending}
|
||||
className="scale-90 mr-2"
|
||||
onCheckedChange={() => toggleSourceMutation.mutate(source)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground hover:text-indigo-500 hover:bg-indigo-500/10 rounded-lg transition-colors"
|
||||
onClick={() => {
|
||||
setSelectedSource(source)
|
||||
setAuthSourceModalOpen(true)
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 rounded-lg transition-colors"
|
||||
disabled={deleteSourceMutation.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(`确定删除认证源「${source.display_name || source.name}」吗?`)) {
|
||||
deleteSourceMutation.mutate(source.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border/50 px-4 py-8 text-center text-xs text-muted-foreground bg-muted/5 flex flex-col items-center justify-center gap-3">
|
||||
<span>暂无配置的认证源,点击上方按钮新增</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedSource(null)
|
||||
setAuthSourceModalOpen(true)
|
||||
}}
|
||||
className="border-dashed"
|
||||
>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
新增认证源
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 人机验证配置 (Cap CAPTCHA) */}
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4 flex flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<Shield className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">人机验证配置 (Cap CAPTCHA)</CardTitle>
|
||||
<CardDescription className="text-xs">配置基于 Proof-of-Work (PoW) 的无感人机验证,保护系统登录免受暴力破解和撞库攻击</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={configs["cap_login_enabled"]?.value === "true"}
|
||||
disabled={updateConfigMutation.isPending}
|
||||
onCheckedChange={(checked) => handleToggle("cap_login_enabled", checked)}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
{/* 自动开始计算 Switch */}
|
||||
<div className="flex items-center justify-between rounded-xl border border-dashed p-4 bg-card mb-4">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-semibold">打开页面后自动开始计算</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={capAutoSolve}
|
||||
onCheckedChange={setCapAutoSolve}
|
||||
/>
|
||||
</div>
|
||||
<form onSubmit={handleCapSave} className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cap_challenge_count" className="text-xs font-semibold">难题数量 (Count)</Label>
|
||||
<Input
|
||||
id="cap_challenge_count"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={capCount}
|
||||
onChange={(e) => setCapCount(e.target.value)}
|
||||
placeholder="50"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">客户端需求解的难题总数。默认 1,推荐 1 至 5</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cap_challenge_difficulty" className="text-xs font-semibold">验证难度 (Difficulty)</Label>
|
||||
<Input
|
||||
id="cap_challenge_difficulty"
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={capDifficulty}
|
||||
onChange={(e) => setCapDifficulty(e.target.value)}
|
||||
placeholder="4"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">PoW 前缀哈希位数,每加 1 计算时间翻倍。默认 4,推荐 4</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cap_challenge_size" className="text-xs font-semibold">盐值长度 (Size)</Label>
|
||||
<Input
|
||||
id="cap_challenge_size"
|
||||
type="number"
|
||||
min={8}
|
||||
max={64}
|
||||
value={capSize}
|
||||
onChange={(e) => setCapSize(e.target.value)}
|
||||
placeholder="32"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">难题盐值混淆字符长度。默认 32</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cap_challenge_ttl" className="text-xs font-semibold">难题超时时长 (秒)</Label>
|
||||
<Input
|
||||
id="cap_challenge_ttl"
|
||||
type="number"
|
||||
min={10}
|
||||
value={capTTL}
|
||||
onChange={(e) => setCapTTL(e.target.value)}
|
||||
placeholder="600"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">难题有效期限。默认 600 秒 (10 分钟)</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="cap_token_ttl" className="text-xs font-semibold">验证凭证有效时长 (秒)</Label>
|
||||
<Input
|
||||
id="cap_token_ttl"
|
||||
type="number"
|
||||
min={10}
|
||||
value={capTokenTTL}
|
||||
onChange={(e) => setCapTokenTTL(e.target.value)}
|
||||
placeholder="1200"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">PoW 计算求解通过后,签发的登录凭证有效时长。默认 1200 秒 (20 分钟)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4 border-t border-dashed">
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={saveCapMutation.isPending}
|
||||
>
|
||||
{saveCapMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
"保存配置"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AuthSourceModal
|
||||
isOpen={authSourceModalOpen}
|
||||
source={selectedSource}
|
||||
onClose={() => setAuthSourceModalOpen(false)}
|
||||
onChanged={async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth", "sources"] })
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth", "public-sources"] })
|
||||
await authSourcesQuery.refetch()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
"use client"
|
||||
|
||||
import {useEffect, useState} from "react"
|
||||
import {useMutation, useQueryClient, type UseQueryResult} from "@tanstack/react-query"
|
||||
import {Globe, Loader2, Mail, Search, Server, Sparkles} from "lucide-react"
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import services from "@/lib/services"
|
||||
import type {SystemConfig} from "@/lib/services/admin"
|
||||
import {toast} from "sonner"
|
||||
|
||||
interface SystemTabProps {
|
||||
configs: Record<string, SystemConfig>
|
||||
systemConfigsQuery: UseQueryResult<SystemConfig[], Error>
|
||||
}
|
||||
|
||||
export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [serverAddress, setServerAddress] = useState("")
|
||||
const [siteName, setSiteName] = useState("")
|
||||
const [smtpHost, setSmtpHost] = useState("")
|
||||
const [smtpPort, setSmtpPort] = useState("")
|
||||
const [smtpUsername, setSmtpUsername] = useState("")
|
||||
const [smtpPassword, setSmtpPassword] = useState("")
|
||||
const [smtpTestOpen, setSmtpTestOpen] = useState(false)
|
||||
const [smtpTestTo, setSmtpTestTo] = useState("")
|
||||
const [smtpTestLog, setSmtpTestLog] = useState("")
|
||||
const [smtpTestSuccess, setSmtpTestSuccess] = useState<boolean | null>(null)
|
||||
const [smtpTestError, setSmtpTestError] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (systemConfigsQuery.data) {
|
||||
setServerAddress(configs["server_address"]?.value || "")
|
||||
setSiteName(configs["site_name"]?.value || "")
|
||||
setSmtpHost(configs["smtp_host"]?.value || "")
|
||||
setSmtpPort(configs["smtp_port"]?.value || "587")
|
||||
setSmtpUsername(configs["smtp_username"]?.value || "")
|
||||
setSmtpPassword(configs["smtp_password"]?.value || "")
|
||||
}
|
||||
}, [systemConfigsQuery.data, configs])
|
||||
|
||||
const handleDetectAddress = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
setServerAddress(window.location.origin)
|
||||
toast.success("已自动获取当前域名并填充")
|
||||
}
|
||||
}
|
||||
|
||||
const updateConfigMutation = useMutation({
|
||||
mutationFn: async ({ key, value }: { key: string; value: boolean }) => {
|
||||
const config = configs[key]
|
||||
if (!config) {
|
||||
throw new Error(`缺少配置项: ${key}`)
|
||||
}
|
||||
await services.adminSystemConfig.updateSystemConfig(key, {
|
||||
value: value ? "true" : "false",
|
||||
description: config.description,
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
|
||||
await queryClient.invalidateQueries({ queryKey: ["public-config"] })
|
||||
toast.success("配置已更新")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "更新配置失败")
|
||||
},
|
||||
})
|
||||
|
||||
const saveSystemMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const currentAddrCfg = configs["server_address"]
|
||||
const currentSiteCfg = configs["site_name"]
|
||||
await Promise.all([
|
||||
services.adminSystemConfig.updateSystemConfig("server_address", {
|
||||
value: serverAddress,
|
||||
description: currentAddrCfg?.description || "服务器地址",
|
||||
}),
|
||||
services.adminSystemConfig.updateSystemConfig("site_name", {
|
||||
value: siteName,
|
||||
description: currentSiteCfg?.description || "系统平台的展示名称",
|
||||
}),
|
||||
])
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["public-config"] }),
|
||||
])
|
||||
toast.success("通用配置已成功保存")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "保存配置失败")
|
||||
},
|
||||
})
|
||||
|
||||
const handleSystemSave = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
saveSystemMutation.mutate()
|
||||
}
|
||||
|
||||
const saveSmtpMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const updates = [
|
||||
{ key: "smtp_host", value: smtpHost },
|
||||
{ key: "smtp_port", value: smtpPort },
|
||||
{ key: "smtp_username", value: smtpUsername },
|
||||
{ key: "smtp_password", value: smtpPassword },
|
||||
]
|
||||
|
||||
for (const update of updates) {
|
||||
const currentCfg = configs[update.key]
|
||||
if (update.key === "smtp_password" && (update.value === "" || update.value === "******")) {
|
||||
// If already configured and sent empty or mask, skip updating it (keep existing)
|
||||
if (currentCfg && currentCfg.value === "******") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
await services.adminSystemConfig.updateSystemConfig(update.key, {
|
||||
value: update.value,
|
||||
description: currentCfg?.description || "",
|
||||
})
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
|
||||
toast.success("SMTP 邮件配置已成功保存")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "保存配置失败")
|
||||
},
|
||||
})
|
||||
|
||||
const handleSmtpSave = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
saveSmtpMutation.mutate()
|
||||
}
|
||||
|
||||
const testSmtpMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
setSmtpTestLog("正在发起连接测试...\n")
|
||||
setSmtpTestSuccess(null)
|
||||
setSmtpTestError("")
|
||||
|
||||
const res = await services.adminSystemConfig.testSMTP({
|
||||
smtp_host: smtpHost,
|
||||
smtp_port: parseInt(smtpPort, 10) || 587,
|
||||
smtp_username: smtpUsername,
|
||||
smtp_password: smtpPassword,
|
||||
to: smtpTestTo,
|
||||
})
|
||||
return res
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setSmtpTestLog(data.log)
|
||||
if (data.success) {
|
||||
setSmtpTestSuccess(true)
|
||||
toast.success("测试邮件发送成功")
|
||||
} else {
|
||||
setSmtpTestSuccess(false)
|
||||
setSmtpTestError(data.error || "发送失败,请检查配置和日志。")
|
||||
toast.error("测试邮件发送失败")
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setSmtpTestSuccess(false)
|
||||
setSmtpTestError(error.message || "请求发送失败")
|
||||
setSmtpTestLog((prev) => prev + `\n[请求错误] ${error.message}\n`)
|
||||
toast.error(error.message || "测试请求发送失败")
|
||||
},
|
||||
})
|
||||
|
||||
const handleSmtpTestSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!smtpTestTo) {
|
||||
toast.error("请输入目标邮箱地址")
|
||||
return
|
||||
}
|
||||
testSmtpMutation.mutate()
|
||||
}
|
||||
|
||||
const indexingEnabled = configs["search_engine_indexing_enabled"]?.value === "true"
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* 通用设置 */}
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-lg bg-indigo-500/10 p-1.5 text-indigo-500">
|
||||
<Server className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">通用设置</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
配置站点标识、服务地址与搜索引擎收录策略
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleSystemSave} className="flex flex-col gap-6">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="site_name" className="text-xs font-semibold">
|
||||
站点名称
|
||||
</Label>
|
||||
<Input
|
||||
id="site_name"
|
||||
type="text"
|
||||
value={siteName}
|
||||
onChange={(e) => setSiteName(e.target.value)}
|
||||
placeholder="例如: OpenFlare"
|
||||
className="border-dashed bg-card text-xs"
|
||||
/>
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
用于网页标题、登录注册页面、管理后台与系统邮件。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor="server_address" className="text-xs font-semibold">
|
||||
服务器访问地址
|
||||
</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={handleDetectAddress}
|
||||
className="h-auto px-0 text-xs"
|
||||
>
|
||||
<Sparkles data-icon="inline-start" />
|
||||
使用当前域名
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
id="server_address"
|
||||
type="url"
|
||||
value={serverAddress}
|
||||
onChange={(e) => setServerAddress(e.target.value)}
|
||||
placeholder="例如: https://example.com"
|
||||
className="border-dashed bg-card text-xs"
|
||||
/>
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
限定 API 的允许来源;留空将开放任意来源访问。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-dashed p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="rounded-md bg-muted p-1.5 text-muted-foreground">
|
||||
<Search className="size-4" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-semibold">搜索引擎抓取与收录</span>
|
||||
<Badge variant={indexingEnabled ? "default" : "secondary"}>
|
||||
{indexingEnabled ? "允许收录" : "禁止收录"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
控制 Google、Baidu、Bing 等搜索引擎是否可以索引公开页面。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
aria-label="允许搜索引擎抓取与收录"
|
||||
checked={indexingEnabled}
|
||||
disabled={updateConfigMutation.isPending || systemConfigsQuery.isPending}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfigMutation.mutate({ key: "search_engine_indexing_enabled", value: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse gap-2 border-t border-dashed pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<Globe className="size-3.5" />
|
||||
地址变更会影响跨域访问策略,请填写完整协议与域名。
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={saveSystemMutation.isPending}
|
||||
>
|
||||
{saveSystemMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 data-icon="inline-start" className="animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
"保存配置"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* SMTP 邮件设置 */}
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<Mail className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">SMTP 邮件设置</CardTitle>
|
||||
<CardDescription className="text-xs">配置系统的邮件发送服务 (SMTP)</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleSmtpSave} className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtp_host" className="text-xs font-semibold">SMTP 服务器地址</Label>
|
||||
<Input
|
||||
id="smtp_host"
|
||||
type="text"
|
||||
value={smtpHost}
|
||||
onChange={(e) => setSmtpHost(e.target.value)}
|
||||
placeholder="例如: smtp.example.com"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtp_port" className="text-xs font-semibold">SMTP 端口</Label>
|
||||
<Input
|
||||
id="smtp_port"
|
||||
type="number"
|
||||
value={smtpPort}
|
||||
onChange={(e) => setSmtpPort(e.target.value)}
|
||||
placeholder="例如: 587 或 465"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtp_username" className="text-xs font-semibold">SMTP 账户</Label>
|
||||
<Input
|
||||
id="smtp_username"
|
||||
type="text"
|
||||
value={smtpUsername}
|
||||
onChange={(e) => setSmtpUsername(e.target.value)}
|
||||
placeholder="例如: sender@example.com"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtp_password" className="text-xs font-semibold">SMTP 访问凭证</Label>
|
||||
<Input
|
||||
id="smtp_password"
|
||||
type="password"
|
||||
value={smtpPassword}
|
||||
onChange={(e) => setSmtpPassword(e.target.value)}
|
||||
placeholder={configs["smtp_password"]?.value === "******" ? "•••••• (已配置,留空或输入新值)" : "输入凭证密码"}
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t border-dashed">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSmtpTestOpen(true)
|
||||
setSmtpTestTo("")
|
||||
setSmtpTestLog("")
|
||||
setSmtpTestSuccess(null)
|
||||
setSmtpTestError("")
|
||||
}}
|
||||
disabled={saveSmtpMutation.isPending}
|
||||
>
|
||||
测试发件
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={saveSmtpMutation.isPending}
|
||||
>
|
||||
{saveSmtpMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
"保存配置"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={smtpTestOpen} onOpenChange={setSmtpTestOpen}>
|
||||
<DialogContent className="max-w-lg border border-dashed">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-semibold">SMTP 发件测试</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
输入接收测试邮件的邮箱地址。系统将使用您在表单中当前填写的 SMTP 配置进行发件测试。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSmtpTestSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtp_test_to" className="text-xs font-semibold">目标邮箱地址</Label>
|
||||
<Input
|
||||
id="smtp_test_to"
|
||||
type="email"
|
||||
required
|
||||
value={smtpTestTo}
|
||||
onChange={(e) => setSmtpTestTo(e.target.value)}
|
||||
placeholder="例如: receiver@example.com"
|
||||
className="bg-card border-dashed text-xs"
|
||||
disabled={testSmtpMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{smtpTestLog && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">连接与传输日志</Label>
|
||||
<pre className="bg-zinc-950 text-zinc-50 font-mono p-4 rounded-lg text-[10px] h-60 overflow-y-auto whitespace-pre-wrap border border-dashed border-zinc-800 leading-relaxed">
|
||||
{smtpTestLog}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{smtpTestSuccess === true && (
|
||||
<div className="p-3 rounded-lg border border-dashed border-emerald-500/30 bg-emerald-500/5 text-emerald-500 text-xs">
|
||||
测试成功!邮件已顺利发出。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{smtpTestSuccess === false && (
|
||||
<div className="p-3 rounded-lg border border-dashed border-rose-500/30 bg-rose-500/5 text-rose-500 text-xs break-all">
|
||||
测试失败:{smtpTestError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0 border-t border-dashed pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSmtpTestOpen(false)}
|
||||
disabled={testSmtpMutation.isPending}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={testSmtpMutation.isPending}
|
||||
>
|
||||
{testSmtpMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
测试中...
|
||||
</>
|
||||
) : (
|
||||
"开始测试"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
"use client"
|
||||
|
||||
import {useState} from "react"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {FileText, Loader2, Pencil, Plus, Trash2} from "lucide-react"
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Textarea} from "@/components/ui/textarea"
|
||||
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
||||
import services from "@/lib/services"
|
||||
import type {Template} from "@/lib/services/admin/types"
|
||||
import {toast} from "sonner"
|
||||
|
||||
export function TemplatesManager() {
|
||||
const queryClient = useQueryClient()
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null)
|
||||
|
||||
// Form states
|
||||
const [key, setKey] = useState("")
|
||||
const [name, setName] = useState("")
|
||||
const [type, setType] = useState("email")
|
||||
const [subject, setSubject] = useState("")
|
||||
const [content, setContent] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
|
||||
const templatesQuery = useQuery({
|
||||
queryKey: ["admin", "templates"],
|
||||
queryFn: () => services.adminTemplate.listTemplates(),
|
||||
})
|
||||
|
||||
const createTemplateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await services.adminTemplate.createTemplate({
|
||||
key,
|
||||
name,
|
||||
type,
|
||||
subject,
|
||||
content,
|
||||
description,
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "templates"] })
|
||||
toast.success("通知模板已成功创建")
|
||||
setModalOpen(false)
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "创建模板失败")
|
||||
},
|
||||
})
|
||||
|
||||
const updateTemplateMutation = useMutation({
|
||||
mutationFn: async (key: string) => {
|
||||
await services.adminTemplate.updateTemplate(key, {
|
||||
name,
|
||||
type,
|
||||
subject,
|
||||
content,
|
||||
description,
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "templates"] })
|
||||
toast.success("通知模板已成功保存")
|
||||
setModalOpen(false)
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "修改模板失败")
|
||||
},
|
||||
})
|
||||
|
||||
const deleteTemplateMutation = useMutation({
|
||||
mutationFn: async (key: string) => {
|
||||
await services.adminTemplate.deleteTemplate(key)
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "templates"] })
|
||||
toast.success("通知模板已删除")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "删除模板失败")
|
||||
},
|
||||
})
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setSelectedTemplate(null)
|
||||
setKey("")
|
||||
setName("")
|
||||
setType("email")
|
||||
setSubject("")
|
||||
setContent("")
|
||||
setDescription("")
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const handleOpenEdit = (tmpl: Template) => {
|
||||
setSelectedTemplate(tmpl)
|
||||
setKey(tmpl.key)
|
||||
setName(tmpl.name)
|
||||
setType(tmpl.type)
|
||||
setSubject(tmpl.subject || "")
|
||||
setContent(tmpl.content)
|
||||
setDescription(tmpl.description || "")
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (selectedTemplate) {
|
||||
updateTemplateMutation.mutate(selectedTemplate.key)
|
||||
} else {
|
||||
createTemplateMutation.mutate()
|
||||
}
|
||||
}
|
||||
|
||||
const isPending = createTemplateMutation.isPending || updateTemplateMutation.isPending
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4 flex flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<FileText className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">通知模板管理</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
配置与编辑系统各类场景的通知邮件/短信模板,支持动态变量渲染
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleOpenCreate}
|
||||
variant="secondary"
|
||||
>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
新增模板
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
{templatesQuery.isPending ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground/50" />
|
||||
</div>
|
||||
) : (templatesQuery.data ?? []).length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{(templatesQuery.data ?? []).map((tmpl) => (
|
||||
<div
|
||||
key={tmpl.id}
|
||||
className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-sm text-foreground">{tmpl.name}</span>
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full border font-medium ${
|
||||
tmpl.is_system
|
||||
? "bg-indigo-500/10 text-indigo-500 border-indigo-500/20"
|
||||
: "bg-amber-500/10 text-amber-500 border-amber-500/20"
|
||||
}`}>
|
||||
{tmpl.is_system ? "系统内置" : "自定义"}
|
||||
</span>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border/50 bg-muted/50 text-muted-foreground font-mono">
|
||||
{tmpl.type.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
标识符: <span className="font-mono text-indigo-500 bg-indigo-500/5 px-1.5 py-0.5 rounded">{tmpl.key}</span>
|
||||
{tmpl.subject && ` · 主题: ${tmpl.subject}`}
|
||||
</div>
|
||||
{tmpl.description && (
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed max-w-xl">
|
||||
{tmpl.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground hover:text-indigo-500 hover:bg-indigo-500/10 rounded-lg transition-colors"
|
||||
onClick={() => handleOpenEdit(tmpl)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 rounded-lg transition-colors"
|
||||
disabled={tmpl.is_system || deleteTemplateMutation.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(`确定删除模板「${tmpl.name}」吗?`)) {
|
||||
deleteTemplateMutation.mutate(tmpl.key)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border/50 px-4 py-8 text-center text-xs text-muted-foreground bg-muted/5 flex flex-col items-center justify-center gap-3">
|
||||
<span>暂无配置的通知模板,点击上方按钮新增</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleOpenCreate}
|
||||
className="border-dashed"
|
||||
>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
新增模板
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<DialogContent className="max-w-xl border border-dashed">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-semibold">
|
||||
{selectedTemplate ? "编辑通知模板" : "新增通知模板"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
配置模板渲染逻辑,支持 Go `text/template` 语法(例如 `{"{{.Code}}"}` 表示验证码变量)。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tmpl_key" className="text-xs font-semibold">
|
||||
模板标识符 (Key)
|
||||
</Label>
|
||||
<Input
|
||||
id="tmpl_key"
|
||||
type="text"
|
||||
required
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
placeholder="例如: login_email"
|
||||
className="bg-card border-dashed text-xs"
|
||||
disabled={!!selectedTemplate}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||
模板的唯一标识,在代码中通过此 Key 调用。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tmpl_name" className="text-xs font-semibold">
|
||||
模板名称
|
||||
</Label>
|
||||
<Input
|
||||
id="tmpl_name"
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="例如: 登录验证码邮件"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||
用于后台识别该模板的可读名称。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tmpl_type" className="text-xs font-semibold">
|
||||
模板类型
|
||||
</Label>
|
||||
<Input
|
||||
id="tmpl_type"
|
||||
type="text"
|
||||
required
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
placeholder="email"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||
模板分类标识,目前支持 `email`。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tmpl_subject" className="text-xs font-semibold">
|
||||
模板主题 (Subject)
|
||||
</Label>
|
||||
<Input
|
||||
id="tmpl_subject"
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder="例如: OpenFlare 登录验证码"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||
邮件标题(类型为 email 时生效)。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tmpl_description" className="text-xs font-semibold">
|
||||
模板说明与变量描述
|
||||
</Label>
|
||||
<Input
|
||||
id="tmpl_description"
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="例如: 包含变量:{{.Code}},5分钟内有效"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tmpl_content" className="text-xs font-semibold">
|
||||
模板正文内容 (Content)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="tmpl_content"
|
||||
required
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="<h3>正文标题</h3><p>内容段落,可用变量 {{.Code}}</p>"
|
||||
rows={8}
|
||||
className="bg-card border-dashed text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0 border-t border-dashed pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setModalOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
"保存配置"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
FileArchive,
|
||||
FileAudio,
|
||||
FileImage,
|
||||
FileText,
|
||||
FileVideo,
|
||||
Globe,
|
||||
Loader2,
|
||||
Lock,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
Upload
|
||||
} from "lucide-react"
|
||||
import {toast} from "sonner"
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Card, CardContent} from "@/components/ui/card"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
|
||||
import {FileImagePreview} from "@/components/common/file-image-preview"
|
||||
import services, {formatFileSize} from "@/lib/services"
|
||||
import type {Upload as UploadRecord} from "@/lib/services/upload/types"
|
||||
|
||||
/* ─── 工具函数 ─────────────────────────────────────────── */
|
||||
|
||||
function getFileIcon(mimeType: string, className = "size-10") {
|
||||
if (mimeType.startsWith("image/")) return <FileImage className={`${className} text-blue-400`} />
|
||||
if (mimeType.startsWith("video/")) return <FileVideo className={`${className} text-purple-400`} />
|
||||
if (mimeType.startsWith("audio/")) return <FileAudio className={`${className} text-green-400`} />
|
||||
if (mimeType.includes("zip") || mimeType.includes("tar") || mimeType.includes("gzip"))
|
||||
return <FileArchive className={`${className} text-amber-400`} />
|
||||
return <FileText className={`${className} text-slate-400`} />
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
export function UserFileManager() {
|
||||
const queryClient = useQueryClient()
|
||||
const [keyword, setKeyword] = React.useState("")
|
||||
const [debouncedKeyword, setDebouncedKeyword] = React.useState("")
|
||||
const [page, setPage] = React.useState(1)
|
||||
const [uploading, setUploading] = React.useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<UploadRecord | null>(null)
|
||||
const [uploadAccessMode, setUploadAccessMode] = React.useState<number>(0) // 默认私有
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
||||
const pageSize = 12 // 每页 12 个卡片更均衡(3x4 或 4x3 布局)
|
||||
|
||||
// 搜索防抖
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedKeyword(keyword)
|
||||
setPage(1)
|
||||
}, 400)
|
||||
return () => clearTimeout(timer)
|
||||
}, [keyword])
|
||||
|
||||
// 我的文件列表查询
|
||||
const { data, isPending } = useQuery({
|
||||
queryKey: ["user-files", page, pageSize, debouncedKeyword],
|
||||
queryFn: () => services.upload.listMyUploads(page, pageSize, debouncedKeyword || undefined),
|
||||
})
|
||||
|
||||
const files = data?.items ?? []
|
||||
const total = data?.total ?? 0
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
|
||||
// 上传文件 Mutation
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
return services.upload.uploadFile(file, "generic", undefined, uploadAccessMode)
|
||||
},
|
||||
onMutate: () => {
|
||||
setUploading(true)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("文件上传成功")
|
||||
void queryClient.invalidateQueries({ queryKey: ["user-files"] })
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(err.message || "上传失败")
|
||||
},
|
||||
onSettled: () => {
|
||||
setUploading(false)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 删除文件 Mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => services.upload.deleteMyFile(id),
|
||||
onSuccess: () => {
|
||||
toast.success("文件已成功删除")
|
||||
void queryClient.invalidateQueries({ queryKey: ["user-files"] })
|
||||
setDeleteTarget(null)
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(err.message || "删除失败")
|
||||
}
|
||||
})
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
uploadMutation.mutate(file)
|
||||
}
|
||||
}
|
||||
|
||||
const triggerUploadClick = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleDownload = (file: UploadRecord) => {
|
||||
const url = services.upload.getDownloadUrl(file.id)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = file.file_name
|
||||
a.click()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* 操作栏:搜索、权限选择、上传按钮 */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索文件名..."
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 self-end sm:self-auto">
|
||||
{/* 上传权限设置 */}
|
||||
<div className="flex items-center gap-1.5 border rounded-lg p-1 bg-muted/40 h-9 text-xs">
|
||||
<Button
|
||||
variant={uploadAccessMode === 0 ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs font-normal"
|
||||
onClick={() => setUploadAccessMode(0)}
|
||||
>
|
||||
<Lock className="mr-1 size-3 text-amber-500" />
|
||||
私有
|
||||
</Button>
|
||||
<Button
|
||||
variant={uploadAccessMode === 1 ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs font-normal"
|
||||
onClick={() => setUploadAccessMode(1)}
|
||||
>
|
||||
<Globe className="mr-1 size-3 text-sky-500" />
|
||||
公开
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
disabled={uploading}
|
||||
/>
|
||||
<Button
|
||||
onClick={triggerUploadClick}
|
||||
disabled={uploading}
|
||||
className="h-9 gap-1.5 text-xs font-medium"
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Plus className="size-3.5" />
|
||||
)}
|
||||
{uploading ? "正在上传..." : "上传文件"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文件展示区 */}
|
||||
{isPending ? (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Loader2 className="size-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<Card className="border border-dashed py-16 flex flex-col items-center justify-center text-center">
|
||||
<CardContent className="flex flex-col items-center gap-4">
|
||||
<div className="p-4 bg-muted rounded-full text-muted-foreground/60">
|
||||
<Upload className="size-10" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold text-sm">还没有上传文件</h3>
|
||||
<p className="text-xs text-muted-foreground max-w-xs">
|
||||
{debouncedKeyword ? "未搜索到匹配的文件" : "上传您的第一个文件,支持图片、视频、文档和压缩包等格式。"}
|
||||
</p>
|
||||
</div>
|
||||
{!debouncedKeyword && (
|
||||
<Button onClick={triggerUploadClick} disabled={uploading} size="sm" className="mt-2 text-xs">
|
||||
选择文件上传
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{files.map((file) => (
|
||||
<Card
|
||||
key={file.id}
|
||||
className="group border border-border/40 hover:border-border/80 bg-card/60 hover:bg-card/95 transition-all duration-300 shadow-xs hover:shadow-md overflow-hidden relative flex flex-col h-full"
|
||||
>
|
||||
{/* 文件预览/图标区 */}
|
||||
<div className="h-36 bg-muted/30 border-b border-dashed relative flex items-center justify-center overflow-hidden p-2 group-hover:bg-muted/10 transition-colors">
|
||||
{file.mime_type.startsWith("image/") ? (
|
||||
<FileImagePreview
|
||||
fileId={file.id}
|
||||
alt={file.file_name}
|
||||
quality="medium"
|
||||
className="max-h-full max-w-full object-contain rounded-md shadow-xs transition-transform duration-350 group-hover:scale-103"
|
||||
fallbackClassName="min-h-full w-full rounded-md"
|
||||
/>
|
||||
) : (
|
||||
getFileIcon(file.mime_type)
|
||||
)}
|
||||
|
||||
{/* 访问权限 Badge */}
|
||||
<div className="absolute top-2 right-2">
|
||||
{file.access_mode === 0 ? (
|
||||
<Badge variant="secondary" className="bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[10px] gap-1 px-1.5 py-0 rounded-md border border-amber-500/20 font-normal">
|
||||
<Lock className="size-2.5" />
|
||||
私有
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="bg-sky-500/10 text-sky-600 dark:text-sky-400 text-[10px] gap-1 px-1.5 py-0 rounded-md border border-sky-500/20 font-normal">
|
||||
<Globe className="size-2.5" />
|
||||
公开
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文件详情区 */}
|
||||
<CardContent className="p-4 flex-1 flex flex-col justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-semibold text-xs text-foreground truncate select-all" title={file.file_name}>
|
||||
{file.file_name}
|
||||
</h4>
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground font-mono">
|
||||
<span>{formatFileSize(file.file_size)}</span>
|
||||
<span className="truncate max-w-[120px]" title={file.mime_type}>{file.mime_type}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-1 border-t border-dashed">
|
||||
<span className="text-[9px] text-muted-foreground/80">{formatDate(file.created_at)}</span>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
title="下载文件"
|
||||
onClick={() => handleDownload(file)}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||
title="删除文件"
|
||||
onClick={() => setDeleteTarget(file)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 pt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-dashed text-xs h-8 px-3"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
<ChevronLeft className="mr-1 size-3.5" />
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground font-medium px-2">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-dashed text-xs h-8 px-3"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
下一页
|
||||
<ChevronRight className="ml-1 size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除确认 Dialog */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialogContent suppressHydrationWarning>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除文件</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除文件{" "}
|
||||
<span className="font-semibold text-foreground">「{deleteTarget?.file_name}」</span>{" "}
|
||||
吗?删除后此文件将无法恢复。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
|
||||
>
|
||||
{deleteMutation.isPending && <Loader2 className="mr-1.5 size-3.5 animate-spin" />}
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo} from 'react';
|
||||
import type {EChartsOption} from 'echarts';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
|
||||
type RankChartItem = {
|
||||
label: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
type RankChartProps = {
|
||||
items: RankChartItem[];
|
||||
color: string;
|
||||
valueFormatter?: (value: number) => string;
|
||||
emptyMessage?: string;
|
||||
};
|
||||
|
||||
const defaultFormatter = (value: number) => value.toLocaleString('zh-CN');
|
||||
|
||||
function getChartValue(params: unknown) {
|
||||
if (typeof params !== 'object' || params === null || !('value' in params)) {
|
||||
return 0;
|
||||
}
|
||||
const rawValue = (params as {value?: unknown}).value;
|
||||
if (Array.isArray(rawValue)) {
|
||||
const candidate = rawValue[0];
|
||||
return typeof candidate === 'number' ? candidate : 0;
|
||||
}
|
||||
return typeof rawValue === 'number' ? rawValue : 0;
|
||||
}
|
||||
|
||||
export function RankChart({
|
||||
items,
|
||||
color,
|
||||
valueFormatter = defaultFormatter,
|
||||
emptyMessage = '暂无分布数据',
|
||||
}: RankChartProps) {
|
||||
const option = useMemo<EChartsOption>(
|
||||
() => ({
|
||||
animationDuration: 400,
|
||||
grid: {
|
||||
left: 16,
|
||||
right: 24,
|
||||
top: 12,
|
||||
bottom: 12,
|
||||
containLabel: true,
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow',
|
||||
},
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.92)',
|
||||
borderWidth: 0,
|
||||
textStyle: {
|
||||
color: '#e2e8f0',
|
||||
fontSize: 12,
|
||||
},
|
||||
formatter: (params: unknown) => {
|
||||
const item = Array.isArray(params) ? params[0] : params;
|
||||
const data = item as {name?: string; value?: number};
|
||||
return `${data.name ?? ''}<br/>${valueFormatter(data.value ?? 0)}`;
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
color: '#94a3b8',
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: 'rgba(148, 163, 184, 0.16)',
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: items.map((item) => item.label),
|
||||
axisTick: {show: false},
|
||||
axisLine: {show: false},
|
||||
axisLabel: {
|
||||
color: '#cbd5e1',
|
||||
width: 120,
|
||||
overflow: 'truncate',
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: items.map((item) => item.value),
|
||||
barWidth: 12,
|
||||
showBackground: true,
|
||||
backgroundStyle: {
|
||||
color: 'rgba(148, 163, 184, 0.12)',
|
||||
borderRadius: 999,
|
||||
},
|
||||
itemStyle: {
|
||||
color,
|
||||
borderRadius: 999,
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
color: '#e2e8f0',
|
||||
formatter: (params: unknown) => valueFormatter(getChartValue(params)),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
[color, items, valueFormatter],
|
||||
);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="flex h-[220px] items-center justify-center rounded-3xl border border-dashed bg-muted/30 text-sm text-muted-foreground">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactECharts
|
||||
option={option}
|
||||
notMerge
|
||||
lazyUpdate
|
||||
style={{height: Math.max(220, items.length * 44), width: '100%'}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo} from 'react';
|
||||
import type {EChartsOption} from 'echarts';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
|
||||
import {calculateNiceAxisMax, formatCompactNumber} from '@/lib/utils/metrics';
|
||||
|
||||
type TrendChartSeries = {
|
||||
label: string;
|
||||
color: string;
|
||||
fillColor?: string;
|
||||
values: number[];
|
||||
variant?: 'line' | 'area';
|
||||
valueFormatter?: (value: number) => string;
|
||||
};
|
||||
|
||||
type TrendChartProps = {
|
||||
labels: string[];
|
||||
series: TrendChartSeries[];
|
||||
height?: number;
|
||||
yAxisValueFormatter?: (value: number) => string;
|
||||
};
|
||||
|
||||
type TooltipParam = {
|
||||
axisValueLabel?: string;
|
||||
color?: string;
|
||||
seriesName?: string;
|
||||
value?: number | string | Array<number | string>;
|
||||
};
|
||||
|
||||
const defaultFormatter = (value: number) => formatCompactNumber(value);
|
||||
|
||||
export function TrendChart({
|
||||
labels,
|
||||
series,
|
||||
height = 220,
|
||||
yAxisValueFormatter,
|
||||
}: TrendChartProps) {
|
||||
const option = useMemo<EChartsOption>(() => {
|
||||
const axisFormatter = yAxisValueFormatter ?? defaultFormatter;
|
||||
const maxValue = calculateNiceAxisMax(series.flatMap((item) => item.values));
|
||||
|
||||
return {
|
||||
animationDuration: 500,
|
||||
animationEasing: 'cubicOut',
|
||||
grid: {
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 20,
|
||||
bottom: 20,
|
||||
containLabel: true,
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.92)',
|
||||
borderWidth: 0,
|
||||
textStyle: {
|
||||
color: '#e2e8f0',
|
||||
fontSize: 12,
|
||||
},
|
||||
formatter: (params: unknown) => {
|
||||
const items = Array.isArray(params) ? (params as TooltipParam[]) : [];
|
||||
if (items.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const header = items[0]?.axisValueLabel ?? '';
|
||||
const rows = items.map((item) => {
|
||||
const matchedSeries = series.find(
|
||||
(seriesItem) => seriesItem.label === item.seriesName,
|
||||
);
|
||||
const formatter =
|
||||
matchedSeries?.valueFormatter ??
|
||||
yAxisValueFormatter ??
|
||||
defaultFormatter;
|
||||
const rawValue = Array.isArray(item.value) ? item.value[1] : item.value;
|
||||
const numericValue =
|
||||
typeof rawValue === 'number' ? rawValue : Number(rawValue ?? 0);
|
||||
|
||||
return [
|
||||
'<span style="display:inline-flex;align-items:center;gap:8px;">',
|
||||
`<span style="display:inline-block;width:8px;height:8px;border-radius:9999px;background:${item.color ?? '#94a3b8'};"></span>`,
|
||||
`<span>${item.seriesName ?? ''}</span>`,
|
||||
`<strong style="margin-left:8px;">${formatter(numericValue)}</strong>`,
|
||||
'</span>',
|
||||
].join('');
|
||||
});
|
||||
|
||||
return [header, ...rows].join('<br/>');
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: labels,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: 'rgba(148, 163, 184, 0.24)',
|
||||
},
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#94a3b8',
|
||||
margin: 14,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: maxValue,
|
||||
splitNumber: 4,
|
||||
axisLabel: {
|
||||
color: '#94a3b8',
|
||||
formatter: (value: number) => axisFormatter(value),
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: 'rgba(148, 163, 184, 0.16)',
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
},
|
||||
series: series.map((item) => ({
|
||||
name: item.label,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
lineStyle: {
|
||||
color: item.color,
|
||||
width: 3,
|
||||
},
|
||||
itemStyle: {
|
||||
color: item.color,
|
||||
},
|
||||
areaStyle:
|
||||
item.variant === 'area'
|
||||
? {
|
||||
color: item.fillColor ?? `${item.color}33`,
|
||||
}
|
||||
: undefined,
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
scale: true,
|
||||
},
|
||||
data: item.values,
|
||||
})),
|
||||
};
|
||||
}, [labels, series, yAxisValueFormatter]);
|
||||
|
||||
if (labels.length === 0 || series.length === 0) {
|
||||
return (
|
||||
<div className="flex h-[220px] items-center justify-center rounded-3xl border border-dashed bg-muted/30 text-sm text-muted-foreground">
|
||||
暂无趋势数据
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{series.map((item) => {
|
||||
const latestValue = item.values[item.values.length - 1] ?? 0;
|
||||
const formatter = item.valueFormatter ?? defaultFormatter;
|
||||
return (
|
||||
<div
|
||||
key={item.label}
|
||||
className="min-w-[140px] rounded-2xl border bg-card px-4 py-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{backgroundColor: item.color}}
|
||||
/>
|
||||
<p className="text-xs tracking-[0.18em] text-muted-foreground uppercase">
|
||||
{item.label}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-2 text-lg font-semibold">
|
||||
{formatter(latestValue)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[28px] border bg-linear-to-b from-white/3 to-transparent px-4 py-4 dark:from-white/3">
|
||||
<ReactECharts
|
||||
option={option}
|
||||
notMerge
|
||||
lazyUpdate
|
||||
style={{height, width: '100%'}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {motion} from "motion/react";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Book, Check, Copy, Terminal} from "lucide-react";
|
||||
|
||||
export interface DeveloperSectionProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Developer Section - 开发者体验展示
|
||||
*/
|
||||
export const DeveloperSection = React.memo(function DeveloperSection({ className }: DeveloperSectionProps) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const codeContent = `# 注册用户
|
||||
curl -X POST https://api.example.com/api/v1/auth/register \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"username": "developer",
|
||||
"email": "dev@example.com",
|
||||
"password": "secure_password"
|
||||
}'`;
|
||||
|
||||
const onCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(codeContent);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = codeContent;
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.left = "-9999px";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textArea);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={cn("relative z-10 w-full min-h-screen flex items-center justify-center px-6 overflow-hidden", className)}>
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div className="absolute inset-0 [mask-image:linear-gradient(to_bottom,transparent,black_20%,black_80%,transparent)]">
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] max-w-[90vw] max-h-[90vh] bg-purple-500/10 rounded-full blur-[120px] animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="container mx-auto max-w-7xl grid lg:grid-cols-2 gap-12 lg:gap-20 items-center relative z-10">
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="order-2 lg:order-1 relative"
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-xl border border-white/20 bg-black backdrop-blur-xl shadow-2xl max-w-full">
|
||||
<div className="flex items-center px-4 py-3 border-b border-white/20 w-full">
|
||||
<div className="flex-1 flex gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-[#ff5f56]" />
|
||||
<div className="w-3 h-3 rounded-full bg-[#ffbd2e]" />
|
||||
<div className="w-3 h-3 rounded-full bg-[#27c93f]" />
|
||||
</div>
|
||||
<div className="flex-none text-xs text-muted-foreground font-mono flex items-center gap-1">
|
||||
<Terminal className="w-3 h-3" />
|
||||
bash
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-x-auto relative group">
|
||||
<div className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-neutral-400 hover:text-white" onClick={onCopy}>
|
||||
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="text-xs sm:text-sm font-mono text-neutral-300 leading-relaxed whitespace-pre-wrap break-all sm:whitespace-pre sm:break-normal overflow-x-auto">
|
||||
<code className="block">
|
||||
<span className="text-green-400"># 快速开始</span>{'\n'}
|
||||
<span className="text-purple-400">curl</span> -X <span className="text-yellow-400">POST</span> <span className="text-green-400">https://api.example.com/api/v1/auth/register</span> \{'\n'}
|
||||
{' '}-H <span className="text-blue-400">"Content-Type: application/json"</span> \{'\n'}
|
||||
{' '}-d <span className="text-orange-400">"username=dev&password=***"</span>
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute -z-10 -bottom-10 -right-10 w-40 h-40 bg-blue-500/20 rounded-full blur-3xl animate-pulse" />
|
||||
</motion.div>
|
||||
|
||||
<div className="flex flex-col justify-center space-y-8 order-1 lg:order-2">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<h2 className="text-3xl md:text-5xl font-bold tracking-tight text-foreground leading-[1.1] mb-6">
|
||||
开发者友好,<br />
|
||||
极速集成上手
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-lg leading-relaxed mb-8">
|
||||
标准化的 RESTful API 接口,完整的 TypeScript 类型定义,详细的文档和 Swagger UI。无论使用什么编程语言,只需几行代码,即可快速集成。
|
||||
</p>
|
||||
|
||||
<ul className="space-y-4 mb-8">
|
||||
{[
|
||||
"RESTful API,语义清晰,易于理解",
|
||||
"OpenAPI / Swagger 接口文档",
|
||||
"TypeScript 类型定义,开发友好",
|
||||
"丰富的代码示例和集成指南"
|
||||
].map((item, i) => (
|
||||
<li key={i} className="flex items-center gap-3 text-sm text-foreground/80">
|
||||
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Link href="/docs/api">
|
||||
<Button variant="secondary" className="rounded-full text-xs hover:bg-muted-foreground/10">
|
||||
<Book className="w-3 h-3" />
|
||||
API 文档
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import * as React from "react";
|
||||
import {cn} from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
import {Github, LucideIcon} from "lucide-react";
|
||||
|
||||
export interface FooterSectionProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Footer Section - 页脚
|
||||
*/
|
||||
export const FooterSection = React.memo(function FooterSection({ className }: FooterSectionProps) {
|
||||
return (
|
||||
<footer className={cn("relative z-10 w-full bg-transparent border-t border-white/10 mt-0 backdrop-blur-sm", className)}>
|
||||
<div className="container mx-auto max-w-7xl px-6 py-20 lg:py-32">
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-12 lg:gap-8 mb-20">
|
||||
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<div className="w-10 h-10 p-2 rounded bg-primary text-sm text-primary-foreground flex items-center justify-center font-bold">
|
||||
OF
|
||||
</div>
|
||||
<span className="text-2xl font-bold tracking-tight text-foreground">OpenFlare</span>
|
||||
</Link>
|
||||
<p className="text-muted-foreground text-base leading-relaxed max-w-sm">
|
||||
开源边缘节点与反向代理管理平台。基于 Go + React 构建,统一管理 OpenResty 节点、网站规则与 WAF 配置。
|
||||
</p>
|
||||
<div className="flex gap-4 pt-2">
|
||||
<SocialLink icon={Github} href="https://github.com/Rain-kl/OpenFlare/" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1">
|
||||
<h3 className="font-semibold text-foreground mb-6">产品</h3>
|
||||
<ul className="space-y-4 text-sm text-muted-foreground">
|
||||
<li><FooterLink href="/">仪表盘</FooterLink></li>
|
||||
<li><FooterLink href="/settings">个人设置</FooterLink></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1">
|
||||
<h3 className="font-semibold text-foreground mb-6">开发</h3>
|
||||
<ul className="space-y-4 text-sm text-muted-foreground">
|
||||
<li><FooterLink href="/docs/how-to-use">快速开始</FooterLink></li>
|
||||
<li><FooterLink href="/docs/api">API 文档</FooterLink></li>
|
||||
<li><FooterLink href="https://github.com/Rain-kl/OpenFlare">源代码</FooterLink></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1">
|
||||
<h3 className="font-semibold text-foreground mb-6">社区</h3>
|
||||
<ul className="space-y-4 text-sm text-muted-foreground">
|
||||
<li><FooterLink href="https://github.com/Rain-kl/OpenFlare/issues">GitHub Issues</FooterLink></li>
|
||||
<li><FooterLink href="https://github.com/Rain-kl/OpenFlare/discussions">讨论</FooterLink></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-8 border-t border-border flex flex-col md:flex-row justify-between items-center gap-4 text-sm text-muted-foreground">
|
||||
<p>© 2026 Modern Platform. All rights reserved.</p>
|
||||
<div className="flex gap-8">
|
||||
<Link href="/docs/privacy-policy" className="hover:text-foreground transition-colors">隐私政策</Link>
|
||||
<Link href="/docs/terms-of-service" className="hover:text-foreground transition-colors">服务条款</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-0 left-0 w-full overflow-hidden pointer-events-none opacity-[0.02]">
|
||||
<div className="text-[12vw] 2xl:text-[180px] font-black leading-none text-foreground whitespace-nowrap select-none text-center transform translate-y-1/3 transition-all duration-700">
|
||||
Modern Platform
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
});
|
||||
|
||||
function SocialLink({ icon: Icon, href }: { icon: LucideIcon, href: string }) {
|
||||
return (
|
||||
<Link href={href} className="w-10 h-10 rounded-full bg-muted/50 flex items-center justify-center text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-all duration-300">
|
||||
<Icon className="w-5 h-5" />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function FooterLink({ href, children }: { href: string, children: React.ReactNode }) {
|
||||
return (
|
||||
<Link href={href} className="hover:text-foreground transition-colors flex items-center group">
|
||||
<span className="relative">
|
||||
{children}
|
||||
<span className="absolute left-0 -bottom-0.5 w-0 h-px bg-foreground transition-all duration-300 group-hover:w-full" />
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import {motion} from "motion/react";
|
||||
import {ArrowRight, CreditCard, Globe, Shield, Zap} from "lucide-react";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {Button} from "@/components/ui/button";
|
||||
|
||||
export interface HeroSectionProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hero Section - 首页 Hero 展示
|
||||
*/
|
||||
export const HeroSection = React.memo(function HeroSection({ className }: HeroSectionProps) {
|
||||
|
||||
return (
|
||||
<section className={cn("w-full", className)}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{
|
||||
delay: 0.3,
|
||||
duration: 0.8,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
className="relative z-10 w-full h-screen flex flex-col justify-center px-6"
|
||||
>
|
||||
<div className="container mx-auto max-w-7xl grid lg:grid-cols-2 gap-12 lg:gap-20 items-center">
|
||||
|
||||
<div className="max-w-5xl pt-20 lg:pt-0">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.1, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="text-4xl md:text-5xl lg:text-6xl font-extrabold tracking-tight leading-[1.1] mb-6 text-foreground"
|
||||
>
|
||||
Modern Platform <br />
|
||||
<span className="bg-clip-text text-primary">
|
||||
为二次开发而生
|
||||
</span>
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="text-sm md:text-base text-muted-foreground max-w-xl leading-relaxed mb-10"
|
||||
>
|
||||
通用的、现代化的后台管理系统脚手架
|
||||
<br className="hidden md:block" />
|
||||
开箱即用、完整基建、极易扩展,助您快速构建企业级应用。
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="flex flex-col sm:flex-row items-center gap-4"
|
||||
>
|
||||
<Link href="/" className="w-full sm:w-auto">
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full rounded-full bg-primary hover:bg-primary/90 font-medium transition-all active:scale-95"
|
||||
>
|
||||
立即开始
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/docs/how-to-use" className="w-full sm:w-auto">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
className="w-full rounded-full font-medium active:scale-95"
|
||||
>
|
||||
了解更多
|
||||
</Button>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.8, delay: 0.5 }}
|
||||
className="mt-16 flex flex-wrap gap-8 text-sm font-medium text-muted-foreground border-t border-border/50 pt-8"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-yellow-500" />
|
||||
<span>开箱即用</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-5 h-5 text-blue-500" />
|
||||
<span>高度可扩展</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-green-500" />
|
||||
<span>工业级基建</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block relative h-full min-h-[500px] w-full">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 50, rotate: -5 }}
|
||||
whileInView={{ opacity: 1, x: 0, rotate: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 1, delay: 0.2, ease: "easeOut" }}
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md aspect-square"
|
||||
>
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-purple-500/30 rounded-full blur-3xl animate-pulse" />
|
||||
<div className="absolute bottom-0 left-0 w-64 h-64 bg-blue-500/30 rounded-full blur-3xl animate-pulse delay-75" />
|
||||
|
||||
<div className="relative z-10 w-full h-64 bg-background/40 backdrop-blur-2xl border border-white/10 rounded-3xl shadow-2xl p-6 flex flex-col justify-between transform transition-transform hover:scale-[1.02] duration-500">
|
||||
<div className="flex gap-4 items-center">
|
||||
<CreditCard className="size-6" />
|
||||
<span>Modern Platform</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-muted-foreground mb-2">Stack</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="inline-block px-2 py-1 bg-blue-500/10 text-blue-500 rounded text-xs font-medium">Go • Gin</span>
|
||||
<span className="inline-block px-2 py-1 bg-purple-500/10 text-purple-500 rounded text-xs font-medium">React • Next.js</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">Ready for</p>
|
||||
<div className="text-lg font-bold tracking-tight text-foreground flex items-center gap-1">
|
||||
<span>Develop</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-8 w-8 rounded-full bg-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
animate={{ y: [0, -20, 0] }}
|
||||
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}
|
||||
className="absolute -top-6 -right-6 z-20 bg-background/60 backdrop-blur-xl border border-white/20 p-4 rounded-2xl shadow-xl"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full flex items-center justify-center bg-emerald-500/20 text-emerald-500">
|
||||
<Zap className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Features</p>
|
||||
<div className="text-sm font-bold">
|
||||
<span>Modern</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 圣诞节主题装饰组件归档
|
||||
* 包含圣诞树、圣诞帽图标以及下雪效果的集成组件
|
||||
*
|
||||
* 使用示例:
|
||||
* ```tsx
|
||||
* import { ChristmasDecorations } from "@/components/layout/avater-style/Christmas"
|
||||
*
|
||||
* function MyComponent() {
|
||||
* const [showChristmas, setShowChristmas] = useState(false)
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* {showChristmas && <ChristmasDecorations.SnowEffect />}
|
||||
* <div onClick={() => setShowChristmas(!showChristmas)}>
|
||||
* <ChristmasDecorations.Hat className="w-8 h-8" />
|
||||
* <ChristmasDecorations.Tree
|
||||
* className="w-12 h-12"
|
||||
* show={showChristmas}
|
||||
* />
|
||||
* </div>
|
||||
* </>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
import {SnowEffect} from "@/components/ui/snow-effect"
|
||||
|
||||
/**
|
||||
* 圣诞树图标组件
|
||||
*/
|
||||
export const ChristmasTree = ({
|
||||
className,
|
||||
show = true
|
||||
}: {
|
||||
className?: string
|
||||
show?: boolean
|
||||
}) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn(
|
||||
"transition-all duration-500 ease-out",
|
||||
show ? "opacity-90 scale-100 translate-y-0" : "opacity-0 scale-50 translate-y-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<rect x="10" y="18" width="4" height="4" fill="#92400E" rx="0.5" />
|
||||
<path d="M12 4L8 9H16L12 4Z" fill="#15803D" />
|
||||
<path d="M12 8L7 14H17L12 8Z" fill="#16A34A" />
|
||||
<path d="M12 12L6 19H18L12 12Z" fill="#22C55E" />
|
||||
<circle cx="10" cy="10" r="0.8" fill="#EF4444" />
|
||||
<circle cx="14" cy="11" r="0.8" fill="#F59E0B" />
|
||||
<circle cx="9" cy="15" r="0.8" fill="#3B82F6" />
|
||||
<circle cx="15" cy="16" r="0.8" fill="#EF4444" />
|
||||
<circle cx="12" cy="14" r="0.8" fill="#F59E0B" />
|
||||
<path d="M12 2L12.5 3.5L14 4L12.5 4.5L12 6L11.5 4.5L10 4L11.5 3.5L12 2Z" fill="#FBBF24" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/**
|
||||
* 圣诞帽图标组件
|
||||
*/
|
||||
export const ChristmasHat = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M12 3C12 3 5 10 5 15C5 17.5 7 19 8 19H16C17 19 19 17.5 19 15C19 10 12 3 12 3Z"
|
||||
fill="#EF4444"
|
||||
stroke="#B91C1C"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="3" r="2.5" fill="white" stroke="#E5E7EB" strokeWidth="1.5" />
|
||||
<rect x="4" y="16" width="16" height="5" rx="2.5" fill="white" stroke="#E5E7EB" strokeWidth="1.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/**
|
||||
* 圣诞节装饰集合
|
||||
* 包含所有圣诞节主题组件的命名空间
|
||||
*/
|
||||
export const ChristmasDecorations = {
|
||||
Tree: ChristmasTree,
|
||||
Hat: ChristmasHat,
|
||||
SnowEffect: SnowEffect,
|
||||
}
|
||||
|
||||
export default ChristmasDecorations
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 元旦/新年主题装饰组件归档
|
||||
* 包含灯笼、福字以及烟花效果的集成组件
|
||||
*
|
||||
* 使用示例:
|
||||
* ```tsx
|
||||
* import { NewYearDecorations } from "@/components/layout/avater-style/NewYear"
|
||||
*
|
||||
* function MyComponent() {
|
||||
* const [showNewYear, setShowNewYear] = useState(false)
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* {showNewYear && <NewYearDecorations.FireworksEffect />}
|
||||
* <div onClick={() => setShowNewYear(!showNewYear)}>
|
||||
* <NewYearDecorations.Lantern className="w-8 h-8" />
|
||||
* <NewYearDecorations.Fu
|
||||
* className="w-12 h-12"
|
||||
* show={showNewYear}
|
||||
* />
|
||||
* </div>
|
||||
* </>
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
import {FireworksEffect} from "@/components/ui/fireworks-effect"
|
||||
|
||||
/**
|
||||
* 红灯笼组件 - 默认头像装饰
|
||||
*/
|
||||
export const Lantern = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
viewBox="0 0 1024 1024"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M512 80V160" stroke="#FFD700" strokeWidth="32" strokeLinecap="round" />
|
||||
|
||||
<ellipse cx="512" cy="420" rx="300" ry="260" fill="#D92424" />
|
||||
<ellipse cx="512" cy="420" rx="300" ry="260" stroke="#961C1C" strokeWidth="10" />
|
||||
|
||||
<path d="M340 180H684C700 180 712 192 712 208V220H312V208C312 192 324 180 340 180Z" fill="#FFD700" />
|
||||
<path d="M340 620H684C700 620 712 632 712 648V660H312V648C312 632 324 620 340 620Z" fill="#FFD700" />
|
||||
|
||||
<path d="M512 160C512 160 620 250 620 420C620 590 512 680 512 680" stroke="#FFD700" strokeWidth="20" strokeLinecap="round" strokeOpacity="0.4" />
|
||||
<path d="M512 160C512 160 404 250 404 420C404 590 512 680 512 680" stroke="#FFD700" strokeWidth="20" strokeLinecap="round" strokeOpacity="0.4" />
|
||||
<path d="M512 160V680" stroke="#FFD700" strokeWidth="20" strokeOpacity="0.4" />
|
||||
|
||||
<circle cx="512" cy="420" r="80" fill="#FFD700" fillOpacity="0.9" />
|
||||
<text x="512" y="450" fontSize="80" fill="#D92424" textAnchor="middle" fontWeight="bold" fontFamily="serif">福</text>
|
||||
|
||||
<path d="M512 660V720" stroke="#FFD700" strokeWidth="20" />
|
||||
<path d="M480 720H544" stroke="#D92424" strokeWidth="40" strokeLinecap="round" />
|
||||
|
||||
<path d="M492 740V900" stroke="#D92424" strokeWidth="16" strokeLinecap="round" />
|
||||
<path d="M512 740V940" stroke="#D92424" strokeWidth="16" strokeLinecap="round" />
|
||||
<path d="M532 740V900" stroke="#D92424" strokeWidth="16" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/**
|
||||
* "福"字挂件 - 点击出现的装饰
|
||||
* 一个菱形的福字牌,使用文字渲染确保准确
|
||||
*/
|
||||
export const FuCharacter = ({
|
||||
className,
|
||||
show = true
|
||||
}: {
|
||||
className?: string
|
||||
show?: boolean
|
||||
}) => (
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn(
|
||||
"transition-all duration-500 ease-out origin-center",
|
||||
show ? "opacity-100 scale-100 rotate-0" : "opacity-0 scale-50 rotate-[-15deg]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<path d="M50 0V15" stroke="#F59E0B" strokeWidth="2" />
|
||||
<g transform="rotate(25 50 50)">
|
||||
<rect x="20" y="20" width="60" height="60" rx="2" fill="#D92424" stroke="#F59E0B" strokeWidth="1.5" />
|
||||
<rect x="23" y="23" width="54" height="54" rx="1" stroke="#F59E0B" strokeWidth="1" strokeOpacity="0.5" strokeDasharray="4 2" fill="none" />
|
||||
|
||||
<text
|
||||
x="50"
|
||||
y="60"
|
||||
fontSize="32"
|
||||
fill="#FFD700"
|
||||
textAnchor="middle"
|
||||
fontWeight="bold"
|
||||
fontFamily="'YouYuan', 'Yuanti SC', 'STYuanti', 'HanyiYuanti', 'Comic Sans MS', sans-serif"
|
||||
style={{ letterSpacing: "0px" }}
|
||||
transform="rotate(130 50 50)"
|
||||
>
|
||||
福
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<path d="M50 85V95" stroke="#D92424" strokeWidth="3" />
|
||||
<circle cx="50" cy="95" r="2" fill="#F59E0B" />
|
||||
<path d="M50 95L47 100M50 95L53 100" stroke="#D92424" strokeWidth="1" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/**
|
||||
* 元旦/新年装饰集合
|
||||
*/
|
||||
export const NewYearDecorations = {
|
||||
Lantern,
|
||||
Fu: FuCharacter,
|
||||
FireworksEffect,
|
||||
}
|
||||
|
||||
export default NewYearDecorations
|
||||
@@ -0,0 +1,48 @@
|
||||
import {AvatarDecorationTheme} from "./types"
|
||||
import {ChristmasDecorations} from "./Christmas"
|
||||
import {NewYearDecorations} from "./NewYear"
|
||||
|
||||
export const DecorationThemes: Record<string, AvatarDecorationTheme> = {
|
||||
christmas: {
|
||||
key: "christmas",
|
||||
MainDecoration: ChristmasDecorations.Hat,
|
||||
InteractionDecoration: ChristmasDecorations.Tree,
|
||||
BackgroundEffect: ChristmasDecorations.SnowEffect,
|
||||
smallMainDecorationConfig: {
|
||||
className: "absolute -top-1 -left-1 w-3 h-3 z-20 rotate-[-20deg] drop-shadow-sm"
|
||||
},
|
||||
largeMainDecorationConfig: {
|
||||
className: "absolute -top-2 -left-1 w-5 h-5 z-20 rotate-[-20deg] drop-shadow-sm"
|
||||
},
|
||||
interactionDecorationConfig: {
|
||||
className: "absolute -top-2 right-2 w-12 h-12 z-0 rotate-[10deg] transition-all duration-500 ease-out"
|
||||
}
|
||||
},
|
||||
newyear: {
|
||||
key: "newyear",
|
||||
MainDecoration: NewYearDecorations.Lantern,
|
||||
InteractionDecoration: NewYearDecorations.Fu,
|
||||
BackgroundEffect: NewYearDecorations.FireworksEffect,
|
||||
smallMainDecorationConfig: {
|
||||
className: "absolute -top-2 -right-1 w-8 h-8 z-20 rotate-[10deg] drop-shadow-md pointer-events-none"
|
||||
},
|
||||
largeMainDecorationConfig: {
|
||||
className: "absolute -top-3 -right-3 w-8 h-8 z-20 rotate-[10deg] drop-shadow-md pointer-events-none"
|
||||
},
|
||||
interactionDecorationConfig: {
|
||||
className: "absolute top-6 left-4 w-10 h-10 z-20 -rotate-[5deg] transition-all duration-500 ease-out"
|
||||
}
|
||||
},
|
||||
none: {
|
||||
key: "none",
|
||||
MainDecoration: () => null,
|
||||
InteractionDecoration: () => null,
|
||||
}
|
||||
}
|
||||
|
||||
/* 可选值: 'christmas', 'newyear', 'none' */
|
||||
export const CURRENT_THEME_KEY: string = "none"
|
||||
|
||||
export const getCurrentTheme = (): AvatarDecorationTheme => {
|
||||
return DecorationThemes[CURRENT_THEME_KEY] || DecorationThemes.none
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {ComponentType} from "react"
|
||||
|
||||
export interface AvatarDecorationTheme {
|
||||
key: string
|
||||
MainDecoration: ComponentType<{ className?: string }>
|
||||
InteractionDecoration: ComponentType<{ className?: string; show?: boolean }>
|
||||
BackgroundEffect?: ComponentType
|
||||
smallMainDecorationConfig?: {
|
||||
className?: string
|
||||
}
|
||||
largeMainDecorationConfig?: {
|
||||
className?: string
|
||||
}
|
||||
interactionDecorationConfig?: {
|
||||
className?: string
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import * as React from "react"
|
||||
import {FileTextIcon} from "lucide-react"
|
||||
import {Button} from "@/components/ui/button"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
|
||||
/**
|
||||
* 空状态展示组件属性
|
||||
* 用于统一显示无数据、无内容等空状态
|
||||
*/
|
||||
export interface EmptyStateProps extends React.ComponentProps<"div"> {
|
||||
title?: string
|
||||
description?: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
actionText?: string
|
||||
onAction?: () => void
|
||||
iconSize?: "sm" | "md" | "lg"
|
||||
}
|
||||
|
||||
/**
|
||||
* 空状态展示组件
|
||||
* 用于统一显示无数据、无内容等空状态
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // 基础用法
|
||||
* <EmptyState
|
||||
* title="暂无数据"
|
||||
* description="当前没有任何记录"
|
||||
* />
|
||||
*
|
||||
* // 带操作按钮
|
||||
* <EmptyState
|
||||
* title="暂无交易记录"
|
||||
* description="还没有任何交易"
|
||||
* actionText="创建交易"
|
||||
* onAction={() => router.push('/create')}
|
||||
* />
|
||||
*
|
||||
* // 自定义图标
|
||||
* <EmptyState
|
||||
* icon={BarChartIcon}
|
||||
* description="未发现提现记录"
|
||||
* />
|
||||
* ```
|
||||
* @param {EmptyStateProps} props - 空状态展示组件属性
|
||||
* @returns {React.ReactNode} 空状态展示组件
|
||||
*/
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
icon: Icon = FileTextIcon,
|
||||
actionText,
|
||||
onAction,
|
||||
className,
|
||||
iconSize = "md",
|
||||
}: EmptyStateProps) {
|
||||
const iconSizes = { sm: "size-10", md: "size-12", lg: "size-16" }
|
||||
const iconInnerSizes = { sm: "size-5", md: "size-6", lg: "size-8" }
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center justify-center py-12 text-center", className)}>
|
||||
<div className={cn(
|
||||
"rounded-full bg-muted flex items-center justify-center mb-4",
|
||||
iconSizes[iconSize],
|
||||
)}>
|
||||
<Icon className={cn("text-muted-foreground", iconInnerSizes[iconSize])} />
|
||||
</div>
|
||||
|
||||
{title && (
|
||||
<h3 className="text-base font-medium mb-1">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{onAction && actionText && (
|
||||
<Button
|
||||
onClick={onAction}
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
>
|
||||
{actionText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 带边框的空状态组件
|
||||
* 适合在卡片或容器内使用
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <EmptyStateWithBorder
|
||||
* title="暂无数据"
|
||||
* description="当前没有任何记录"
|
||||
* />
|
||||
* ```
|
||||
* @param {EmptyStateProps} props - 空状态展示组件属性
|
||||
* @returns {React.ReactNode} 带边框的空状态组件
|
||||
*/
|
||||
export function EmptyStateWithBorder(props: EmptyStateProps) {
|
||||
return (
|
||||
<div className="border border-dashed rounded-lg">
|
||||
<EmptyState {...props} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 简化版空状态组件(内联使用)
|
||||
* 适合在较小的区域显示
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <EmptyInline
|
||||
* message="暂无数据"
|
||||
* icon={FileTextIcon}
|
||||
* />
|
||||
* ```
|
||||
* @param {Pick<EmptyStateProps, 'icon' | 'className'> & { message?: string }} props - 简化版空状态组件属性
|
||||
* @returns {React.ReactNode} 简化版空状态组件
|
||||
*/
|
||||
export function EmptyInline({
|
||||
message = "暂无数据",
|
||||
icon: Icon = FileTextIcon,
|
||||
className,
|
||||
}: Pick<EmptyStateProps, 'icon' | 'className'> & { message?: string }) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground", className)}>
|
||||
<Icon className="size-4" />
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import * as React from "react"
|
||||
import {AlertCircle} from "lucide-react"
|
||||
import {Button} from "@/components/ui/button"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
|
||||
/**
|
||||
* 错误展示组件属性
|
||||
* 用于统一显示加载失败、请求失败等错误状态
|
||||
*/
|
||||
export interface ErrorDisplayProps {
|
||||
title?: string
|
||||
message?: string
|
||||
error?: Error | null
|
||||
onRetry?: () => void
|
||||
retryText?: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
className?: string
|
||||
showStack?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误展示组件
|
||||
* 用于统一显示加载失败、请求失败等错误状态
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // 基础用法
|
||||
* <ErrorDisplay
|
||||
* title="加载失败"
|
||||
* message="无法获取数据,请稍后重试"
|
||||
* />
|
||||
*
|
||||
* // 带重试按钮
|
||||
* <ErrorDisplay
|
||||
* error={error}
|
||||
* onRetry={() => refetch()}
|
||||
* />
|
||||
*
|
||||
* // 自定义样式
|
||||
* <ErrorDisplay
|
||||
* title="网络错误"
|
||||
* message="请检查您的网络连接"
|
||||
* className="min-h-[400px]"
|
||||
* />
|
||||
* ```
|
||||
* @param {ErrorDisplayProps} props - 错误展示组件属性
|
||||
* @returns {React.ReactNode} 错误展示组件
|
||||
*/
|
||||
export function ErrorDisplay({
|
||||
title = "加载失败",
|
||||
message,
|
||||
error,
|
||||
onRetry,
|
||||
retryText = "重试",
|
||||
icon: Icon = AlertCircle,
|
||||
className,
|
||||
showStack = false,
|
||||
}: ErrorDisplayProps) {
|
||||
const errorMessage = message || error?.message || "发生未知错误,请稍后重试"
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center justify-center py-12 text-center", className)}>
|
||||
<div className="size-12 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center mb-4">
|
||||
<Icon className="size-6 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold mb-2">{title}</h3>
|
||||
|
||||
<p className="text-sm text-muted-foreground max-w-md mb-4">{errorMessage}</p>
|
||||
|
||||
{onRetry && (
|
||||
<Button
|
||||
onClick={onRetry}
|
||||
variant="outline"
|
||||
className="mt-2"
|
||||
>
|
||||
{retryText}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showStack && error?.stack && process.env.NODE_ENV === 'development' && (
|
||||
<details className="mt-6 text-left max-w-2xl w-full">
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">
|
||||
查看详细错误信息
|
||||
</summary>
|
||||
<pre className="mt-2 p-4 bg-muted rounded-lg text-xs overflow-auto">
|
||||
{error.stack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 区域级错误组件
|
||||
* 适合在较小的区域显示错误
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ErrorInline
|
||||
* message="发生错误"
|
||||
* error={error}
|
||||
* onRetry={() => refetch()}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function ErrorInline({
|
||||
message,
|
||||
error,
|
||||
onRetry,
|
||||
className,
|
||||
}: Omit<ErrorDisplayProps, 'title' | 'icon' | 'showStack'>) {
|
||||
const errorMessage = message || error?.message || "发生未知错误"
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2 text-sm text-red-600 dark:text-red-400", className)}>
|
||||
<AlertCircle className="size-3 shrink-0 text-destructive" />
|
||||
<span className="flex-1 text-xs">{errorMessage}</span>
|
||||
{onRetry && (
|
||||
<Button
|
||||
onClick={onRetry}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-6 px-4 text-xs"
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面级错误组件
|
||||
* 适合作为页面主要内容的错误状态
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ErrorPage
|
||||
* title="加载失败"
|
||||
* message="无法获取数据,请稍后重试"
|
||||
* error={error}
|
||||
* onRetry={() => refetch()}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function ErrorPage(props: ErrorDisplayProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[calc(100vh-8rem)]">
|
||||
<ErrorDisplay
|
||||
{...props}
|
||||
showStack={props.showStack ?? process.env.NODE_ENV === 'development'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client"
|
||||
|
||||
import {memo, useEffect, useState} from "react"
|
||||
import {AnimatePresence, motion} from "motion/react"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Bell, Info, Maximize2, Minimize2, Moon, Search, Settings, Sun} from "lucide-react"
|
||||
import {useUser} from "@/contexts/user-context"
|
||||
import {useBellRing} from "@/contexts/bell-ring-context"
|
||||
import {useNotificationSettings} from "@/contexts/notification-settings-context"
|
||||
import {SidebarTrigger} from "@/components/ui/sidebar"
|
||||
import {useTheme} from "next-themes"
|
||||
import {useRouter} from "next/navigation"
|
||||
import {SearchDialog} from "@/components/layout/search-dialog"
|
||||
import {Kbd} from "@/components/ui/kbd"
|
||||
|
||||
|
||||
/**
|
||||
* 铃铛按钮组件
|
||||
*/
|
||||
const BellButton = memo(function BellButton() {
|
||||
const { isRinging } = useBellRing()
|
||||
const { showBell, isMounted } = useNotificationSettings()
|
||||
const [isClickAnimating, setIsClickAnimating] = useState(false)
|
||||
|
||||
if (!isMounted) return null
|
||||
if (!showBell) return null
|
||||
|
||||
const handleClick = () => {
|
||||
setIsClickAnimating(true)
|
||||
setTimeout(() => setIsClickAnimating(false), 600)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-9 text-muted-foreground hover:text-foreground"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Bell
|
||||
className="size-[18px]"
|
||||
style={(isRinging || isClickAnimating) ? { animation: 'var(--animate-bell-ring)', transformOrigin: 'top center' } : undefined}
|
||||
/>
|
||||
<span className="sr-only">通知</span>
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
/**
|
||||
* 站点头部组件
|
||||
* 用于显示站点头部
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SiteHeader />
|
||||
* ```
|
||||
*/
|
||||
export function SiteHeader({ isFullWidth = false, onToggleFullWidth }: { isFullWidth?: boolean, onToggleFullWidth?: (value: boolean) => void }) {
|
||||
const { user } = useUser()
|
||||
const { setTheme, resolvedTheme } = useTheme()
|
||||
const router = useRouter()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
|
||||
const [metaKey, setMetaKey] = useState("⌘")
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
if (typeof navigator !== 'undefined' && !navigator.userAgent?.includes("Mac")) {
|
||||
setMetaKey("Ctrl")
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<AppBanner />
|
||||
{user?.need_change_password && (
|
||||
<div className="bg-amber-500/10 border-b border-amber-500/20 px-4 py-3 text-amber-500 text-xs sm:text-sm flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="size-4 shrink-0 text-amber-500" />
|
||||
<span>为了您的账号安全,请立即修改密码!</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-amber-500 hover:text-amber-600 border-amber-500/30 hover:border-amber-500/50 bg-transparent shrink-0 h-8 px-3 text-xs"
|
||||
onClick={() => router.push("/settings/profile")}
|
||||
>
|
||||
去修改密码
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<header className="flex h-(--header-height) shrink-0 items-center bg-background px-4 md:px-0">
|
||||
<div className="flex w-full items-center justify-between md:hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarTrigger />
|
||||
<span className="text-sm font-medium truncate max-w-[120px]">
|
||||
{user?.nickname || user?.username || "Guest"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => setSearchOpen(true)}>
|
||||
<Search className="size-[18px]" />
|
||||
<span className="sr-only">搜索</span>
|
||||
</Button>
|
||||
<BellButton />
|
||||
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => router.push('/settings')}>
|
||||
<Settings className="size-[18px]" />
|
||||
<span className="sr-only">设置</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`hidden md:flex w-full items-center gap-4 transition-all duration-300 ease-in-out ${!isFullWidth ? "max-w-[1320px]" : "max-w-full"} mx-auto md:px-8 lg:px-12`}>
|
||||
<div className="relative w-64 cursor-pointer" onClick={() => setSearchOpen(true)}>
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<div className="flex h-8 items-center rounded-md border border-border/60 bg-muted/70 pl-10 pr-2.5 text-sm text-muted-foreground transition-colors hover:border-border hover:bg-muted">
|
||||
<span>搜索</span>
|
||||
<Kbd className="ml-auto gap-0.5 font-mono">
|
||||
<span>{metaKey}</span>
|
||||
<span>K</span>
|
||||
</Kbd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<BellButton />
|
||||
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => router.push('/settings')}>
|
||||
<Settings className="size-[18px]" />
|
||||
<span className="sr-only">设置</span>
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => onToggleFullWidth?.(!isFullWidth)}>
|
||||
{isFullWidth ? <Minimize2 className="size-[18px]" /> : <Maximize2 className="size-[18px]" />}
|
||||
<span className="sr-only">切换全宽</span>
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}>
|
||||
{mounted ? (resolvedTheme === 'dark' ? <Sun className="size-[18px]" /> : <Moon className="size-[18px]" />) : <Moon className="size-[18px]" />}
|
||||
<span className="sr-only">主题切换</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mounted && <SearchDialog open={searchOpen} onOpenChange={setSearchOpen} />}
|
||||
</header>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AppBanner() {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const bannerKey = 'ldc-banner-dismissed-script'
|
||||
|
||||
useEffect(() => {
|
||||
const dismissed = localStorage.getItem(bannerKey)
|
||||
if (!dismissed) {
|
||||
setIsVisible(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDismiss = () => {
|
||||
localStorage.setItem(bannerKey, 'true')
|
||||
setIsVisible(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
className="relative flex w-full items-center justify-center bg-muted/50 overflow-hidden"
|
||||
>
|
||||
<div className="flex w-full items-center justify-center px-4 pr-10 py-2 text-xs md:text-[13px] font-medium text-muted-foreground">
|
||||
<p className="flex flex-wrap items-center justify-center gap-x-2 text-center">
|
||||
<span className="text-foreground">最新通知</span>
|
||||
<a href="https://example.com" target="_blank" className="underline underline-offset-4 hover:text-foreground">
|
||||
查看详情
|
||||
</a>
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 size-6 -translate-y-1/2 text-muted-foreground hover:text-foreground shrink-0"
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
<span className="sr-only">Dismiss</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="lucide lucide-x"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import * as React from "react"
|
||||
import {motion} from "motion/react"
|
||||
import {cn} from "@/lib/utils"
|
||||
import {FileTextIcon} from "lucide-react"
|
||||
|
||||
/**
|
||||
* 加载页面组件
|
||||
*
|
||||
* 用于统一显示加载状态
|
||||
* @example
|
||||
* ```tsx
|
||||
* <LoadingPage text="系统" badgeText="系统" />
|
||||
* ```
|
||||
* @param {string} text - 显示的文本内容,默认为"系统"
|
||||
* @param {string} badgeText - 显示的徽章文本,默认为"系统"
|
||||
* @returns {React.ReactNode} 加载页面组件
|
||||
*/
|
||||
|
||||
export function AppleSpinner({ className = "size-8 text-muted-foreground" }: { className?: string }) {
|
||||
const spokes = Array.from({ length: 12 })
|
||||
return (
|
||||
<div className={cn("relative flex items-center justify-center", className)}>
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
@keyframes apple-spinner-fade {
|
||||
0% { opacity: 1; }
|
||||
100% { opacity: 0.15; }
|
||||
}
|
||||
.apple-spinner-spoke {
|
||||
animation: apple-spinner-fade 1.2s linear infinite;
|
||||
}
|
||||
`}} />
|
||||
<div className="relative size-full">
|
||||
{spokes.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="apple-spinner-spoke absolute top-0 left-[46%] w-[8%] h-[28%] bg-current rounded-full"
|
||||
style={{
|
||||
transform: `rotate(${i * 30}deg)`,
|
||||
transformOrigin: "50% 178.5%",
|
||||
animationDelay: `${(i - 12) * 0.1}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载页面组件
|
||||
*
|
||||
* 用于统一显示加载状态
|
||||
*/
|
||||
export function LoadingPage(props: { text?: string; badgeText?: string }) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const _ = props
|
||||
return (
|
||||
<div className="absolute inset-0 z-50 overflow-hidden font-sans bg-background/80 backdrop-blur-md text-foreground flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<AppleSpinner className="size-9 text-muted-foreground/80" />
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface LoadingStateProps extends React.ComponentProps<"div"> {
|
||||
title?: string
|
||||
description?: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
iconSize?: "sm" | "md" | "lg"
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载状态展示组件
|
||||
* 用于统一显示加载中的状态
|
||||
*/
|
||||
export function LoadingState({
|
||||
title = "加载中",
|
||||
description = "正在获取活动数据...",
|
||||
icon: Icon = FileTextIcon,
|
||||
className,
|
||||
iconSize = "md",
|
||||
}: LoadingStateProps) {
|
||||
const iconSizes = { sm: "size-8", md: "size-10", lg: "size-14" }
|
||||
const iconInnerSizes = { sm: "size-4", md: "size-5", lg: "size-7" }
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center justify-center py-12 text-center", className)}>
|
||||
<div className={cn(
|
||||
"rounded-full bg-muted flex items-center justify-center mb-2 animate-pulse",
|
||||
iconSizes[iconSize]
|
||||
)}>
|
||||
<Icon className={cn("text-muted-foreground", iconInnerSizes[iconSize])} />
|
||||
</div>
|
||||
|
||||
{title && (
|
||||
<h3 className="text-sm font-medium mb-1 animate-pulse">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground max-w-md animate-pulse">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 带边框的加载状态组件
|
||||
*/
|
||||
export function LoadingStateWithBorder(props: LoadingStateProps) {
|
||||
return (
|
||||
<div className="border border-dashed rounded-lg">
|
||||
<LoadingState {...props} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {usePathname} from 'next/navigation';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {ChevronRight} from 'lucide-react';
|
||||
|
||||
import {Collapsible, CollapsibleContent, CollapsibleTrigger} from '@/components/ui/collapsible';
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import type {OpenFlareNavGroup} from '@/lib/navigation/openflare-nav';
|
||||
import {
|
||||
matchesNavPath,
|
||||
openflareSidebarNav,
|
||||
isNavGroupActive,
|
||||
} from '@/lib/navigation/openflare-nav';
|
||||
|
||||
function SidebarNavGroupMenuItem({
|
||||
group,
|
||||
pathname,
|
||||
onNavigate,
|
||||
}: {
|
||||
group: OpenFlareNavGroup;
|
||||
pathname: string;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const groupActive = isNavGroupActive(pathname, group);
|
||||
const [open, setOpen] = useState(groupActive);
|
||||
|
||||
useEffect(() => {
|
||||
if (groupActive) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [groupActive]);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
asChild
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className="group/collapsible"
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton tooltip={group.title} isActive={groupActive}>
|
||||
<group.icon />
|
||||
<span>{group.title}</span>
|
||||
<ChevronRight className="ml-auto size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{group.items.map((item) => (
|
||||
<SidebarMenuSubItem key={item.title}>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
isActive={matchesNavPath(pathname, item.url, item.childUrls)}
|
||||
>
|
||||
<Link href={item.url} onClick={onNavigate}>
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpenFlareSidebarMenu({
|
||||
onNavigate,
|
||||
}: {
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<SidebarMenu className="gap-1">
|
||||
{openflareSidebarNav.map((entry) => {
|
||||
if (entry.kind === 'group') {
|
||||
return (
|
||||
<SidebarNavGroupMenuItem
|
||||
key={entry.title}
|
||||
group={entry}
|
||||
pathname={pathname}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={entry.title}>
|
||||
<SidebarMenuButton
|
||||
tooltip={entry.title}
|
||||
isActive={matchesNavPath(pathname, entry.url, entry.childUrls)}
|
||||
asChild
|
||||
>
|
||||
<Link href={entry.url} onClick={onNavigate}>
|
||||
<entry.icon />
|
||||
<span>{entry.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client"
|
||||
|
||||
import {usePublicConfig} from "@/hooks/use-public-config"
|
||||
|
||||
export function RobotsMeta() {
|
||||
const { config } = usePublicConfig()
|
||||
|
||||
// Default to noindex, nofollow if config is loading or if it's explicitly disabled
|
||||
const enabled = config?.search_engine_indexing_enabled === "true"
|
||||
|
||||
return (
|
||||
<meta
|
||||
name="robots"
|
||||
content={enabled ? "index, follow" : "noindex, nofollow"}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
"use client"
|
||||
|
||||
import {useCallback, useEffect, useState} from "react"
|
||||
import {useRouter} from "next/navigation"
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import {Kbd, KbdGroup} from "@/components/ui/kbd"
|
||||
import {type SearchItem, searchItems} from "@/lib/utils/search-data"
|
||||
import {FileText, Home, Settings, Shield} from "lucide-react"
|
||||
import {useUser} from "@/contexts/user-context"
|
||||
|
||||
interface SearchDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const categoryIcons = {
|
||||
page: Home,
|
||||
feature: FileText,
|
||||
setting: Settings,
|
||||
admin: Shield,
|
||||
}
|
||||
|
||||
const categoryLabels = {
|
||||
page: '页面',
|
||||
feature: '功能',
|
||||
setting: '设置',
|
||||
admin: '管理',
|
||||
}
|
||||
|
||||
const getTips = (metaKey: string) => [
|
||||
(
|
||||
<>
|
||||
<span className="text-muted-foreground/80 lowercase">Tips: 还可以使用</span>
|
||||
<Kbd className="mx-1">/</Kbd>
|
||||
<span className="text-muted-foreground/80 lowercase">来打开此界面</span>
|
||||
</>
|
||||
),
|
||||
(
|
||||
<>
|
||||
<span className="text-muted-foreground/80 lowercase">Tips: 使用</span>
|
||||
<Kbd className="mx-1">↑</Kbd>
|
||||
<Kbd className="mx-1">↓</Kbd>
|
||||
<span className="text-muted-foreground/80 lowercase">来切换选中项</span>
|
||||
</>
|
||||
),
|
||||
(
|
||||
<>
|
||||
<span className="text-muted-foreground/80 lowercase">Tips: 按住</span>
|
||||
<Kbd className="mx-1">{metaKey}</Kbd>
|
||||
<span className="text-muted-foreground/80 lowercase">+</span>
|
||||
<Kbd className="mx-1">↵</Kbd>
|
||||
<span className="text-muted-foreground/80 lowercase">在新标签页打开</span>
|
||||
</>
|
||||
),
|
||||
(
|
||||
<>
|
||||
<span className="text-muted-foreground/80 lowercase">你知道吗:搜索功能还在持续升级中</span>
|
||||
</>
|
||||
)
|
||||
]
|
||||
|
||||
export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
|
||||
const router = useRouter()
|
||||
const { user } = useUser()
|
||||
const [search, setSearch] = useState('')
|
||||
const [currentTip, setCurrentTip] = useState<React.ReactNode>(null)
|
||||
const [results, setResults] = useState<SearchItem[]>([])
|
||||
const [metaKey, setMetaKey] = useState("⌘")
|
||||
const [isCtrlPressed, setIsCtrlPressed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof navigator !== 'undefined' && !navigator.userAgent?.includes("Mac")) {
|
||||
setMetaKey("Ctrl")
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Control' || e.key === 'Meta') {
|
||||
setIsCtrlPressed(true)
|
||||
}
|
||||
}
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Control' || e.key === 'Meta') {
|
||||
setIsCtrlPressed(false)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('keyup', handleKeyUp)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('keyup', handleKeyUp)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const tips = getTips(metaKey)
|
||||
const randomTip = tips[Math.floor(Math.random() * tips.length)]
|
||||
setCurrentTip(randomTip)
|
||||
}
|
||||
}, [open, metaKey])
|
||||
|
||||
useEffect(() => {
|
||||
const items = searchItems(search, user?.is_admin)
|
||||
setResults(items)
|
||||
}, [search, user?.is_admin])
|
||||
|
||||
const handleSelect = useCallback((item: SearchItem, openInNewTab = false) => {
|
||||
onOpenChange(false)
|
||||
if (openInNewTab) {
|
||||
window.open(item.url, '_blank')
|
||||
} else {
|
||||
router.push(item.url)
|
||||
}
|
||||
setSearch('')
|
||||
}, [onOpenChange, router])
|
||||
|
||||
useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
onOpenChange(!open)
|
||||
}
|
||||
|
||||
if (e.key === '/'&& !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
const target = e.target as HTMLElement
|
||||
const isEditing = target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.tagName === 'SELECT' ||
|
||||
target.isContentEditable ||
|
||||
target.closest('[contenteditable="true"]')
|
||||
|
||||
if (!isEditing) {
|
||||
e.preventDefault()
|
||||
onOpenChange(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', down)
|
||||
return () => document.removeEventListener('keydown', down)
|
||||
}, [open, onOpenChange])
|
||||
|
||||
// Group results by category
|
||||
const groupedResults = results.reduce((acc, item) => {
|
||||
if (!acc[item.category]) {
|
||||
acc[item.category] = []
|
||||
}
|
||||
acc[item.category].push(item)
|
||||
return acc
|
||||
}, {} as Record<string, SearchItem[]>)
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
shouldFilter={false}
|
||||
className="max-w-[calc(100%-1.5rem)] sm:max-w-[500px] md:max-w-[540px]"
|
||||
>
|
||||
<CommandInput
|
||||
placeholder="搜索页面和功能..."
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>没有找到相关内容,换个词试试?</CommandEmpty>
|
||||
{Object.entries(groupedResults).map(([category, items]) => {
|
||||
const Icon = categoryIcons[category as keyof typeof categoryIcons]
|
||||
return (
|
||||
<CommandGroup key={category} heading={categoryLabels[category as keyof typeof categoryLabels]}>
|
||||
{items.map((item) => (
|
||||
<CommandItem
|
||||
key={item.id}
|
||||
value={item.title}
|
||||
onSelect={() => handleSelect(item, isCtrlPressed)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted/70">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span>
|
||||
{item.matchRange ? (
|
||||
<>
|
||||
{item.title.substring(0, item.matchRange[0])}
|
||||
<span className="text-primary font-bold">
|
||||
{item.title.substring(item.matchRange[0], item.matchRange[1] + 1)}
|
||||
</span>
|
||||
{item.title.substring(item.matchRange[1] + 1)}
|
||||
</>
|
||||
) : (
|
||||
item.title
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate text-[11px] leading-4 text-muted-foreground">{item.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-[10px] leading-none font-medium text-muted-foreground">
|
||||
{categoryLabels[item.category as keyof typeof categoryLabels]}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)
|
||||
})}
|
||||
</CommandList>
|
||||
<div className="hidden border-t bg-muted/20 px-4 py-2.5 md:flex items-center gap-4 text-[10px] text-muted-foreground uppercase tracking-wider font-medium select-none">
|
||||
<div className="flex items-center gap-1">
|
||||
<KbdGroup>
|
||||
{isCtrlPressed && <Kbd>{metaKey}</Kbd>}
|
||||
<Kbd>↵</Kbd>
|
||||
</KbdGroup>
|
||||
<span>{isCtrlPressed ? '在新标签页打开' : '打开'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Kbd>Esc</Kbd>
|
||||
<span>关闭搜索界面</span>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{currentTip}
|
||||
</div>
|
||||
</div>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import {usePathname, useRouter} from "next/navigation"
|
||||
import {APP_BUILD_DATE, APP_VERSION} from "@/lib/app-info"
|
||||
import {toast} from "sonner"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Spinner} from "@/components/ui/spinner"
|
||||
import {AnimateIcon} from "@/components/animate-ui/icons/icon"
|
||||
import {ChevronLeft} from "@/components/animate-ui/icons/chevron-left"
|
||||
import {ChevronRight} from "@/components/animate-ui/icons/chevron-right"
|
||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {getCurrentTheme} from "@/components/layout/avater-style/registry"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Bell,
|
||||
ChevronDown,
|
||||
Code,
|
||||
CreditCard,
|
||||
Database,
|
||||
FileQuestionMark,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Layers,
|
||||
LogOut,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Terminal,
|
||||
UserRound,
|
||||
} from "lucide-react"
|
||||
|
||||
import {useUser} from "@/contexts/user-context"
|
||||
import {usePublicConfig} from "@/hooks/use-public-config"
|
||||
import {OpenFlareSidebarMenu} from "@/components/layout/openflare-sidebar-menu"
|
||||
import {openflareSidebarNav} from "@/lib/navigation/openflare-nav"
|
||||
|
||||
/* 导航数据 */
|
||||
const data = {
|
||||
admin: [
|
||||
{ title: "用户管理", url: "/admin/users", icon: UserRound },
|
||||
{ title: "任务管理", url: "/admin/tasks", icon: Layers },
|
||||
{ title: "存储管理", url: "/admin/files", icon: FolderOpen },
|
||||
{ title: "数据管理", url: "/admin/database", icon: Database },
|
||||
{ title: "通知推送", url: "/admin/push", icon: Bell },
|
||||
{ title: "系统日志", url: "/admin/logs", icon: Terminal },
|
||||
{ title: "系统配置", url: "/admin/system", icon: ShieldCheck },
|
||||
{ title: "系统设置", url: "/admin/settings", icon: Settings },
|
||||
],
|
||||
document: [
|
||||
{ title: "规范示例", url: "/admin/demo", icon: Code },
|
||||
{ title: "接口文档", url: "/docs/api", icon: CreditCard, external: true },
|
||||
{ title: "使用文档", url: "/docs/how-to-use", icon: FileText, external: true },
|
||||
],
|
||||
}
|
||||
|
||||
function parseMenuDisplayConfig(raw: string | undefined): Record<string, boolean> {
|
||||
if (!raw) return {}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}
|
||||
|
||||
return Object.entries(parsed).reduce<Record<string, boolean>>((result, [key, value]) => {
|
||||
if (typeof value === "boolean") {
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}, {})
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用侧边栏组件
|
||||
* 显示应用侧边栏
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <AppSidebar />
|
||||
* ```
|
||||
* @returns {React.ReactNode} 应用侧边栏组件
|
||||
*/
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const { toggleSidebar, state, isMobile, setOpenMobile } = useSidebar()
|
||||
const {user, logout, loading: userLoading} = useUser()
|
||||
const { config } = usePublicConfig()
|
||||
const [showLogoutDialog, setShowLogoutDialog] = React.useState(false)
|
||||
const [isLoggingOut, setIsLoggingOut] = React.useState(false)
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
|
||||
const currentTheme = getCurrentTheme()
|
||||
const {
|
||||
MainDecoration,
|
||||
InteractionDecoration,
|
||||
BackgroundEffect,
|
||||
smallMainDecorationConfig,
|
||||
largeMainDecorationConfig,
|
||||
interactionDecorationConfig
|
||||
} = currentTheme
|
||||
const hasAvatarDecoration = currentTheme.key !== "none"
|
||||
|
||||
const [showEffect, setShowEffect] = React.useState(false)
|
||||
const [userMenuOpen, setUserMenuOpen] = React.useState(false)
|
||||
const userMenuLastToggle = React.useRef(0)
|
||||
|
||||
const handleUserMenuOpenChange = React.useCallback((open: boolean) => {
|
||||
const now = Date.now()
|
||||
if (now - userMenuLastToggle.current < 65) return
|
||||
userMenuLastToggle.current = now
|
||||
setUserMenuOpen(open)
|
||||
}, [])
|
||||
|
||||
const handleCloseSidebar = React.useCallback(() => {
|
||||
if (isMobile) {
|
||||
setOpenMobile(false)
|
||||
}
|
||||
}, [isMobile, setOpenMobile])
|
||||
|
||||
const handleLogout = async () => {
|
||||
setIsLoggingOut(true)
|
||||
try {
|
||||
await logout()
|
||||
setShowLogoutDialog(false)
|
||||
} catch (error) {
|
||||
toast.error("登出失败", {
|
||||
description: error instanceof Error ? error.message : "登出时发生错误,请重试"
|
||||
})
|
||||
setIsLoggingOut(false)
|
||||
}
|
||||
}
|
||||
|
||||
const adminFiltered = React.useMemo(() => {
|
||||
const displayConfig = parseMenuDisplayConfig(config?.menu_display_config)
|
||||
return data.admin.filter((item) => displayConfig[item.url] !== false)
|
||||
}, [config])
|
||||
|
||||
const documentFiltered = React.useMemo(() => {
|
||||
const displayConfig = parseMenuDisplayConfig(config?.menu_display_config)
|
||||
return data.document.filter((item) => displayConfig[item.url] !== false)
|
||||
}, [config])
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasAvatarDecoration && showEffect && BackgroundEffect && <BackgroundEffect />}
|
||||
<Sidebar collapsible="icon" {...props} className="px-2 relative border-r border-border/40 group-data-[collapsible=icon]">
|
||||
<Button
|
||||
onClick={toggleSidebar}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-1/2 -right-6 w-2 h-4 text-muted-foreground hover:bg-background hidden md:flex"
|
||||
>
|
||||
{state === "expanded" ? (
|
||||
<AnimateIcon animateOnHover>
|
||||
<ChevronLeft className="size-6" />
|
||||
</AnimateIcon>
|
||||
) : (
|
||||
<AnimateIcon animateOnHover>
|
||||
<ChevronRight className="size-6" />
|
||||
</AnimateIcon>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<SidebarHeader className="py-4 md:-ml-2">
|
||||
<DropdownMenu open={userMenuOpen} onOpenChange={handleUserMenuOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex h-12 hover:bg-accent group-data-[collapsible=icon]:ml-2"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<div className="relative overflow-visible">
|
||||
{hasAvatarDecoration && <MainDecoration className={smallMainDecorationConfig?.className} />}
|
||||
<Avatar className="size-6 rounded relative z-10">
|
||||
<AvatarImage
|
||||
src={user?.avatar_url}
|
||||
alt={user?.nickname}
|
||||
/>
|
||||
<AvatarFallback className="rounded bg-muted text-sm">
|
||||
{user?.nickname?.charAt(0)?.toUpperCase() || "L"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className="flex flex-col items-start flex-1 text-left group-data-[collapsible=icon]:hidden">
|
||||
<span className="text-xs font-medium truncate w-full text-left ml-2">
|
||||
{user?.nickname || user?.username || "Unknown User"}
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-muted-foreground/100 truncate w-full text-left ml-2">
|
||||
{user?.is_admin ? "系统管理员" : "普通用户"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className="size-4 text-muted-foreground ml-auto group-data-[collapsible=icon]:hidden" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className={isMobile || state === "collapsed" ? "ml-1 w-68 z-[200]" : "w-64 z-[200]"}
|
||||
align="start"
|
||||
side={isMobile || state === "collapsed" ? "bottom" : "right"}
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel>
|
||||
<div className="flex flex-col items-center mb-4 gap-1 pt-4">
|
||||
<div
|
||||
className={`relative w-32 h-16 flex items-center justify-center overflow-visible select-none transition-transform ${hasAvatarDecoration ? "cursor-pointer active:scale-95" : ""}`}
|
||||
onClick={hasAvatarDecoration ? () => setShowEffect(!showEffect) : undefined}
|
||||
>
|
||||
{hasAvatarDecoration && (
|
||||
<InteractionDecoration
|
||||
show={showEffect}
|
||||
className={interactionDecorationConfig?.className}
|
||||
/>
|
||||
)}
|
||||
<div className="relative z-10">
|
||||
{hasAvatarDecoration && <MainDecoration className={largeMainDecorationConfig?.className} />}
|
||||
<Avatar className="size-14 rounded-full pointer-events-none">
|
||||
<AvatarImage
|
||||
src={user?.avatar_url}
|
||||
alt={user?.nickname}
|
||||
/>
|
||||
<AvatarFallback className="rounded-full bg-transparent text-lg">
|
||||
{user?.nickname?.charAt(0)?.toUpperCase() || "L"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-base font-semibold truncate mt-2">
|
||||
{userLoading
|
||||
? "加载中..."
|
||||
: user?.nickname || user?.username || "未登录"}
|
||||
</span>
|
||||
<span className="text-xs font-base text-muted-foreground">
|
||||
{userLoading ? "正在同步账户" : user?.is_admin ? "系统管理员" : "普通用户"}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => {
|
||||
router.push("/settings/profile")
|
||||
handleCloseSidebar()
|
||||
}}>
|
||||
<UserRound className="mr-2 size-4" />
|
||||
<span>我的资料</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => {
|
||||
router.push("/settings")
|
||||
handleCloseSidebar()
|
||||
}}>
|
||||
<Settings className="mr-2 size-4" />
|
||||
<span>设置</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="my-2" />
|
||||
<DropdownMenuItem onClick={() => {
|
||||
router.push("/docs/how-to-use")
|
||||
handleCloseSidebar()
|
||||
}}>
|
||||
<FileQuestionMark className="mr-2 size-4" />
|
||||
<span>使用帮助</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive hover:bg-destructive/50" onClick={() => setShowLogoutDialog(true)}>
|
||||
<LogOut className="mr-2 size-4 text-destructive" />
|
||||
<span>退出登录</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent className="group-data-[collapsible=icon]">
|
||||
{openflareSidebarNav.length > 0 && (
|
||||
<SidebarGroup className="py-0">
|
||||
<SidebarGroupContent className="py-1">
|
||||
<OpenFlareSidebarMenu onNavigate={handleCloseSidebar} />
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
|
||||
{user?.is_admin && adminFiltered.length > 0 && (
|
||||
<SidebarGroup className="py-0 pt-4">
|
||||
<SidebarGroupLabel className="text-xs font-normal text-muted-foreground">
|
||||
管理
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent className="py-1">
|
||||
<SidebarMenu className="gap-1">
|
||||
{adminFiltered.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
isActive={pathname === item.url || pathname.startsWith(`${item.url}/`)}
|
||||
asChild
|
||||
>
|
||||
<Link href={item.url} onClick={handleCloseSidebar}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
|
||||
{documentFiltered.length > 0 && (
|
||||
<SidebarGroup className="py-0 pt-4">
|
||||
<SidebarGroupLabel className="text-xs font-normal text-muted-foreground">
|
||||
文档库
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent className="py-1">
|
||||
<SidebarMenu className="gap-1">
|
||||
{documentFiltered.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
isActive={pathname === item.url}
|
||||
asChild
|
||||
>
|
||||
{item.external ? (
|
||||
<Link href={item.url} target="_blank" rel="noopener noreferrer" onClick={handleCloseSidebar}>
|
||||
{item.icon && <item.icon />}
|
||||
<span className="flex-1">{item.title}</span>
|
||||
<ArrowUpRight className="size-3 text-muted-foreground" />
|
||||
</Link>
|
||||
) : (
|
||||
<Link href={item.url} onClick={handleCloseSidebar}>
|
||||
{item.icon && <item.icon />}
|
||||
<span className="flex-1">{item.title}</span>
|
||||
</Link>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
|
||||
</SidebarContent>
|
||||
<SidebarFooter className="mt-auto px-3 py-3 group-data-[collapsible=icon]:hidden">
|
||||
<div className="border-t border-border/60 pt-3 text-[11px] leading-5 text-muted-foreground">
|
||||
<div>Version {APP_VERSION}</div>
|
||||
{APP_BUILD_DATE && <div>Build At {APP_BUILD_DATE}</div>}
|
||||
</div>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
|
||||
<AlertDialog open={showLogoutDialog} onOpenChange={(open) => !isLoggingOut && setShowLogoutDialog(open)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认登出</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{isLoggingOut ? '正在登出,请稍候...' : '您确定要登出当前账户吗?'}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoggingOut}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleLogout} disabled={isLoggingOut}>
|
||||
{isLoggingOut && <Spinner className="mr-2" />}
|
||||
{isLoggingOut ? '登出中...' : '确认登出'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {ThemeProvider as NextThemesProvider} from "next-themes"
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {motion} from "motion/react"
|
||||
|
||||
|
||||
/**
|
||||
* 页面过渡组件
|
||||
* 用于统一显示页面过渡效果
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <PageTransition>
|
||||
* <div>内容</div>
|
||||
* </PageTransition>
|
||||
* ```
|
||||
* @param {React.ReactNode} children - 页面过渡组件的子元素
|
||||
* @returns {React.ReactNode} 页面过渡组件
|
||||
*/
|
||||
export function PageTransition({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import {useUser} from "@/contexts/user-context"
|
||||
|
||||
/**
|
||||
* Auth provider bridge hook
|
||||
*
|
||||
* Provides a stable interface for components that use the useAuth() pattern.
|
||||
* Delegates to the canonical UserContext under the hood.
|
||||
*/
|
||||
export function useAuth() {
|
||||
return useUser()
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client"
|
||||
|
||||
import {QueryClient, QueryClientProvider} from "@tanstack/react-query"
|
||||
import {useState} from "react"
|
||||
|
||||
export function AppQueryProvider({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
import {useEffect} from 'react'
|
||||
import {ConfigService} from '@/lib/services/config'
|
||||
import {usePathname} from 'next/navigation'
|
||||
|
||||
export function SiteTitleUpdater() {
|
||||
const pathname = usePathname()
|
||||
const publicConfigQuery = useQuery({
|
||||
queryKey: ["public-config"],
|
||||
queryFn: () => ConfigService.getPublicConfig(),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const siteName = publicConfigQuery.data?.site_name || "OpenFlare"
|
||||
|
||||
// Determine the page suffix based on path
|
||||
let suffix = ""
|
||||
if (pathname === "/login") {
|
||||
suffix = " - 登录"
|
||||
} else if (pathname === "/register") {
|
||||
suffix = " - 注册"
|
||||
} else if (pathname.startsWith("/admin")) {
|
||||
suffix = " - 后台管理"
|
||||
} else if (pathname === "/") {
|
||||
suffix = " - 总览"
|
||||
}
|
||||
|
||||
document.title = `${siteName}${suffix}`
|
||||
}, [publicConfigQuery.data?.site_name, pathname])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import {ChevronDownIcon} from "lucide-react"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
import {buttonVariants} from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants({ variant: "destructive", size: "sm"}), "text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
import {cn} from "@/lib/utils";
|
||||
import React, {ReactNode, useEffect, useState} from "react";
|
||||
|
||||
interface AuroraBackgroundProps extends React.HTMLProps<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
showRadialGradient?: boolean;
|
||||
}
|
||||
|
||||
export const AuroraBackground = ({
|
||||
className,
|
||||
children,
|
||||
showRadialGradient = true,
|
||||
...props
|
||||
}: AuroraBackgroundProps) => {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
setIsVisible(!document.hidden);
|
||||
};
|
||||
|
||||
// Add event listener for page visibility changes
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div
|
||||
className={cn(
|
||||
"transition-bg relative flex h-[100vh] flex-col items-center justify-center bg-zinc-50 text-slate-950 dark:bg-zinc-900",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
style={
|
||||
{
|
||||
"--aurora":
|
||||
"repeating-linear-gradient(100deg,#3b82f6_10%,#a5b4fc_15%,#93c5fd_20%,#ddd6fe_25%,#60a5fa_30%)",
|
||||
"--dark-gradient":
|
||||
"repeating-linear-gradient(100deg,#000_0%,#000_7%,transparent_10%,transparent_12%,#000_16%)",
|
||||
"--white-gradient":
|
||||
"repeating-linear-gradient(100deg,#fff_0%,#fff_7%,transparent_10%,transparent_12%,#fff_16%)",
|
||||
|
||||
"--blue-300": "#93c5fd",
|
||||
"--blue-400": "#60a5fa",
|
||||
"--blue-500": "#3b82f6",
|
||||
"--indigo-300": "#a5b4fc",
|
||||
"--violet-200": "#ddd6fe",
|
||||
"--black": "#000",
|
||||
"--white": "#fff",
|
||||
"--transparent": "transparent",
|
||||
// Hide container when page is not visible
|
||||
visibility: isVisible ? "visible" : "hidden",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
// I'm sorry but this is what peak developer performance looks like // trigger warning
|
||||
className={cn(
|
||||
`after:animate-aurora pointer-events-none absolute -inset-[10px] [background-image:var(--white-gradient),var(--aurora)] [background-size:300%,_200%] [background-position:50%_50%,50%_50%] opacity-50 blur-[10px] invert filter will-change-transform [--aurora:repeating-linear-gradient(100deg,var(--blue-500)_10%,var(--indigo-300)_15%,var(--blue-300)_20%,var(--violet-200)_25%,var(--blue-400)_30%)] [--dark-gradient:repeating-linear-gradient(100deg,var(--black)_0%,var(--black)_7%,var(--transparent)_10%,var(--transparent)_12%,var(--black)_16%)] [--white-gradient:repeating-linear-gradient(100deg,var(--white)_0%,var(--white)_7%,var(--transparent)_10%,var(--transparent)_12%,var(--white)_16%)] after:absolute after:inset-0 after:[background-image:var(--white-gradient),var(--aurora)] after:[background-size:200%,_100%] after:[background-attachment:fixed] after:mix-blend-difference after:content-[""] dark:[background-image:var(--dark-gradient),var(--aurora)] dark:invert-0 after:dark:[background-image:var(--dark-gradient),var(--aurora)]`,
|
||||
|
||||
showRadialGradient &&
|
||||
`[mask-image:radial-gradient(ellipse_at_100%_0%,black_10%,var(--transparent)_70%)]`,
|
||||
)}
|
||||
style={{
|
||||
// Pause animation when page is not visible
|
||||
animationPlayState: isVisible ? "running" : "paused",
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import {Slot} from "@radix-ui/react-slot"
|
||||
import {cva, type VariantProps} from "class-variance-authority"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,109 @@
|
||||
import * as React from "react"
|
||||
import {Slot} from "@radix-ui/react-slot"
|
||||
import {ChevronRight, MoreHorizontal} from "lucide-react"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as React from "react"
|
||||
import {Slot} from "@radix-ui/react-slot"
|
||||
import {cva, type VariantProps} from "class-variance-authority"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
auth: "bg-foreground text-background hover:bg-foreground/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,212 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon,} from "lucide-react"
|
||||
import {DayButton, DayPicker, getDefaultClassNames} from "react-day-picker"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
import {Button, buttonVariants} from "@/components/ui/button"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"flex gap-4 flex-col md:flex-row relative",
|
||||
defaultClassNames.months
|
||||
),
|
||||
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
"absolute bg-popover inset-0 opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"select-none w-(--cell-size)",
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
"text-[0.8rem] select-none text-muted-foreground",
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||
props.showWeekNumber
|
||||
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
|
||||
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
"rounded-l-md bg-accent",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-4 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-4 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import useEmblaCarousel, {type UseEmblaCarouselType,} from "embla-carousel-react"
|
||||
import {ArrowLeft, ArrowRight} from "lucide-react"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
import {Button} from "@/components/ui/button"
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1]
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||
type CarouselOptions = UseCarouselParameters[0]
|
||||
type CarouselPlugin = UseCarouselParameters[1]
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: "horizontal" | "vertical"
|
||||
setApi?: (api: CarouselApi) => void
|
||||
}
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: boolean
|
||||
canScrollNext: boolean
|
||||
} & CarouselProps
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function Carousel({
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & CarouselProps) {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins
|
||||
)
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) return
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}, [])
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev()
|
||||
}, [api])
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext()
|
||||
}, [api])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) return
|
||||
setApi(api)
|
||||
}, [api, setApi])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) return
|
||||
onSelect(api)
|
||||
api.on("reInit", onSelect)
|
||||
api.on("select", onSelect)
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect)
|
||||
}
|
||||
}, [api, onSelect])
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
data-slot="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { carouselRef, orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="overflow-hidden"
|
||||
data-slot="carousel-content"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
data-slot="carousel-item"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselPrevious({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-previous"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -left-12 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselNext({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-next"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -right-12 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
}
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
}) {
|
||||
const uniqueId = React.useId()
|
||||
const [mounted, setMounted] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const chartId = mounted ? `chart-${ id || uniqueId.replace(/:/g, "") }` : "chart-"
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{mounted && <ChartStyle id={chartId} config={config} />}
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${ prefix } [data-chart=${ id }] {
|
||||
${ colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color
|
||||
return color ? ` --color-${ key }: ${ color };` : null
|
||||
})
|
||||
.join("\n") }
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
}) {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${ labelKey || item?.dataKey || item?.name || "value" }`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${ nameKey || item.name || item.dataKey || "value" }`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
}) {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item) => {
|
||||
const key = `${ nameKey || item.dataKey || "value" }`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import {CheckIcon} from "lucide-react"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {Check, Copy} from "lucide-react"
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
interface CodeBlockProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
code: string
|
||||
language?: string
|
||||
showLineNumbers?: boolean
|
||||
}
|
||||
|
||||
export function CodeBlock({
|
||||
code,
|
||||
language = "bash",
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockProps) {
|
||||
const [hasCopied, setHasCopied] = React.useState(false)
|
||||
|
||||
const onCopy = () => {
|
||||
navigator.clipboard.writeText(code)
|
||||
setHasCopied(true)
|
||||
setTimeout(() => setHasCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative rounded-xl overflow-hidden bg-[#1e1e1e] dark:bg-[#0d0d0d] border border-border/40 shadow-sm my-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-[#1e1e1e] dark:bg-[#0d0d0d] border-b border-white/5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-[#ff5f56] border border-[#e0443e]/50" />
|
||||
<div className="w-3 h-3 rounded-full bg-[#ffbd2e] border border-[#dea123]/50" />
|
||||
<div className="w-3 h-3 rounded-full bg-[#27c93f] border border-[#1aab29]/50" />
|
||||
</div>
|
||||
<button
|
||||
onClick={onCopy}
|
||||
className="text-muted-foreground hover:text-white transition-colors"
|
||||
>
|
||||
{hasCopied ? (
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 overflow-x-auto bg-transparent">
|
||||
<pre className="font-mono text-xs leading-relaxed text-[#e0e0e0] !bg-transparent !p-0 !m-0 !border-0 shadow-none whitespace-pre-wrap break-all">
|
||||
<code className={cn("!bg-transparent !p-0 !m-0 !border-0 shadow-none font-mono", language && `language-${ language }`)}>
|
||||
{code}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {Collapsible as CollapsiblePrimitive} from "radix-ui"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Trigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Content>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Content data-slot="collapsible-content" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
@@ -0,0 +1,209 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {Command as CommandPrimitive} from "cmdk"
|
||||
import {SearchIcon} from "lucide-react"
|
||||
import {motion} from "motion/react"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,} from "@/components/ui/dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = false,
|
||||
shouldFilter,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
shouldFilter?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0 shadow-2xl", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<Command
|
||||
shouldFilter={shouldFilter}
|
||||
className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-4 [&_[cmdk-input-wrapper]_svg]:w-4 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-3 [&_[cmdk-item]]:py-2 [&_[cmdk-item]_svg]:h-4 [&_[cmdk-item]_svg]:w-4"
|
||||
>
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-12 items-center gap-2 border-b px-4"
|
||||
>
|
||||
<SearchIcon className="size-5 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-full w-full rounded-md bg-transparent py-2 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
const ref = React.useRef<HTMLDivElement>(null)
|
||||
const [height, setHeight] = React.useState<number | "auto">("auto")
|
||||
|
||||
React.useEffect(() => {
|
||||
if (ref.current) {
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setHeight(entry.contentRect.height)
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(ref.current)
|
||||
return () => resizeObserver.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ height }}
|
||||
transition={{ duration: 0.25, ease: [0.32, 0.72, 0, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[380px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</CommandPrimitive.List>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-[11px] [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"relative flex cursor-pointer items-center gap-3 rounded-md border border-transparent px-3 py-2 text-sm outline-hidden select-none transition-colors data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:border-border/70 data-[selected=true]:bg-accent/80 data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import {XIcon} from "lucide-react"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
// 统一小屏样式:全宽留白、适度圆角、滚动保护
|
||||
const mobileBase = "w-full max-w-[calc(100%-1.5rem)]"
|
||||
const desktopBase = "sm:max-w-lg md:max-w-xl"
|
||||
const commonStyle = "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl border p-6 shadow-lg duration-200 max-h-[90vh] overflow-y-auto"
|
||||
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
commonStyle,
|
||||
mobileBase,
|
||||
desktopBase,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-row justify-end gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-base text-left leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-left text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import * as React from "react"
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
const DocsTable = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="w-full overflow-x-auto rounded-lg border border-border/50">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("min-w-full text-sm text-left !my-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
DocsTable.displayName = "DocsTable"
|
||||
|
||||
const DocsTableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("bg-muted/50 text-muted-foreground font-medium border-b border-border/50", className)} {...props} />
|
||||
))
|
||||
DocsTableHeader.displayName = "DocsTableHeader"
|
||||
|
||||
const DocsTableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("bg-background", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DocsTableBody.displayName = "DocsTableBody"
|
||||
|
||||
const DocsTableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn("transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted border-b border-border/50 last:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DocsTableRow.displayName = "DocsTableRow"
|
||||
|
||||
const DocsTableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"pl-6 pr-4 py-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 first:pl-6 last:pr-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DocsTableHead.displayName = "DocsTableHead"
|
||||
|
||||
const DocsTableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("pl-6 pr-4 py-2 align-middle [&:has([role=checkbox])]:pr-0 first:pl-6 last:pr-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DocsTableCell.displayName = "DocsTableCell"
|
||||
|
||||
export {
|
||||
DocsTable,
|
||||
DocsTableHeader,
|
||||
DocsTableBody,
|
||||
DocsTableHead,
|
||||
DocsTableRow,
|
||||
DocsTableCell,
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {Drawer as DrawerPrimitive} from "vaul"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
function Drawer({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
||||
}
|
||||
|
||||
function DrawerClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import {CheckIcon, ChevronRightIcon, CircleIcon} from "lucide-react"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
"use client"
|
||||
|
||||
import {useMemo} from "react"
|
||||
import {cva, type VariantProps} from "class-variance-authority"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Separator} from "@/components/ui/separator"
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-6",
|
||||
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-3 font-medium",
|
||||
"data-[variant=legend]:text-base",
|
||||
"data-[variant=label]:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
|
||||
horizontal: [
|
||||
"flex-row items-center",
|
||||
"[&>[data-slot=field-label]]:flex-auto",
|
||||
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
responsive: [
|
||||
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
|
||||
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
|
||||
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
|
||||
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
|
||||
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
|
||||
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
||||
]
|
||||
|
||||
if (uniqueErrors?.length == 1) {
|
||||
return uniqueErrors[0]?.message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-destructive text-sm font-normal", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client"
|
||||
|
||||
import {useEffect, useRef} from "react"
|
||||
|
||||
interface Particle {
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
alpha: number
|
||||
color: string
|
||||
decay: number
|
||||
}
|
||||
|
||||
interface Firework {
|
||||
x: number
|
||||
y: number
|
||||
targetY: number
|
||||
vx: number
|
||||
vy: number
|
||||
color: string
|
||||
particles: Particle[]
|
||||
exploded: boolean
|
||||
dead: boolean
|
||||
}
|
||||
|
||||
export function FireworksEffect() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
|
||||
let animationFrameId: number
|
||||
let fireworks: Firework[] = []
|
||||
|
||||
const colors = [
|
||||
"#ff0000", "#ff7f00", "#ffff00", "#00ff00", "#0000ff", "#4b0082", "#8f00ff",
|
||||
"#ff1493", "#00bfff", "#ffd700", "#ff4500", "#32cd32"
|
||||
]
|
||||
|
||||
const resizeCanvas = () => {
|
||||
canvas.width = window.innerWidth
|
||||
canvas.height = window.innerHeight
|
||||
}
|
||||
|
||||
const createFirework = () => {
|
||||
const x = Math.random() * canvas.width
|
||||
const y = canvas.height
|
||||
|
||||
const targetY = canvas.height * 0.2 + Math.random() * (canvas.height * 0.5)
|
||||
const speed = 8 + Math.random() * 5
|
||||
|
||||
fireworks.push({
|
||||
x,
|
||||
y,
|
||||
targetY,
|
||||
vx: (Math.random() - 0.5) * 3,
|
||||
vy: -speed,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
particles: [],
|
||||
exploded: false,
|
||||
dead: false
|
||||
})
|
||||
}
|
||||
|
||||
const createParticles = (x: number, y: number, color: string) => {
|
||||
const particleCount = 50 + Math.random() * 50
|
||||
const particles: Particle[] = []
|
||||
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
const angle = Math.random() * Math.PI * 2
|
||||
const speed = Math.random() * 5 + 1
|
||||
particles.push({
|
||||
x,
|
||||
y,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
alpha: 1,
|
||||
color: color,
|
||||
decay: Math.random() * 0.01 + 0.005
|
||||
})
|
||||
}
|
||||
return particles
|
||||
}
|
||||
|
||||
const updateAndDraw = () => {
|
||||
if (!ctx || !canvas) return
|
||||
|
||||
ctx.globalCompositeOperation = "destination-out"
|
||||
ctx.fillStyle = "rgba(0, 0, 0, 0.1)"
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
ctx.globalCompositeOperation = "lighter"
|
||||
|
||||
if (Math.random() < 0.04) {
|
||||
createFirework()
|
||||
}
|
||||
|
||||
fireworks.forEach((fw) => {
|
||||
if (fw.dead) return
|
||||
|
||||
if (!fw.exploded) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(fw.x, fw.y, 2, 0, Math.PI * 2)
|
||||
ctx.fillStyle = fw.color
|
||||
ctx.fill()
|
||||
|
||||
fw.x += fw.vx
|
||||
fw.y += fw.vy
|
||||
fw.vy += 0.15
|
||||
|
||||
if (fw.vy >= 0 || fw.y <= fw.targetY) {
|
||||
fw.exploded = true
|
||||
fw.particles = createParticles(fw.x, fw.y, fw.color)
|
||||
}
|
||||
} else {
|
||||
for (let i = fw.particles.length - 1; i >= 0; i--) {
|
||||
const p = fw.particles[i]
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, 1.5, 0, Math.PI * 2)
|
||||
ctx.fillStyle = p.color
|
||||
ctx.globalAlpha = p.alpha
|
||||
ctx.fill()
|
||||
ctx.globalAlpha = 1
|
||||
|
||||
p.x += p.vx
|
||||
p.y += p.vy
|
||||
p.vy += 0.1
|
||||
p.alpha -= p.decay
|
||||
|
||||
if (p.alpha <= 0) {
|
||||
fw.particles.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
if (fw.particles.length === 0) {
|
||||
fw.dead = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
fireworks = fireworks.filter(fw => !fw.dead)
|
||||
|
||||
animationFrameId = requestAnimationFrame(updateAndDraw)
|
||||
}
|
||||
|
||||
resizeCanvas()
|
||||
updateAndDraw()
|
||||
|
||||
window.addEventListener("resize", resizeCanvas)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
window.removeEventListener("resize", resizeCanvas)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="fixed inset-0 pointer-events-none z-50"
|
||||
style={{ background: "transparent" }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import {Slot} from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
} from "react-hook-form"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
import {Label} from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : props.children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
"use client"
|
||||
|
||||
import {CropIcon, RotateCcwIcon} from "lucide-react"
|
||||
import {Slot} from "@radix-ui/react-slot"
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type CSSProperties,
|
||||
type MouseEvent,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
type SyntheticEvent,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import ReactCrop, {
|
||||
centerCrop,
|
||||
makeAspectCrop,
|
||||
type PercentCrop,
|
||||
type PixelCrop,
|
||||
type ReactCropProps,
|
||||
} from "react-image-crop"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
import "react-image-crop/dist/ReactCrop.css"
|
||||
|
||||
const centerAspectCrop = (
|
||||
mediaWidth: number,
|
||||
mediaHeight: number,
|
||||
aspect: number | undefined,
|
||||
): PercentCrop =>
|
||||
centerCrop(
|
||||
aspect
|
||||
? makeAspectCrop(
|
||||
{
|
||||
unit: "%",
|
||||
width: 90,
|
||||
},
|
||||
aspect,
|
||||
mediaWidth,
|
||||
mediaHeight,
|
||||
)
|
||||
: { x: 0, y: 0, width: 90, height: 90, unit: "%" },
|
||||
mediaWidth,
|
||||
mediaHeight,
|
||||
)
|
||||
|
||||
const getCroppedPngImage = async (
|
||||
imageSrc: HTMLImageElement,
|
||||
scaleFactor: number,
|
||||
pixelCrop: PixelCrop,
|
||||
maxImageSize: number,
|
||||
): Promise<string> => {
|
||||
const canvas = document.createElement("canvas")
|
||||
const ctx = canvas.getContext("2d")
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error("Context is null, this should never happen.")
|
||||
}
|
||||
|
||||
const scaleX = imageSrc.naturalWidth / imageSrc.width
|
||||
const scaleY = imageSrc.naturalHeight / imageSrc.height
|
||||
|
||||
ctx.imageSmoothingEnabled = false
|
||||
canvas.width = pixelCrop.width
|
||||
canvas.height = pixelCrop.height
|
||||
|
||||
ctx.drawImage(
|
||||
imageSrc,
|
||||
pixelCrop.x * scaleX,
|
||||
pixelCrop.y * scaleY,
|
||||
pixelCrop.width * scaleX,
|
||||
pixelCrop.height * scaleY,
|
||||
0,
|
||||
0,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
)
|
||||
|
||||
const croppedImageUrl = canvas.toDataURL("image/png")
|
||||
const response = await fetch(croppedImageUrl)
|
||||
const blob = await response.blob()
|
||||
|
||||
if (blob.size > maxImageSize) {
|
||||
return await getCroppedPngImage(imageSrc, scaleFactor * 0.9, pixelCrop, maxImageSize)
|
||||
}
|
||||
|
||||
return croppedImageUrl
|
||||
}
|
||||
|
||||
interface ImageCropContextType {
|
||||
file: File
|
||||
maxImageSize: number
|
||||
imgSrc: string
|
||||
crop: PercentCrop | undefined
|
||||
completedCrop: PixelCrop | null
|
||||
imgRef: RefObject<HTMLImageElement | null>
|
||||
onCrop?: (croppedImage: string) => void
|
||||
reactCropProps: Omit<ReactCropProps, "onChange" | "onComplete" | "children">
|
||||
handleChange: (pixelCrop: PixelCrop, percentCrop: PercentCrop) => void
|
||||
handleComplete: (pixelCrop: PixelCrop, percentCrop: PercentCrop) => Promise<void>
|
||||
onImageLoad: (e: SyntheticEvent<HTMLImageElement>) => void
|
||||
applyCrop: () => Promise<void>
|
||||
resetCrop: () => void
|
||||
}
|
||||
|
||||
const ImageCropContext = createContext<ImageCropContextType | null>(null)
|
||||
|
||||
const useImageCrop = () => {
|
||||
const context = useContext(ImageCropContext)
|
||||
if (!context) {
|
||||
throw new Error("ImageCrop components must be used within ImageCrop")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export type ImageCropProps = {
|
||||
file: File
|
||||
maxImageSize?: number
|
||||
onCrop?: (croppedImage: string) => void
|
||||
children: ReactNode
|
||||
onChange?: ReactCropProps["onChange"]
|
||||
onComplete?: ReactCropProps["onComplete"]
|
||||
} & Omit<ReactCropProps, "onChange" | "onComplete" | "children">
|
||||
|
||||
export const ImageCrop = ({
|
||||
file,
|
||||
maxImageSize = 1024 * 1024 * 5,
|
||||
onCrop,
|
||||
children,
|
||||
onChange,
|
||||
onComplete,
|
||||
...reactCropProps
|
||||
}: ImageCropProps) => {
|
||||
const imgRef = useRef<HTMLImageElement | null>(null)
|
||||
const [imgSrc, setImgSrc] = useState<string>("")
|
||||
const [crop, setCrop] = useState<PercentCrop>()
|
||||
const [completedCrop, setCompletedCrop] = useState<PixelCrop | null>(null)
|
||||
const [initialCrop, setInitialCrop] = useState<PercentCrop>()
|
||||
|
||||
useEffect(() => {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener("load", () => setImgSrc(reader.result?.toString() || ""))
|
||||
reader.readAsDataURL(file)
|
||||
}, [file])
|
||||
|
||||
const onImageLoad = useCallback(
|
||||
(e: SyntheticEvent<HTMLImageElement>) => {
|
||||
const { width, height } = e.currentTarget
|
||||
const newCrop = centerAspectCrop(width, height, reactCropProps.aspect)
|
||||
setCrop(newCrop)
|
||||
setInitialCrop(newCrop)
|
||||
},
|
||||
[reactCropProps.aspect],
|
||||
)
|
||||
|
||||
const handleChange = (pixelCrop: PixelCrop, percentCrop: PercentCrop) => {
|
||||
setCrop(percentCrop)
|
||||
onChange?.(pixelCrop, percentCrop)
|
||||
}
|
||||
|
||||
const handleComplete = async (pixelCrop: PixelCrop, percentCrop: PercentCrop) => {
|
||||
setCompletedCrop(pixelCrop)
|
||||
onComplete?.(pixelCrop, percentCrop)
|
||||
}
|
||||
|
||||
const applyCrop = async () => {
|
||||
if (!(imgRef.current && completedCrop)) {
|
||||
return
|
||||
}
|
||||
|
||||
const croppedImage = await getCroppedPngImage(imgRef.current, 1, completedCrop, maxImageSize)
|
||||
|
||||
onCrop?.(croppedImage)
|
||||
}
|
||||
|
||||
const resetCrop = () => {
|
||||
if (initialCrop) {
|
||||
setCrop(initialCrop)
|
||||
setCompletedCrop(null)
|
||||
}
|
||||
}
|
||||
|
||||
const contextValue: ImageCropContextType = {
|
||||
file,
|
||||
maxImageSize,
|
||||
imgSrc,
|
||||
crop,
|
||||
completedCrop,
|
||||
imgRef,
|
||||
onCrop,
|
||||
reactCropProps,
|
||||
handleChange,
|
||||
handleComplete,
|
||||
onImageLoad,
|
||||
applyCrop,
|
||||
resetCrop,
|
||||
}
|
||||
|
||||
return <ImageCropContext.Provider value={contextValue}>{children}</ImageCropContext.Provider>
|
||||
}
|
||||
|
||||
export interface ImageCropContentProps {
|
||||
style?: CSSProperties
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const ImageCropContent = ({ style, className }: ImageCropContentProps) => {
|
||||
const { imgSrc, crop, handleChange, handleComplete, onImageLoad, imgRef, reactCropProps } =
|
||||
useImageCrop()
|
||||
|
||||
const shadcnStyle = {
|
||||
"--rc-border-color": "var(--color-border)",
|
||||
"--rc-focus-color": "var(--color-primary)",
|
||||
} as CSSProperties
|
||||
|
||||
return (
|
||||
<ReactCrop
|
||||
className={cn("max-h-[277px] max-w-full", className)}
|
||||
crop={crop}
|
||||
onChange={handleChange}
|
||||
onComplete={handleComplete}
|
||||
style={{ ...shadcnStyle, ...style }}
|
||||
{...reactCropProps}
|
||||
>
|
||||
{imgSrc && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
alt="crop"
|
||||
className="size-full"
|
||||
height={400}
|
||||
onLoad={onImageLoad}
|
||||
ref={imgRef}
|
||||
src={imgSrc}
|
||||
width={400}
|
||||
/>
|
||||
)}
|
||||
</ReactCrop>
|
||||
)
|
||||
}
|
||||
|
||||
export type ImageCropApplyProps = ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
export const ImageCropApply = ({
|
||||
asChild = false,
|
||||
children,
|
||||
onClick,
|
||||
...props
|
||||
}: ImageCropApplyProps) => {
|
||||
const { applyCrop } = useImageCrop()
|
||||
|
||||
const handleClick = async (e: MouseEvent<HTMLButtonElement>) => {
|
||||
await applyCrop()
|
||||
onClick?.(e)
|
||||
}
|
||||
|
||||
if (asChild) {
|
||||
return (
|
||||
<Slot onClick={handleClick} {...(props as ComponentProps<"button">)}>
|
||||
{children}
|
||||
</Slot>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button onClick={handleClick} size="icon" variant="ghost" {...(props as ComponentProps<"button">)}>
|
||||
{children ?? <CropIcon className="size-4" />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export type ImageCropResetProps = ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
export const ImageCropReset = ({
|
||||
asChild = false,
|
||||
children,
|
||||
onClick,
|
||||
...props
|
||||
}: ImageCropResetProps) => {
|
||||
const { resetCrop } = useImageCrop()
|
||||
|
||||
const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
resetCrop()
|
||||
onClick?.(e)
|
||||
}
|
||||
|
||||
if (asChild) {
|
||||
return (
|
||||
<Slot onClick={handleClick} {...(props as ComponentProps<"button">)}>
|
||||
{children}
|
||||
</Slot>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button onClick={handleClick} size="icon" variant="ghost" {...(props as ComponentProps<"button">)}>
|
||||
{children ?? <RotateCcwIcon className="size-4" />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// Keep the original Cropper component for backward compatibility
|
||||
export type CropperProps = Omit<ReactCropProps, "onChange"> & {
|
||||
file: File
|
||||
maxImageSize?: number
|
||||
onCrop?: (croppedImage: string) => void
|
||||
onChange?: ReactCropProps["onChange"]
|
||||
}
|
||||
|
||||
export const Cropper = ({
|
||||
onChange,
|
||||
onComplete,
|
||||
onCrop,
|
||||
style,
|
||||
className,
|
||||
file,
|
||||
maxImageSize,
|
||||
...props
|
||||
}: CropperProps) => (
|
||||
<ImageCrop
|
||||
file={file}
|
||||
maxImageSize={maxImageSize}
|
||||
onChange={onChange}
|
||||
onComplete={onComplete}
|
||||
onCrop={onCrop}
|
||||
{...(props as Omit<ReactCropProps, "onChange" | "onComplete" | "children">)}
|
||||
>
|
||||
<ImageCropContent className={className} style={style} />
|
||||
</ImageCrop>
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {OTPInput, OTPInputContext} from "input-otp"
|
||||
import {MinusIcon} from "lucide-react"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
const ErrorContext = React.createContext<boolean>(false)
|
||||
|
||||
function InputOTP({
|
||||
className,
|
||||
containerClassName,
|
||||
error,
|
||||
...props
|
||||
}: React.ComponentProps<typeof OTPInput> & {
|
||||
containerClassName?: string
|
||||
error?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ErrorContext.Provider value={error || false}>
|
||||
<OTPInput
|
||||
data-slot="input-otp"
|
||||
containerClassName={cn(
|
||||
"flex items-center gap-2 has-disabled:opacity-50",
|
||||
containerClassName
|
||||
)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
</ErrorContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-group"
|
||||
className={cn("flex items-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPSlot({
|
||||
index,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
index: number
|
||||
mask?: boolean
|
||||
}) {
|
||||
const inputOTPContext = React.useContext(OTPInputContext)
|
||||
const hasError = React.useContext(ErrorContext)
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
|
||||
|
||||
const slots = inputOTPContext?.slots ?? []
|
||||
const isAllFilled = slots.length >= 6 && slots.every(slot => slot.char)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"relative flex h-9 w-9 items-center justify-center border text-sm shadow-xs transition-all outline-none first:rounded-l-md last:rounded-r-md data-[active=true]:z-10",
|
||||
// 基础边框样式 - 错误时使用红色边框
|
||||
hasError ? "border-destructive dark:bg-input/30" : "border-input dark:bg-input/30",
|
||||
// 聚焦样式 - 错误时使用红色聚焦边框
|
||||
"data-[active=true]:ring-0 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40",
|
||||
hasError
|
||||
? "data-[active=true]:border-destructive"
|
||||
: "data-[active=true]:border-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{isAllFilled || (char && !isActive) ? "●" : char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||
<MinusIcon />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-8 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-xs file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/30 focus-visible:ring-[2px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,28 @@
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd"
|
||||
className={cn(
|
||||
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1.5 font-sans text-xs font-medium text-muted-foreground select-none",
|
||||
"[&_svg:not([class*='size-'])]:size-3",
|
||||
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd-group"
|
||||
className={cn("inline-flex items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Kbd, KbdGroup }
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-xs leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,127 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants, type Button } from "@/components/ui/button"
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="pagination-item" {...props} />
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||
React.ComponentProps<"a">
|
||||
|
||||
function PaginationLink({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) {
|
||||
return (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationPrevious({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
<span className="hidden sm:block">Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationNext({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="hidden sm:block">Next</span>
|
||||
<ChevronRightIcon />
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="pagination-ellipsis"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
type ScrollAreaProps = React.ComponentProps<typeof ScrollAreaPrimitive.Root> & {
|
||||
viewportRef?: React.Ref<HTMLDivElement>
|
||||
}
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
viewportRef,
|
||||
...props
|
||||
}: ScrollAreaProps) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={viewportRef}
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import {CheckIcon, ChevronDownIcon, ChevronUpIcon} from "lucide-react"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"cursor-pointer border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-8 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import {XIcon} from "lucide-react"
|
||||
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-svh w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-svh w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user