This commit is contained in:
ryan
2026-07-13 15:17:45 +08:00
parent 08e61ce833
commit e0eb4e5975
334 changed files with 17212 additions and 12321 deletions
+11 -11
View File
@@ -5,9 +5,9 @@ import { motion, type Variants } from 'motion/react';
import {
getVariants,
useAnimateIconContext,
IconWrapper,
type IconProps,
IconWrapper,
useAnimateIconContext,
} from '@/components/animate-ui/icons/icon';
type CheckProps = IconProps<keyof typeof animations>;
@@ -39,23 +39,23 @@ function IconComponent({ size, ...props }: CheckProps) {
return (
<motion.svg
xmlns="http://www.w3.org/2000/svg"
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
initial="initial"
strokeLinecap='round'
strokeLinejoin='round'
initial='initial'
animate={controls}
{...props}
>
<motion.path
d="m4 12 5 5L20 6"
d='m4 12 5 5L20 6'
variants={variants.path}
initial="initial"
initial='initial'
animate={controls}
/>
</motion.svg>
@@ -5,9 +5,9 @@ import { motion, type Variants } from 'motion/react';
import {
getVariants,
useAnimateIconContext,
IconWrapper,
type IconProps,
IconWrapper,
useAnimateIconContext,
} from '@/components/animate-ui/icons/icon';
type ChevronDownProps = IconProps<keyof typeof animations>;
@@ -44,21 +44,21 @@ function IconComponent({ size, ...props }: ChevronDownProps) {
return (
<motion.svg
xmlns="http://www.w3.org/2000/svg"
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
strokeLinecap='round'
strokeLinejoin='round'
{...props}
>
<motion.path
d="m6 9 6 6 6-6"
d='m6 9 6 6 6-6'
variants={variants.path}
initial="initial"
initial='initial'
animate={controls}
/>
</motion.svg>
@@ -5,9 +5,9 @@ import { motion, type Variants } from 'motion/react';
import {
getVariants,
useAnimateIconContext,
IconWrapper,
type IconProps,
IconWrapper,
useAnimateIconContext,
} from '@/components/animate-ui/icons/icon';
type ChevronLeftProps = IconProps<keyof typeof animations>;
@@ -44,21 +44,21 @@ function IconComponent({ size, ...props }: ChevronLeftProps) {
return (
<motion.svg
xmlns="http://www.w3.org/2000/svg"
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
strokeLinecap='round'
strokeLinejoin='round'
{...props}
>
<motion.path
d="m15 18-6-6 6-6"
d='m15 18-6-6 6-6'
variants={variants.path}
initial="initial"
initial='initial'
animate={controls}
/>
</motion.svg>
@@ -5,9 +5,9 @@ import { motion, type Variants } from 'motion/react';
import {
getVariants,
useAnimateIconContext,
IconWrapper,
type IconProps,
IconWrapper,
useAnimateIconContext,
} from '@/components/animate-ui/icons/icon';
type ChevronRightProps = IconProps<keyof typeof animations>;
@@ -44,21 +44,21 @@ function IconComponent({ size, ...props }: ChevronRightProps) {
return (
<motion.svg
xmlns="http://www.w3.org/2000/svg"
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
strokeLinecap='round'
strokeLinejoin='round'
{...props}
>
<motion.path
d="m9 18 6-6-6-6"
d='m9 18 6-6-6-6'
variants={variants.path}
initial="initial"
initial='initial'
animate={controls}
/>
</motion.svg>
@@ -5,9 +5,9 @@ import { motion, type Variants } from 'motion/react';
import {
getVariants,
useAnimateIconContext,
IconWrapper,
type IconProps,
IconWrapper,
useAnimateIconContext,
} from '@/components/animate-ui/icons/icon';
type CircleCheckProps = IconProps<keyof typeof animations>;
@@ -40,29 +40,29 @@ function IconComponent({ size, ...props }: CircleCheckProps) {
return (
<motion.svg
xmlns="http://www.w3.org/2000/svg"
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
strokeLinecap='round'
strokeLinejoin='round'
{...props}
>
<motion.circle
cx="12"
cy="12"
r="10"
cx='12'
cy='12'
r='10'
variants={variants.circle}
initial="initial"
initial='initial'
animate={controls}
/>
<motion.path
d="m9 12 2 2 4-4"
d='m9 12 2 2 4-4'
variants={variants.path}
initial="initial"
initial='initial'
animate={controls}
/>
</motion.svg>
+10 -7
View File
@@ -2,18 +2,21 @@
import * as React from 'react';
import {
motion,
useAnimation,
type SVGMotionProps,
type UseInViewOptions,
type LegacyAnimationControls,
type Variants,
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';
import {
Slot,
type WithAsChild,
} from '@/components/animate-ui/primitives/animate/slot';
const staticAnimations = {
path: {
+13 -13
View File
@@ -5,9 +5,9 @@ import { motion, type Variants } from 'motion/react';
import {
getVariants,
useAnimateIconContext,
IconWrapper,
type IconProps,
IconWrapper,
useAnimateIconContext,
} from '@/components/animate-ui/icons/icon';
type SendProps = IconProps<keyof typeof animations>;
@@ -50,28 +50,28 @@ function IconComponent({ size, ...props }: SendProps) {
return (
<motion.svg
xmlns="http://www.w3.org/2000/svg"
xmlns='http://www.w3.org/2000/svg'
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
strokeLinecap='round'
strokeLinejoin='round'
{...props}
>
<motion.g variants={variants.group} initial="initial" animate={controls}>
<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"
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"
initial='initial'
animate={controls}
/>
<motion.path
d="M21.9,2.1l-10.9,10.9"
d='M21.9,2.1l-10.9,10.9'
variants={variants.path2}
initial="initial"
initial='initial'
animate={controls}
/>
</motion.g>
@@ -1,9 +1,12 @@
'use client';
import * as React from 'react';
import { motion, type HTMLMotionProps } from 'motion/react';
import { type HTMLMotionProps, motion } from 'motion/react';
import { Slot, type WithAsChild } from '@/components/animate-ui/primitives/animate/slot';
import {
Slot,
type WithAsChild,
} from '@/components/animate-ui/primitives/animate/slot';
type ButtonProps = WithAsChild<
HTMLMotionProps<'button'> & {
@@ -1,10 +1,13 @@
'use client';
import * as React from 'react';
import { motion, type HTMLMotionProps } from 'motion/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';
import {
Slot,
type WithAsChild,
} from '@/components/animate-ui/primitives/animate/slot';
type Ripple = {
id: number;
@@ -80,7 +83,7 @@ function RippleButton({
<RippleButtonProvider value={{ ripples, setRipples }}>
<Component
ref={buttonRef}
data-slot="ripple-button"
data-slot='ripple-button'
onClick={handleClick}
whileTap={{ scale: tapScale }}
whileHover={{ scale: hoverScale }}
@@ -2,7 +2,7 @@
import * as React from 'react';
import { Collapsible as CollapsiblePrimitive } from 'radix-ui';
import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
import { AnimatePresence, type HTMLMotionProps, motion } from 'motion/react';
import { getStrictContext } from '@/lib/utils/get-strict-context';
import { useControlledState } from '@/hooks/use-controlled-state';
@@ -27,7 +27,7 @@ function Collapsible(props: CollapsibleProps) {
return (
<CollapsibleProvider value={{ isOpen, setIsOpen }}>
<CollapsiblePrimitive.Root
data-slot="collapsible"
data-slot='collapsible'
{...props}
onOpenChange={setIsOpen}
/>
@@ -41,7 +41,7 @@ type CollapsibleTriggerProps = React.ComponentProps<
function CollapsibleTrigger(props: CollapsibleTriggerProps) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
<CollapsiblePrimitive.Trigger data-slot='collapsible-trigger' {...props} />
);
}
@@ -65,8 +65,8 @@ function CollapsibleContent({
{keepRendered ? (
<CollapsiblePrimitive.Content asChild forceMount>
<motion.div
key="collapsible-content"
data-slot="collapsible-content"
key='collapsible-content'
data-slot='collapsible-content'
layout
initial={{ opacity: 0, height: 0, overflow: 'hidden', y: 20 }}
animate={
@@ -82,8 +82,8 @@ function CollapsibleContent({
isOpen && (
<CollapsiblePrimitive.Content asChild forceMount>
<motion.div
key="collapsible-content"
data-slot="collapsible-content"
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 }}
@@ -3,10 +3,7 @@
import * as React from 'react';
import { useMotionValue, useSpring, type SpringOptions } from 'motion/react';
import {
useIsInView,
type UseIsInViewOptions,
} from '@/hooks/use-is-in-view';
import { useIsInView, type UseIsInViewOptions } from '@/hooks/use-is-in-view';
type CountingNumberProps = Omit<React.ComponentProps<'span'>, 'children'> & {
number: number;
@@ -81,7 +78,7 @@ function CountingNumber({
const [intPart, fracPart] = formatted.split(decimalSeparator);
const paddedInt = intPart?.padStart(finalIntLength, '0') ?? '';
formatted = fracPart
? `${ paddedInt }${ decimalSeparator }${ fracPart }`
? `${paddedInt}${decimalSeparator}${fracPart}`
: paddedInt;
}
@@ -90,7 +87,7 @@ function CountingNumber({
const regex = /\B(?=(\d{3})+(?!\d))/g;
const separatedInt = intPart.replace(regex, thousandSeparator);
formatted = fracPart
? `${ separatedInt }${ decimalSeparator }${ fracPart }`
? `${separatedInt}${decimalSeparator}${fracPart}`
: separatedInt;
}
@@ -98,7 +95,15 @@ function CountingNumber({
}
});
return () => unsubscribe();
}, [springVal, decimals, padStart, number, decimalSeparator, localRef, thousandSeparator]);
}, [
springVal,
decimals,
padStart,
number,
decimalSeparator,
localRef,
thousandSeparator,
]);
const finalIntLength = Math.floor(Math.abs(number)).toString().length;
@@ -108,26 +113,28 @@ function CountingNumber({
if (padStart) {
const [intPart, fracPart] = out.split(decimalSeparator);
const paddedInt = (intPart ?? '').padStart(finalIntLength, '0');
out = fracPart ? `${ paddedInt }${ decimalSeparator }${ fracPart }` : paddedInt;
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;
out = fracPart
? `${separatedInt}${decimalSeparator}${fracPart}`
: separatedInt;
}
return out;
};
const zeroText = padStart
? '0'.padStart(finalIntLength, '0') +
(decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : '')
(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}>
<span ref={localRef} data-slot='counting-number' {...props}>
{initialText}
</span>
);
+36 -32
View File
@@ -1,67 +1,71 @@
"use client"
'use client';
import type {ReactNode} from "react"
import {motion} from "motion/react"
import {WavesIcon} from "lucide-react"
import type { ReactNode } from 'react';
import { motion } from 'motion/react';
import { WavesIcon } from 'lucide-react';
import {cn} from "@/lib/utils"
import { cn } from '@/lib/utils';
interface AuthShellProps {
children: ReactNode
wide?: boolean
children: ReactNode;
wide?: boolean;
}
export function AuthShell({children, wide = false}: AuthShellProps) {
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">
<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)]"
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"
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"}}
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",
'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
title: string;
description: string;
siteName?: string;
}
export function AuthHeading({
title,
description,
siteName = "Wavelet",
siteName = 'Wavelet',
}: 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" />
<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>
<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">
<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>
<p className='text-sm text-muted-foreground [@media(min-height:900px)]:text-base'>
{description}
</p>
</div>
</header>
)
);
}
+59 -46
View File
@@ -1,20 +1,26 @@
'use client'
'use client';
import {useEffect, useRef, useState} from 'react'
import {CheckCircle2, Loader2, ShieldAlert, ShieldCheck, ShieldQuestion} from 'lucide-react'
import {getCapToken} from '@/lib/cap-solver'
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'
type CapStatus = 'idle' | 'solving' | 'solved' | 'error';
interface CapWidgetProps {
/** Called with the one-time token once the challenge is solved */
onToken: (token: string) => void
onToken: (token: string) => void;
/** Called when the challenge fails or encounters an error */
onError?: (err: Error) => void
onError?: (err: Error) => void;
/** Whether to auto-start solving when the component mounts. Defaults to true. */
autoStart?: boolean
autoStart?: boolean;
/** PoW scope sent to the backend */
scope?: string
scope?: string;
}
/**
@@ -23,88 +29,95 @@ interface CapWidgetProps {
* 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)
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('')
if (solving.current) return;
solving.current = true;
setStatus('solving');
setErrorMsg('');
try {
const token = await getCapToken(scope)
setStatus('solved')
onToken(token)
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)
const error = err instanceof Error ? err : new Error(String(err));
setStatus('error');
setErrorMsg(error.message);
onError?.(error);
} finally {
solving.current = false
solving.current = false;
}
}
};
// Auto-start on mount (only when autoStart is enabled)
useEffect(() => {
if (autoStart) {
void solve()
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">
<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"
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>
<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>
<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>
<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" />
<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"
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">
<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>
<span className='text-xs text-muted-foreground underline'>重试</span>
</button>
)}
</div>
)
);
}
+196 -171
View File
@@ -1,213 +1,235 @@
"use client"
'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 { 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"
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 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"
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)
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])
onOTPStateChange?.(showLoginCodeInput);
}, [showLoginCodeInput, onOTPStateChange]);
useEffect(() => {
if (loginCooldown > 0) {
const timer = setTimeout(() => setLoginCooldown(loginCooldown - 1), 1000)
return () => clearTimeout(timer)
const timer = setTimeout(() => setLoginCooldown(loginCooldown - 1), 1000);
return () => clearTimeout(timer);
}
}, [loginCooldown])
}, [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 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"],
queryKey: ['public-config'],
queryFn: () => ConfigService.getPublicConfig(),
})
});
const authSourcesQuery = useQuery({
queryKey: ["auth-sources"],
queryKey: ['auth-sources'],
queryFn: () => AuthService.getAuthSources(),
})
});
const capEnabled = configBool(publicConfigQuery.data?.cap_login_enabled, false)
const capAutoSolve = configBool(publicConfigQuery.data?.cap_auto_solve, true)
const capEnabled = configBool(
publicConfigQuery.data?.cap_login_enabled,
false,
);
const capAutoSolve = configBool(publicConfigQuery.data?.cap_auto_solve, true);
const loginMutation = useMutation({
mutationFn: (req: LoginRequest) => {
const headers: Record<string, string> = {}
const headers: Record<string, string> = {};
if (capEnabled && capTokenRef.current) {
headers["X-Cap-Token"] = capTokenRef.current
headers['X-Cap-Token'] = capTokenRef.current;
// Consume the token — next login attempt will need a new one
capTokenRef.current = null
setCapReady(false)
capTokenRef.current = null;
setCapReady(false);
}
return AuthService.login(req, Object.keys(headers).length ? headers : undefined)
return AuthService.login(
req,
Object.keys(headers).length ? headers : undefined,
);
},
onSuccess: (user) => {
setUser(user)
toast.success("登录成功")
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)
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("登录验证码已发送至您的邮箱,请注意查收")
已向您的安全邮箱{' '}
<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)
capTokenRef.current = null;
setCapReady(false);
setCapResetKey((key) => key + 1);
}
return
return;
}
if (errorMsg.startsWith("smtp_invalid:")) {
const tip = errorMsg.substring("smtp_invalid:".length)
setLoginCodeTip("请输入您的登录验证码。")
setShowLoginCodeInput(true)
setLoginCooldown(0)
toast.warning(tip)
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)
capTokenRef.current = null;
setCapReady(false);
setCapResetKey((key) => key + 1);
}
return
return;
}
setErrorMessage(errorMsg || "登录失败,请重试")
setErrorMessage(errorMsg || '登录失败,请重试');
if (capEnabled) {
capTokenRef.current = null
setCapReady(false)
setCapResetKey((key) => key + 1)
capTokenRef.current = null;
setCapReady(false);
setCapResetKey((key) => key + 1);
}
},
})
});
const handlePasswordLogin = () => {
setErrorMessage("")
const trimmedUsername = username.trim()
setErrorMessage('');
const trimmedUsername = username.trim();
if (!trimmedUsername || !password) {
toast.error("邮箱/用户名或密码未填写完整", {
description: "请先输入邮箱/用户名和密码后再登录",
})
return
toast.error('邮箱/用户名或密码未填写完整', {
description: '请先输入邮箱/用户名和密码后再登录',
});
return;
}
if (capEnabled && !capReady) {
toast.error(
capAutoSolve
? "人机验证尚未完成,请稍候…"
: "请先点击「开始验证」完成人机验证",
)
return
? '人机验证尚未完成,请稍候…'
: '请先点击「开始验证」完成人机验证',
);
return;
}
loginMutation.mutate({
username: trimmedUsername,
password,
code: showLoginCodeInput ? code.trim() : undefined,
})
}
});
};
const handleResendLoginCode = () => {
setCode("")
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
setErrorMessage('');
persistRedirectTarget(searchParams);
const { authorize_url } = await AuthService.getAuthorizeUrl(sourceName);
window.location.href = authorize_url;
} catch (error) {
toast.error(error instanceof Error ? error.message : "第三方登录失败")
toast.error(error instanceof Error ? error.message : '第三方登录失败');
}
}
};
const handleCapToken = (token: string) => {
capTokenRef.current = token
setCapReady(true)
setCapError(false)
}
capTokenRef.current = token;
setCapReady(true);
setCapError(false);
};
const handleCapError = () => {
capTokenRef.current = null
setCapReady(false)
setCapError(true)
}
capTokenRef.current = null;
setCapReady(false);
setCapError(true);
};
const registrationEnabled =
configBool(publicConfigQuery.data?.registration_enabled, true) &&
configBool(publicConfigQuery.data?.password_register_enabled, true)
configBool(publicConfigQuery.data?.password_register_enabled, true);
const passwordLoginEnabled = configBool(publicConfigQuery.data?.password_login_enabled, true)
const oidcLoginEnabled = configBool(publicConfigQuery.data?.oidc_login_enabled, true)
const authSources = oidcLoginEnabled ? (authSourcesQuery.data ?? []) : []
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)
(capEnabled && capAutoSolve && !capReady && !capError);
if (publicConfigQuery.isPending) {
return (
<div className="flex items-center justify-center py-24">
<div className='flex items-center justify-center py-24'>
<Spinner />
</div>
)
);
}
if (showLoginCodeInput) {
@@ -221,54 +243,54 @@ export function LoginForm({ onOTPStateChange }: { onOTPStateChange?: (show: bool
onResend={handleResendLoginCode}
onSubmit={handlePasswordLogin}
/>
)
);
}
return (
<div className="flex flex-col gap-6 [@media(max-height:700px)]:gap-4">
<div className='flex flex-col gap-6 [@media(max-height:700px)]:gap-4'>
<AuthHeading
siteName={publicConfigQuery.data?.site_name}
title="登录到您的账号"
description="欢迎回来,请输入您的账号信息继续。"
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>
<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"
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()}
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>
<Field className='gap-1.5'>
<FieldLabel htmlFor='password'>
密码 <span className='text-destructive'>*</span>
</FieldLabel>
<div className="relative">
<div className='relative'>
<Input
id="password"
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()}
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"
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 />}
@@ -288,15 +310,15 @@ export function LoginForm({ onOTPStateChange }: { onOTPStateChange?: (show: bool
)}
{errorMessage ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
<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"
type='button'
className='h-10 w-full [@media(max-height:700px)]:h-9'
variant='auth'
onClick={handlePasswordLogin}
disabled={loginDisabled}
>
@@ -306,25 +328,25 @@ export function LoginForm({ onOTPStateChange }: { onOTPStateChange?: (show: bool
登录中...
</>
) : (
"登录"
'登录'
)}
</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 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">
<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"
type='button'
variant='outline'
className='h-10 [@media(max-height:700px)]:h-9'
onClick={() => void handleOAuthLogin(source.name)}
>
{source.display_name || source.name} 登录
@@ -335,13 +357,16 @@ export function LoginForm({ onOTPStateChange }: { onOTPStateChange?: (show: bool
) : null}
{registrationEnabled && (
<div className="text-center text-sm text-muted-foreground">
还没有账号?{" "}
<Link href="/register" className="font-medium text-foreground underline underline-offset-4">
<div className='text-center text-sm text-muted-foreground'>
还没有账号?{' '}
<Link
href='/register'
className='font-medium text-foreground underline underline-offset-4'
>
立即注册
</Link>
</div>
)}
</div>
)
);
}
+106 -89
View File
@@ -1,18 +1,17 @@
"use client"
'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"
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';
/**
* 登录页面组件
@@ -25,151 +24,169 @@ import {safeRedirectTarget} from "@/lib/utils"
* @returns {React.ReactNode} 登录页面组件
*/
export function LoginPage() {
const router = useRouter()
const searchParams = useSearchParams()
const { user, loading, setUser } = useAuth()
const [showOTP, setShowOTP] = useState(false)
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 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 [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 || '/home'
const callbackUrl = searchParams.get('callbackUrl');
const storedRedirect = sessionStorage.getItem('redirect_after_login');
const target = callbackUrl || storedRedirect || '/home';
if (storedRedirect) {
sessionStorage.removeItem('redirect_after_login')
sessionStorage.removeItem('redirect_after_login');
}
return safeRedirectTarget(target)
}, [searchParams])
return safeRedirectTarget(target);
}, [searchParams]);
const resolveRedirectTargetRef = useRef(resolveRedirectTarget)
const resolveRedirectTargetRef = useRef(resolveRedirectTarget);
useEffect(() => {
resolveRedirectTargetRef.current = resolveRedirectTarget
}, [resolveRedirectTarget])
resolveRedirectTargetRef.current = resolveRedirectTarget;
}, [resolveRedirectTarget]);
/* 登录页兜底:已登录用户直接跳转 */
useEffect(() => {
const state = searchParams.get('state')
const code = searchParams.get('code')
const state = searchParams.get('state');
const code = searchParams.get('code');
if ((state && code) || loading || !user) {
return
return;
}
if (!redirectedRef.current) {
redirectedRef.current = true
router.replace(resolveRedirectTargetRef.current())
redirectedRef.current = true;
router.replace(resolveRedirectTargetRef.current());
}
}, [loading, router, searchParams, user])
}, [loading, router, searchParams, user]);
/* 回调逻辑 */
useEffect(() => {
const handleOAuthCallback = async () => {
const state = searchParams.get('state')
const code = searchParams.get('code')
const state = searchParams.get('state');
const code = searchParams.get('code');
if (state && code) {
if (callbackProcessedRef.current) return
callbackProcessedRef.current = true
if (callbackProcessedRef.current) return;
callbackProcessedRef.current = true;
setIsProcessingCallback(true)
setIsProcessingCallback(true);
try {
const result = await AuthService.handleCallback({ state, code })
if (result.status === "need_bind") {
toast.info("您的第三方账号未绑定本地账号,系统已关闭注册。请登录已有本地账号进行绑定。")
setIsProcessingCallback(false)
router.replace('/login')
return
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)
setUser(result.user);
}
setLoginSuccess(true)
toast.success(result.status === "bound" ? "绑定成功" : "登录成功")
setLoginSuccess(true);
toast.success(result.status === 'bound' ? '绑定成功' : '登录成功');
setTimeout(() => {
if (!redirectedRef.current) {
redirectedRef.current = true
router.replace(resolveRedirectTargetRef.current())
redirectedRef.current = true;
router.replace(resolveRedirectTargetRef.current());
}
}, 1500)
}, 1500);
} catch (error) {
console.error('OAuth callback error:', error)
toast.error(error instanceof Error ? error.message : "登录失败,请重试")
setIsProcessingCallback(false)
router.replace('/login')
console.error('OAuth callback error:', error);
toast.error(
error instanceof Error ? error.message : '登录失败,请重试',
);
setIsProcessingCallback(false);
router.replace('/login');
}
}
}
handleOAuthCallback()
}, [router, searchParams, setUser])
};
handleOAuthCallback();
}, [router, searchParams, setUser]);
return (
<AuthShell wide={showOTP}>
<div className="w-full">
<AnimatePresence mode="wait">
<div className='w-full'>
<AnimatePresence mode='wait'>
{isProcessingCallback || isCheckingSession ? (
<motion.div
key={isProcessingCallback ? "processing" : "session-check"}
key={isProcessingCallback ? 'processing' : 'session-check'}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="w-full"
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 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 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">
<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"
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} />
<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 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 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 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"
key='login-form-wrapper'
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
className="w-full"
className='w-full'
>
<LoginForm onOTPStateChange={setShowOTP} />
</motion.div>
@@ -177,5 +194,5 @@ export function LoginPage() {
</AnimatePresence>
</div>
</AuthShell>
)
);
}
+53 -43
View File
@@ -1,22 +1,27 @@
"use client"
'use client';
import * as React from "react"
import {RefreshCwIcon} from "lucide-react"
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"
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
code: string;
setCode: (val: string) => void;
loginCodeTip: React.ReactNode;
loginCooldown: number;
isPending: boolean;
onResend: () => void;
onSubmit: () => void;
}
export function OTPForm({
@@ -29,49 +34,54 @@ export function OTPForm({
onSubmit,
}: OTPFormProps) {
return (
<div className="flex flex-col gap-6 [@media(max-height:700px)]:gap-4">
<div className='flex flex-col gap-6 [@media(max-height:700px)]:gap-4'>
<AuthHeading
title="验证您的登录"
description="输入发送到安全邮箱的 6 位验证码。"
title='验证您的登录'
description='输入发送到安全邮箱的 6 位验证码。'
/>
{loginCodeTip ? (
<p className="text-sm leading-6 text-muted-foreground">{loginCodeTip}</p>
<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">
<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"
variant='outline'
size='sm'
type='button'
onClick={onResend}
disabled={loginCooldown > 0 || isPending}
className="h-8 text-xs"
className='h-8 text-xs'
>
<RefreshCwIcon className={cn(isPending && "animate-spin")} />
{loginCooldown > 0 ? `${loginCooldown}秒后重发` : "重新发送"}
<RefreshCwIcon className={cn(isPending && 'animate-spin')} />
{loginCooldown > 0 ? `${loginCooldown}秒后重发` : '重新发送'}
</Button>
</div>
<div className="flex justify-start">
<div className='flex justify-start'>
<InputOTP
maxLength={6}
id="otp-verification"
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">
<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">
<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} />
@@ -80,24 +90,24 @@ export function OTPForm({
</div>
</Field>
<Button
type="button"
className="h-10 w-full [@media(max-height:700px)]:h-9"
variant="auth"
type='button'
className='h-10 w-full [@media(max-height:700px)]:h-9'
variant='auth'
onClick={onSubmit}
disabled={isPending || code.length < 6}
>
{isPending ? "验证中..." : "验证"}
{isPending ? '验证中...' : '验证'}
</Button>
<div className="text-center text-sm text-muted-foreground">
遇到登录问题?{" "}
<div className='text-center text-sm text-muted-foreground'>
遇到登录问题?{' '}
<a
href="#"
className="underline underline-offset-4 transition-colors hover:text-primary"
href='#'
className='underline underline-offset-4 transition-colors hover:text-primary'
>
联系客服
</a>
</div>
</div>
</div>
)
);
}
+228 -188
View File
@@ -1,225 +1,250 @@
"use client"
'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 { 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"
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 callbackUrl = searchParams.get('callbackUrl');
const storedRedirect =
typeof window === "undefined"
typeof window === 'undefined'
? null
: sessionStorage.getItem("redirect_after_login")
return safeRedirectTarget(callbackUrl || storedRedirect || "/home")
: sessionStorage.getItem('redirect_after_login');
return safeRedirectTarget(callbackUrl || storedRedirect || '/home');
}
function configBool(value: string | undefined, fallback: boolean) {
if (value === undefined) return fallback
return value === "true"
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("")
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)
const timer = setTimeout(
() => setRegisterCooldown(registerCooldown - 1),
1000,
);
return () => clearTimeout(timer);
}
}, [registerCooldown])
}, [registerCooldown]);
const publicConfigQuery = useQuery({
queryKey: ["public-config"],
queryKey: ['public-config'],
queryFn: () => ConfigService.getPublicConfig(),
})
});
const redirectTarget = useMemo(
() => getRedirectTarget(searchParams),
[searchParams],
)
);
const registrationEnabled =
configBool(publicConfigQuery.data?.registration_enabled, true) &&
configBool(publicConfigQuery.data?.password_register_enabled, true)
configBool(publicConfigQuery.data?.password_register_enabled, true);
const emailRegisterEnabled = configBool(publicConfigQuery.data?.email_register_verification_enabled, false)
const emailRegisterEnabled = configBool(
publicConfigQuery.data?.email_register_verification_enabled,
false,
);
const capEnabled = configBool(publicConfigQuery.data?.cap_login_enabled, false)
const capAutoSolve = configBool(publicConfigQuery.data?.cap_auto_solve, true)
const capEnabled = configBool(
publicConfigQuery.data?.cap_login_enabled,
false,
);
const capAutoSolve = configBool(publicConfigQuery.data?.cap_auto_solve, true);
const [capScope, setCapScope] = useState<'send_email_code' | 'register'>('send_email_code')
const [capScope, setCapScope] = useState<'send_email_code' | 'register'>(
'send_email_code',
);
// 监听 emailRegisterEnabled 改变初始 scope
useEffect(() => {
setCapScope(emailRegisterEnabled ? 'send_email_code' : 'register')
}, [emailRegisterEnabled])
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 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)
}
capTokenRef.current = token;
setCapReady(true);
setCapError(false);
};
const handleCapError = () => {
capTokenRef.current = null
setCapReady(false)
setCapError(true)
}
capTokenRef.current = null;
setCapReady(false);
setCapError(true);
};
// Redirect to login if registration is closed
useEffect(() => {
if (publicConfigQuery.isSuccess && !registrationEnabled) {
toast.error("系统注册功能已关闭")
router.replace("/login")
toast.error('系统注册功能已关闭');
router.replace('/login');
}
}, [publicConfigQuery.isSuccess, registrationEnabled, router])
}, [publicConfigQuery.isSuccess, registrationEnabled, router]);
const registerMutation = useMutation({
mutationFn: (req: RegisterRequest) => {
const headers: Record<string, string> = {}
const headers: Record<string, string> = {};
if (capEnabled && capTokenRef.current) {
headers["X-Cap-Token"] = capTokenRef.current
capTokenRef.current = null
setCapReady(false)
headers['X-Cap-Token'] = capTokenRef.current;
capTokenRef.current = null;
setCapReady(false);
}
return AuthService.register(req, Object.keys(headers).length ? headers : undefined)
return AuthService.register(
req,
Object.keys(headers).length ? headers : undefined,
);
},
onSuccess: (user) => {
setUser(user)
router.replace(redirectTarget)
toast.success("注册并登录成功")
setUser(user);
router.replace(redirectTarget);
toast.success('注册并登录成功');
},
onError: (error: Error) => {
setErrorMessage(error.message || "注册失败,请重试")
setErrorMessage(error.message || '注册失败,请重试');
if (capEnabled) {
capTokenRef.current = null
setCapReady(false)
setCapResetKey((key) => key + 1)
capTokenRef.current = null;
setCapReady(false);
setCapResetKey((key) => key + 1);
}
},
})
});
const sendRegisterCodeMutation = useMutation({
mutationFn: (targetEmail: string) => {
const headers: Record<string, string> = {}
const headers: Record<string, string> = {};
if (capEnabled && capTokenRef.current) {
headers["X-Cap-Token"] = capTokenRef.current
capTokenRef.current = null
setCapReady(false)
headers['X-Cap-Token'] = capTokenRef.current;
capTokenRef.current = null;
setCapReady(false);
}
return AuthService.sendEmailCode(targetEmail, "register", Object.keys(headers).length ? headers : undefined)
return AuthService.sendEmailCode(
targetEmail,
'register',
Object.keys(headers).length ? headers : undefined,
);
},
onSuccess: () => {
setRegisterCooldown(60)
toast.success("验证码已发送至您的邮箱,请查收")
setRegisterCooldown(60);
toast.success('验证码已发送至您的邮箱,请查收');
if (capEnabled) {
setCapScope('register')
capTokenRef.current = null
setCapReady(false)
setCapResetKey((key) => key + 1)
setCapScope('register');
capTokenRef.current = null;
setCapReady(false);
setCapResetKey((key) => key + 1);
}
},
onError: (error: Error) => {
toast.error(error.message || "发送验证码失败,请重试")
toast.error(error.message || '发送验证码失败,请重试');
if (capEnabled) {
capTokenRef.current = null
setCapReady(false)
setCapResetKey((key) => key + 1)
capTokenRef.current = null;
setCapReady(false);
setCapResetKey((key) => key + 1);
}
},
})
});
const registerDisabled =
registerMutation.isPending ||
(capEnabled && capScope === 'register' && capAutoSolve && !capReady && !capError)
(capEnabled &&
capScope === 'register' &&
capAutoSolve &&
!capReady &&
!capError);
const sendCodeDisabled =
registerCooldown > 0 ||
sendRegisterCodeMutation.isPending ||
(capEnabled && capScope === 'send_email_code' && capAutoSolve && !capReady && !capError)
(capEnabled &&
capScope === 'send_email_code' &&
capAutoSolve &&
!capReady &&
!capError);
const handleSendRegisterCode = () => {
const trimmedEmail = email.trim()
const trimmedEmail = email.trim();
if (!trimmedEmail) {
toast.error("请先输入邮箱地址")
return
toast.error('请先输入邮箱地址');
return;
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(trimmedEmail)) {
toast.error("请输入有效的邮箱地址")
return
toast.error('请输入有效的邮箱地址');
return;
}
if (capEnabled && capScope === 'send_email_code' && !capReady) {
toast.error(
capAutoSolve
? "人机验证尚未完成,请稍候…"
: "请先点击「开始验证」完成人机验证",
)
return
? '人机验证尚未完成,请稍候…'
: '请先点击「开始验证」完成人机验证',
);
return;
}
sendRegisterCodeMutation.mutate(trimmedEmail)
}
sendRegisterCodeMutation.mutate(trimmedEmail);
};
const handleRegister = () => {
setErrorMessage("")
setErrorMessage('');
if (!username.trim() || !password) {
toast.error("用户名和密码不能为空")
return
toast.error('用户名和密码不能为空');
return;
}
if (password.length < 8) {
toast.error("密码长度不能少于 8 位")
return
toast.error('密码长度不能少于 8 位');
return;
}
const trimmedEmail = email.trim()
const trimmedEmail = email.trim();
if (!trimmedEmail) {
toast.error("邮箱不能为空")
return
toast.error('邮箱不能为空');
return;
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(trimmedEmail)) {
toast.error("请输入有效的邮箱地址")
return
toast.error('请输入有效的邮箱地址');
return;
}
if (emailRegisterEnabled && !code.trim()) {
toast.error("验证码不能为空")
return
toast.error('验证码不能为空');
return;
}
if (capEnabled && capScope === 'register' && !capReady) {
toast.error(
capAutoSolve
? "人机验证尚未完成,请稍候…"
: "请先点击「开始验证」完成人机验证",
)
return
? '人机验证尚未完成,请稍候…'
: '请先点击「开始验证」完成人机验证',
);
return;
}
registerMutation.mutate({
username: username.trim(),
@@ -227,104 +252,116 @@ export function RegisterForm() {
nickname: nickname.trim() || undefined,
email: trimmedEmail,
code: code.trim() || undefined,
})
}
});
};
if (publicConfigQuery.isPending) {
return (
<div className="flex items-center justify-center py-24">
<div className='flex items-center justify-center py-24'>
<Spinner />
</div>
)
);
}
if (!registrationEnabled) {
return null
return null;
}
return (
<div className="flex flex-col gap-6 [@media(max-height:700px)]:gap-4">
<div className='flex flex-col gap-6 [@media(max-height:700px)]:gap-4'>
<AuthHeading
siteName={publicConfigQuery.data?.site_name}
title="创建您的账号"
description="填写以下信息,开始使用平台服务。"
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>
<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="nickname"
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()}
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>
<Field className='gap-1.5'>
<FieldLabel htmlFor='email'>
电子邮箱 <span className='text-destructive'>*</span>
</FieldLabel>
<Input
id="email"
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()}
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>
<Field className='gap-1.5'>
<FieldLabel htmlFor='password'>
密码 <span className='text-destructive'>*</span>
</FieldLabel>
<Input
id="password"
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()}
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">
<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"
id='code'
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="请输入 6 位邮箱验证码"
placeholder='请输入 6 位邮箱验证码'
maxLength={6}
className="h-10 flex-1 text-sm [@media(max-height:700px)]:h-9"
onKeyDown={(e) => e.key === "Enter" && handleRegister()}
className='h-10 flex-1 text-sm [@media(max-height:700px)]:h-9'
onKeyDown={(e) => e.key === 'Enter' && handleRegister()}
/>
<Button
type="button"
variant="outline"
type='button'
variant='outline'
onClick={handleSendRegisterCode}
disabled={sendCodeDisabled}
className="h-10 w-[120px] text-xs [@media(max-height:700px)]:h-9"
className='h-10 w-[120px] text-xs [@media(max-height:700px)]:h-9'
>
{registerCooldown > 0 ? `${registerCooldown}秒后重发` : "获取验证码"}
{registerCooldown > 0
? `${registerCooldown}秒后重发`
: '获取验证码'}
</Button>
</div>
</Field>
@@ -342,15 +379,15 @@ export function RegisterForm() {
)}
{errorMessage ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
<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"
type='button'
className='h-10 w-full [@media(max-height:700px)]:h-9'
variant='auth'
onClick={handleRegister}
disabled={registerDisabled}
>
@@ -360,17 +397,20 @@ export function RegisterForm() {
注册中...
</>
) : (
"创建账号"
'创建账号'
)}
</Button>
</div>
<div className="text-center text-sm text-muted-foreground">
已经有账号?{" "}
<Link href="/login" className="font-medium text-foreground underline underline-offset-4">
<div className='text-center text-sm text-muted-foreground'>
已经有账号?{' '}
<Link
href='/login'
className='font-medium text-foreground underline underline-offset-4'
>
返回登录
</Link>
</div>
</div>
)
);
}
+4 -4
View File
@@ -1,12 +1,12 @@
"use client"
'use client';
import {RegisterForm} from "@/components/auth/register-form"
import {AuthShell} from "@/components/auth/auth-shell"
import { RegisterForm } from '@/components/auth/register-form';
import { AuthShell } from '@/components/auth/auth-shell';
export function RegisterPage() {
return (
<AuthShell wide>
<RegisterForm />
</AuthShell>
)
);
}
+32 -33
View File
@@ -1,16 +1,16 @@
"use client"
'use client';
import type {ReactNode} from "react"
import {Loader2} from "lucide-react"
import type { ReactNode } from 'react';
import { Loader2 } from 'lucide-react';
import {ErrorPage} from "@/components/layout/error"
import {useUser} from "@/contexts/user-context"
import { ErrorPage } from '@/components/layout/error';
import { useUser } from '@/contexts/user-context';
type RequireAuthProps = {
children: ReactNode
fallback?: ReactNode
minHeightClassName?: string
}
children: ReactNode;
fallback?: ReactNode;
minHeightClassName?: string;
};
/**
* Guards page content until the session user is available.
@@ -19,49 +19,48 @@ type RequireAuthProps = {
export function RequireAuth({
children,
fallback,
minHeightClassName = "min-h-[400px]",
minHeightClassName = 'min-h-[400px]',
}: RequireAuthProps) {
const {user, loading} = useUser()
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>
)
return (
fallback ?? (
<div
className={`flex items-center justify-center ${minHeightClassName}`}
>
<Loader2 className='size-6 animate-spin text-primary' />
</div>
)
);
}
if (!user) {
return null
return null;
}
return <>{children}</>
return <>{children}</>;
}
type RequireAdminAuthProps = {
children: ReactNode
}
children: ReactNode;
};
/** Guards admin routes after the shared shell has rendered. */
export function RequireAdminAuth({children}: RequireAdminAuthProps) {
const {user, loading} = useUser()
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 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 <ErrorPage title='访问被拒绝' message='您没有权限访问此页面' />;
}
return <>{children}</>
}
return <>{children}</>;
}
+311 -108
View File
@@ -1,5 +1,5 @@
import {type PolicySection} from "./types"
import {CodeBlock} from "@/components/ui/code-block"
import { type PolicySection } from './types';
import { CodeBlock } from '@/components/ui/code-block';
import {
DocsTable,
DocsTableBody,
@@ -7,9 +7,9 @@ import {
DocsTableHead,
DocsTableHeader,
DocsTableRow,
} from "@/components/ui/docs-table"
} from '@/components/ui/docs-table';
export const DOCS_LAST_UPDATED = "2026-06-07"
export const DOCS_LAST_UPDATED = '2026-06-07';
/**
* ------------------------------------------------------------------
@@ -18,39 +18,53 @@ export const DOCS_LAST_UPDATED = "2026-06-07"
*/
export const apiSections: PolicySection[] = [
{
value: "api-specs",
title: "1. 接口规范与鉴权说明",
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 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>
<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 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 className='font-mono text-xs'>
error_msg
</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>错误信息。请求成功时为空字符串 `&quot;&quot;`,失败时包含错误详情描述。</DocsTableCell>
<DocsTableCell>
错误信息。请求成功时为空字符串
`&quot;&quot;`,失败时包含错误详情描述。
</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">data</DocsTableCell>
<DocsTableCell className='font-mono text-xs'>data</DocsTableCell>
<DocsTableCell>any</DocsTableCell>
<DocsTableCell>接口返回的具体数据内容。请求失败或无数据返回时为 `null`。</DocsTableCell>
<DocsTableCell>
接口返回的具体数据内容。请求失败或无数据返回时为 `null`。
</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<p className="mt-2">成功响应示例:</p>
<p className='mt-2'>成功响应示例:</p>
<CodeBlock
code={`{
"error_msg": "",
@@ -60,70 +74,112 @@ export const apiSections: PolicySection[] = [
"nickname": "Ryan"
}
}`}
language="json"
language='json'
/>
<p className="mt-2">失败响应示例:</p>
<p className='mt-2'>失败响应示例:</p>
<CodeBlock
code={`{
"error_msg": "用户密码错误",
"data": null
}`}
language="json"
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>
<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>
<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: '1-1-response-format', title: '1.1 统一响应格式' },
{ value: '1-2-authentication', title: '1.2 鉴权方式' },
],
},
{
value: "auth-apis",
title: "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>
<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 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 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 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 className='font-mono text-xs'>
nickname
</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>昵称。未传时默认与用户名一致。</DocsTableCell>
@@ -131,27 +187,44 @@ export const apiSections: PolicySection[] = [
</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>
<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 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 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 className='font-mono text-xs'>
password
</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>密码</DocsTableCell>
@@ -159,41 +232,103 @@ export const apiSections: PolicySection[] = [
</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-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>
<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: '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) 接口",
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 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-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>
<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": "",
@@ -209,34 +344,74 @@ export const apiSections: PolicySection[] = [
}
}
}`}
language="json"
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-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>
<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: '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. 公共配置与管理接口",
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>
<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": "",
@@ -248,22 +423,50 @@ export const apiSections: PolicySection[] = [
"oidc_login_enabled": "true"
}
}`}
language="json"
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>
<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 (管理员)" },
]
}
]
{ value: '4-1-public-config', title: '4.1 公共系统配置' },
{ value: '4-2-admin-configs', title: '4.2 系统配置项 CRUD (管理员)' },
],
},
];
+193 -77
View File
@@ -1,5 +1,5 @@
import {type PolicySection} from "./types"
import {CodeBlock} from "@/components/ui/code-block"
import { type PolicySection } from './types';
import { CodeBlock } from '@/components/ui/code-block';
/**
* ------------------------------------------------------------------
@@ -8,131 +8,247 @@ import {CodeBlock} from "@/components/ui/code-block"
*/
export const howToUseSections: PolicySection[] = [
{
value: "quick-start",
title: "1. 快速开始",
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 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 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. 身份认证与安全设置",
value: 'auth-security',
title: '2. 身份认证与安全设置',
content: (
<div className="space-y-4 text-sm leading-relaxed">
<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>
<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>
<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 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: '2-1-login', title: '2.1 常规账号密码认证' },
{ value: '2-2-oidc', title: '2.2 第三方 OIDC 认证源' },
],
},
{
value: "access-token",
title: "3. 个人访问令牌 (AccessToken) 接口对接",
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>
<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>
<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:8000
Authorization: Bearer at_628d022b7a95e26b...`}
language="http"
language='http'
/>
</div>
<div className="space-y-2">
<p className="font-semibold text-xs text-muted-foreground">方式二:X-Access-Token 自定义头</p>
<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:8000
X-Access-Token: at_628d022b7a95e26b...`}
language="http"
language='http'
/>
</div>
</div>
),
children: [
{ value: "3-1-generation", title: "3.1 令牌生成与存储规范" },
{ value: "3-2-usage", title: "3.2 携带 Header 进行 API 调用" },
]
{ value: '3-1-generation', title: '3.1 令牌生成与存储规范' },
{ value: '3-2-usage', title: '3.2 携带 Header 进行 API 调用' },
],
},
{
value: "config-system",
title: "4. 动态系统配置项",
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>
<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. 异步任务与定时调度",
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>
<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. 链路追踪与结构化日志",
value: 'tracing-metrics',
title: '6. 链路追踪与结构化日志',
content: (
<div className="space-y-4 text-sm leading-relaxed">
<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 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>
)
}
]
),
},
];
@@ -1,18 +1,18 @@
"use client"
'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"
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
title: string;
lastUpdated: string;
sections: PolicySection[];
description?: React.ReactNode;
}
export function LegalPageLayout({
@@ -21,245 +21,288 @@ export function LegalPageLayout({
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 [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
})
restDelta: 0.001,
});
const allSectionIds = React.useMemo(() => {
return sections.flatMap(section => [
return sections.flatMap((section) => [
section.value,
...(section.children?.map(child => child.value) || [])
])
}, [sections])
...(section.children?.map((child) => child.value) || []),
]);
}, [sections]);
React.useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveSection(entry.target.id)
setActiveSection(entry.target.id);
}
})
});
},
{ rootMargin: "-20% 0px -35% 0px" }
)
{ rootMargin: '-20% 0px -35% 0px' },
);
allSectionIds.forEach((id) => {
const element = document.getElementById(id)
if (element) observer.observe(element)
})
const element = document.getElementById(id);
if (element) observer.observe(element);
});
return () => observer.disconnect()
}, [sections, allSectionIds])
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("")
}
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
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[] = []
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
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())
})
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 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 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\`\`\``
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 (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")
.join('\n\n');
}
const text = convertInlineNodes(Array.from(node.childNodes)).trim()
return text
const text = convertInlineNodes(Array.from(node.childNodes)).trim();
return text;
}
return convertInlineNodes(Array.from(node.childNodes)).trim()
}
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}`)
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]")
const descriptionElement = contentRoot.querySelector(
'[data-docs-description]',
);
if (descriptionElement) {
const descriptionText = convertInlineNodes(Array.from(descriptionElement.childNodes)).trim()
const descriptionText = convertInlineNodes(
Array.from(descriptionElement.childNodes),
).trim();
if (descriptionText) {
lines.push("")
lines.push(descriptionText)
lines.push('');
lines.push(descriptionText);
}
}
}
lines.push("")
lines.push('');
const sectionNodes = Array.from(contentRoot.querySelectorAll("[data-docs-section]")) as HTMLElement[]
const sectionNodes = Array.from(
contentRoot.querySelectorAll('[data-docs-section]'),
) as HTMLElement[];
sectionNodes.forEach((section) => {
const heading = section.querySelector("h2")
const heading = section.querySelector('h2');
if (heading?.textContent) {
lines.push(`## ${heading.textContent.trim()}`)
lines.push("")
lines.push(`## ${heading.textContent.trim()}`);
lines.push('');
}
const prose = section.querySelector("[data-docs-content]")
const prose = section.querySelector('[data-docs-content]');
if (prose) {
const blocks = Array.from(prose.children)
.map((child) => convertBlockNode(child as HTMLElement, 0))
.filter(Boolean)
.filter(Boolean);
if (blocks.length) {
lines.push(blocks.join("\n\n"))
lines.push("")
lines.push(blocks.join('\n\n'));
lines.push('');
}
}
})
});
return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n"
}
return (
lines
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim() + '\n'
);
};
const handleCopyMarkdown = async () => {
const markdown = buildMarkdown()
if (!markdown) return
const markdown = buildMarkdown();
if (!markdown) return;
try {
await navigator.clipboard.writeText(markdown)
setHasCopied(true)
setTimeout(() => setHasCopied(false), 2000)
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 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)
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
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"
})
behavior: 'smooth',
});
}
}
};
return (
<div className="min-h-screen bg-background relative selection:bg-primary/20">
<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"
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 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">
<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"
variant='ghost'
className='gap-2 pl-0 text-muted-foreground hover:text-foreground hover:bg-transparent -ml-2'
>
<ArrowLeft className="w-4 h-4" />
<ArrowLeft className='w-4 h-4' />
返回首页
</Button>
</Link>
@@ -269,32 +312,39 @@ export function LegalPageLayout({
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">
<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" />
<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" />
<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"
type='button'
variant='outline'
size='sm'
onClick={handleCopyMarkdown}
className="h-8 gap-2 text-xs"
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"}
{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">
<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>
@@ -302,44 +352,46 @@ export function LegalPageLayout({
</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">
<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">
<div key={section.value} className='space-y-1'>
<a
href={`#${ section.value }`}
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",
'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"
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50',
)}
>
<span className="truncate">{section.title}</span>
<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"
layoutId='active-dot'
className='w-1.5 h-1.5 rounded-full bg-primary'
/>
)}
</a>
{section.children && (
<div className="pl-4 space-y-1">
<div className='pl-4 space-y-1'>
{section.children.map((child) => (
<a
key={child.value}
href={`#${ 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",
'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"
? 'text-primary font-medium bg-primary/5'
: 'text-muted-foreground/80 hover:text-foreground hover:bg-muted/30',
)}
>
{child.title}
@@ -354,23 +406,23 @@ export function LegalPageLayout({
</div>
</aside>
<main className="lg:col-span-9 lg:col-start-4 space-y-8 md:space-y-16 pb-20">
<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" }}
viewport={{ once: true, margin: '100px' }}
transition={{ duration: 0.3 }}
className="scroll-mt-24 group"
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">
<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">
<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>
@@ -378,25 +430,29 @@ export function LegalPageLayout({
<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"
)}>
'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">
<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/Wavelet/issues" target="_blank">
<Button variant="outline" className="h-9 text-xs">
<div className='flex justify-center gap-4'>
<Link
href='https://github.com/Rain-kl/Wavelet/issues'
target='_blank'
>
<Button variant='outline' className='h-9 text-xs'>
联系支持
</Button>
</Link>
@@ -405,7 +461,7 @@ export function LegalPageLayout({
</div>
</main>
</div>
</div >
</div >
)
</div>
</div>
);
}
+106 -46
View File
@@ -1,4 +1,4 @@
import {type PolicySection} from "./types"
import { type PolicySection } from './types';
/**
* ------------------------------------------------------------------
@@ -7,94 +7,154 @@ import {type PolicySection} from "./types"
*/
export const privacySections: PolicySection[] = [
{
value: "collection-details",
title: "1. 详细信息收集说明",
value: 'collection-details',
title: '1. 详细信息收集说明',
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>我们仅遵循合法、正当、必要的原则,收集为您提供服务所必需的信息。我们的数据收集范围严格限制如下:</p>
<div className="space-y-3">
<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>
<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>
<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. 数据存储与安全保护",
value: 'storage-security',
title: '2. 数据存储与安全保护',
content: (
<div className="space-y-4 text-sm leading-relaxed">
<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 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. 信息使用规范",
value: 'usage-rules',
title: '3. 信息使用规范',
content: (
<div className="space-y-4 text-sm leading-relaxed">
<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 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>
<p>
<strong>禁止用途:</strong>我们承诺<strong>绝不</strong>
将您的数据出售给第三方,亦不会向任何机构提供任何用户画像分析或广告推送服务。
</p>
</div>
),
},
{
value: "sharing-disclosure",
title: "4. 信息共享与对外披露",
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">
<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>
<li>
根据适用的法律法规、法律程序的要求、强制性的行政或司法要求所必须的情况下进行提供。
</li>
</ul>
<p><strong>4.2 转让与公开披露:</strong>我们不会将您的个人信息转让给任何公司、组织和个人,亦不进行任何公开商业披露。</p>
<p>
<strong>4.2 转让与公开披露:</strong>
我们不会将您的个人信息转让给任何公司、组织和个人,亦不进行任何公开商业披露。
</p>
</div>
),
},
{
value: "user-rights",
title: "5. 您的权利与数据管理",
value: 'user-rights',
title: '5. 您的权利与数据管理',
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>依照《中华人民共和国个人信息保护法》,您对您的个人信息享有完整的控制权:</p>
<div className="space-y-3">
<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>
<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>
<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. 政策更新与通知",
value: 'policy-update',
title: '6. 政策更新与通知',
content: (
<div className="space-y-4 text-sm leading-relaxed">
<div className='space-y-4 text-sm leading-relaxed'>
<p>随着业务的发展或法律法规的变动,本《隐私政策》条款可能发生变更。</p>
<p>当条款发生重大变更时,我们会以显著方式(如平台公告、站内弹窗等)予以通知。如果您继续使用本服务,即表示您同意接受修订后的政策约束。</p>
<p>
当条款发生重大变更时,我们会以显著方式(如平台公告、站内弹窗等)予以通知。如果您继续使用本服务,即表示您同意接受修订后的政策约束。
</p>
</div>
),
},
]
];
+112 -45
View File
@@ -1,6 +1,6 @@
import {type PolicySection} from "./types"
import { type PolicySection } from './types';
export const TERMS_LAST_UPDATED = "2026-06-07"
export const TERMS_LAST_UPDATED = '2026-06-07';
/**
* ------------------------------------------------------------------
@@ -9,68 +9,128 @@ export const TERMS_LAST_UPDATED = "2026-06-07"
*/
export const termsSections: PolicySection[] = [
{
value: "contract-establishment",
title: "1. 缔约申明与服务综述",
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 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. 服务定义与性质界定",
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>
<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. 账号注册与使用规范",
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>
<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. 用户行为准则(负面清单)",
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>
<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>
<p>
<strong>处理规则:</strong>
一旦发现您违反上述规定,平台运维主体有权不经通知
<strong>
立即永久封禁您的账号、拦截所有 AccessToken
请求,并依法向公安机关、网安部门移交相关线索。
</strong>
</p>
</div>
),
},
{
value: "liability-limitation",
title: "5. 免责声明与不可抗力",
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">
<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>
@@ -80,13 +140,20 @@ export const termsSections: PolicySection[] = [
),
},
{
value: "governing-law",
title: "6. 法律适用与争议解决",
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 className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>6.1 法律适用:</strong>
本协议的订立、执行、解释及争议的解决均适用
<strong>中华人民共和国法律</strong>(不包括港澳台地区冲突法)。
</p>
<p>
<strong>6.2 争议解决:</strong>
若发生任何争议或纠纷,首先应友好协商解决;协商不成的,应提交至本系统部署或运营方所在地有管辖权的人民法院管辖。
</p>
</div>
),
},
]
];
+8 -8
View File
@@ -1,11 +1,11 @@
import { type ReactNode } from "react"
import { type ReactNode } from 'react';
export type PolicySection = {
value: string
title: string
content: ReactNode
value: string;
title: string;
content: ReactNode;
children?: {
value: string
title: string
}[]
}
value: string;
title: string;
}[];
};
@@ -1,51 +1,56 @@
"use client"
'use client';
import * as React from "react"
import {ImageOff} from "lucide-react"
import * as React from 'react';
import { ImageOff } from 'lucide-react';
import {cn} from "@/lib/utils"
import {getFileUrl, type ImageQuality} from "@/lib/services/upload/utils"
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"
}
fileId: string | number;
alt: string;
quality?: ImageQuality;
className?: string;
fallbackClassName?: string;
variant?: 'compact' | 'default';
};
export function FileImagePreview({
fileId,
alt,
quality = "low",
quality = 'low',
className,
fallbackClassName,
variant = "default",
variant = 'default',
}: FileImagePreviewProps) {
const [failed, setFailed] = React.useState(false)
const src = getFileUrl(fileId, quality)
const [failed, setFailed] = React.useState(false);
const src = getFileUrl(fileId, quality);
React.useEffect(() => {
setFailed(false)
}, [fileId, quality])
setFailed(false);
}, [fileId, quality]);
if (!src || failed) {
return (
<div
role="img"
role='img'
aria-label={`${alt} 加载失败`}
className={cn(
"flex size-full flex-col items-center justify-center gap-1 bg-muted/50 text-muted-foreground",
'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>
<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 (
@@ -53,10 +58,10 @@ export function FileImagePreview({
<img
src={src}
alt={alt}
loading="lazy"
decoding="async"
loading='lazy'
decoding='async'
className={className}
onError={() => setFailed(true)}
/>
)
}
);
}
@@ -1,11 +1,11 @@
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 { 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,
@@ -15,49 +15,54 @@ import {
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"
} 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
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>
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
}[]
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
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
emptyIcon?: LucideIcon;
emptyDescription?: string;
loadingDescription?: string;
getId: (item: T) => string | number;
headerExtra?: React.ReactNode;
}
export function ManagePage<T>({
@@ -73,85 +78,85 @@ export function ManagePage<T>({
columns,
renderDetail,
emptyIcon = Layers,
emptyDescription = "暂无数据",
loadingDescription = "加载中",
emptyDescription = '暂无数据',
loadingDescription = '加载中',
getId,
headerExtra
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 [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)
}
setHoveredItem(item);
};
/** 选择处理 */
const handleSelect = (item: T) => {
const itemId = getId(item)
const selectedId = selectedItem ? getId(selectedItem) : null
const itemId = getId(item);
const selectedId = selectedItem ? getId(selectedItem) : null;
if (itemId === selectedId) {
setSelectedItem(null)
setEditData({})
setSelectedItem(null);
setEditData({});
} else {
setSelectedItem(item)
setEditData(getInitialEditData(item))
setSelectedItem(item);
setEditData(getInitialEditData(item));
}
setHoveredItem(null)
}
setHoveredItem(null);
};
/** 编辑数据处理 */
const handleEditDataChange = (field: keyof T, value: T[keyof T]) => {
setEditData(prev => ({
setEditData((prev) => ({
...prev,
[field]: value
}))
}
[field]: value,
}));
};
/** 保存处理 */
const handleSave = async () => {
if (!selectedItem) return
if (!selectedItem) return;
try {
setSaving(true)
await onSave(selectedItem, editData)
toast.success('保存成功')
setSaving(true);
await onSave(selectedItem, editData);
toast.success('保存成功');
} catch (error) {
toast.error('保存失败', {
description: error instanceof Error ? error.message : '未知错误'
})
description: error instanceof Error ? error.message : '未知错误',
});
} finally {
setSaving(false)
setSaving(false);
}
}
};
/** 删除处理 */
const handleDeleteClick = (item: T) => {
setDeletingItem(item)
}
setDeletingItem(item);
};
const handleConfirmDelete = async () => {
if (!deletingItem || !onDelete) return
if (!deletingItem || !onDelete) return;
try {
await onDelete(deletingItem)
toast.success('删除成功')
setDeletingItem(null)
await onDelete(deletingItem);
toast.success('删除成功');
setDeletingItem(null);
// 如果删除的是当前选中的项,清除选中状态
if (selectedItem && getId(selectedItem) === getId(deletingItem)) {
setSelectedItem(null)
setEditData({})
setSelectedItem(null);
setEditData({});
}
} catch (error) {
toast.error('删除失败', {
description: error instanceof Error ? error.message : '未知错误'
})
description: error instanceof Error ? error.message : '未知错误',
});
}
}
};
/** 渲染内容 */
const renderContent = () => {
@@ -161,28 +166,25 @@ export function ManagePage<T>({
icon={ListRestart}
description={loadingDescription}
/>
)
);
}
if (error) {
return (
<div className="p-8 border border-dashed rounded-lg">
<div className='p-8 border border-dashed rounded-lg'>
<ErrorInline
error={error}
onRetry={onReload}
className="justify-center"
className='justify-center'
/>
</div>
)
);
}
if (!data || data.length === 0) {
return (
<EmptyStateWithBorder
icon={emptyIcon}
description={emptyDescription}
/>
)
<EmptyStateWithBorder icon={emptyIcon} description={emptyDescription} />
);
}
return (
@@ -196,27 +198,25 @@ export function ManagePage<T>({
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 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>
<h1 className='text-2xl font-semibold tracking-tight'>{title}</h1>
</div>
</div>
{headerExtra}
</div>
<div className="space-y-6">
<div className='space-y-6'>
<div>
<div className="mb-4">
<div className="font-semibold">配置列表</div>
<div className='mb-4'>
<div className='font-semibold'>配置列表</div>
</div>
{renderContent()}
</div>
@@ -228,12 +228,15 @@ export function ManagePage<T>({
editData,
onEditDataChange: handleEditDataChange,
onSave: handleSave,
saving
saving,
})}
</div>
</div>
<AlertDialog open={!!deletingItem} onOpenChange={(open) => !open && setDeletingItem(null)}>
<AlertDialog
open={!!deletingItem}
onOpenChange={(open) => !open && setDeletingItem(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除?</AlertDialogTitle>
@@ -243,67 +246,73 @@ export function ManagePage<T>({
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmDelete} className="bg-red-600 hover:bg-red-700">
<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
title?: string;
isEmpty: boolean;
emptyDescription?: string;
onSave?: () => void;
saving?: boolean;
children: React.ReactNode;
}
/** 详情面板 */
export function ManageDetailPanel({
title = "配置信息",
title = '配置信息',
isEmpty,
emptyDescription = "请选择配置查看详情",
emptyDescription = '请选择配置查看详情',
onSave,
saving = false,
children
children,
}: ManageDetailPanelProps) {
if (isEmpty) {
return (
<div className="space-y-4">
<div className="font-semibold mb-4">{title}</div>
<EmptyStateWithBorder
icon={Layers}
description={emptyDescription}
/>
<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 className='space-y-4 sticky top-6'>
<div>
<div className="flex items-center justify-between mb-4">
<div className="font-semibold">{title}</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"
size='sm'
className='px-3 h-7 text-xs'
>
{saving ? (<><Spinner /> 更新中</>) : '更新'}
{saving ? (
<>
<Spinner /> 更新中
</>
) : (
'更新'
)}
</Button>
)}
</div>
{children}
</div>
</div>
)
);
}
/** 表格面板 */
@@ -315,56 +324,61 @@ export function ManageTable<T>({
onSelect,
onHover,
onDelete,
getId
getId,
}: {
data: T[]
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
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">
<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">
<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 || '' }`}
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>}
{onDelete && (
<TableHead className='whitespace-nowrap text-center w-[120px]'>
操作
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody className="animate-in fade-in duration-200">
<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
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'
}`}
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)}
@@ -372,34 +386,34 @@ export function ManageTable<T>({
{columns.map((col, index) => (
<TableCell
key={index}
className={`text-xs py-1 ${ col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : '' } ${ col.className || '' }`}
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">
<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"
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)
e.stopPropagation();
onDelete(item);
}}
>
<Trash2 className="h-3.5 w-3.5" />
<Trash2 className='h-3.5 w-3.5' />
</Button>
</TableCell>
)}
</TableRow>
)
);
})}
</TableBody>
</table>
</div>
<ScrollBar orientation="horizontal" />
<ScrollBar orientation='horizontal' />
</ScrollArea>
</div>
)
);
}
@@ -1,34 +1,34 @@
"use client"
'use client';
import * as React from "react"
import { useState } from "react"
import * as React from 'react';
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
} 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"
} 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
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (password: string) => void;
loading?: boolean;
title?: string;
description?: string;
}
/**
* 密码输入对话框
*
*
* 用于敏感操作前的二次确认
*/
export function PasswordDialog({
@@ -36,35 +36,33 @@ export function PasswordDialog({
onOpenChange,
onConfirm,
loading = false,
title = "请输入密码",
description = "为了您的账户安全,请输入6位认证密码进行验证"
title = '请输入密码',
description = '为了您的账户安全,请输入6位认证密码进行验证',
}: PasswordDialogProps) {
const [password, setPassword] = useState("")
const [password, setPassword] = useState('');
const handleConfirm = () => {
if (password.length === 6) {
onConfirm(password)
onConfirm(password);
}
}
};
/* 当对话框关闭时重置密码*/
React.useEffect(() => {
if (!isOpen) {
setPassword("")
setPassword('');
}
}, [isOpen])
}, [isOpen]);
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="w-xs sm:w-md">
<DialogContent className='w-xs sm:w-md'>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
{description}
</DialogDescription>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="py-2 flex justify-center">
<div className='py-2 flex justify-center'>
<InputOTP
maxLength={6}
value={password}
@@ -72,47 +70,47 @@ export function PasswordDialog({
disabled={loading}
onKeyDown={(e) => {
if (e.key === 'Enter' && password.length === 6 && !loading) {
handleConfirm()
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 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">
<div className='flex justify-end gap-2 pt-2'>
<Button
variant="ghost"
variant='ghost'
onClick={() => onOpenChange(false)}
disabled={loading}
className="h-8 text-xs"
className='h-8 text-xs'
>
取消
</Button>
<Button
type="button"
className="bg-primary hover:bg-primary/90 h-8 text-xs"
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" />
<Spinner className='mr-2 h-4 w-4' />
验证中...
</>
) : (
"确认"
'确认'
)}
</Button>
</div>
</DialogContent>
</Dialog>
)
);
}
+146 -107
View File
@@ -1,127 +1,140 @@
"use client"
'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"
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 { user } = useUser();
const [isBannerVisible, setIsBannerVisible] = React.useState(true);
const quickLinks = [
{
title: "个人资料",
description: "管理您的个人账户信息及个性化配置",
title: '个人资料',
description: '管理您的个人账户信息及个性化配置',
icon: User,
url: "/settings/profile",
color: "text-blue-500",
bgColor: "bg-blue-500/10",
borderColor: "hover:border-blue-500/30",
url: '/settings/profile',
color: 'text-blue-500',
bgColor: 'bg-blue-500/10',
borderColor: 'hover:border-blue-500/30',
},
{
title: "开发接口文档",
description: "查看开放平台的 RESTful 接口规格说明",
title: '开发接口文档',
description: '查看开放平台的 RESTful 接口规格说明',
icon: FileText,
url: "/docs/api",
color: "text-emerald-500",
bgColor: "bg-emerald-500/10",
borderColor: "hover:border-emerald-500/30",
url: '/docs/api',
color: 'text-emerald-500',
bgColor: 'bg-emerald-500/10',
borderColor: 'hover:border-emerald-500/30',
external: true,
},
{
title: "使用文档",
description: "学习如何集成 API 及日常操作帮助指南",
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",
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: "管理登录注册开关与认证源安全策略",
title: '系统设置',
description: '管理登录注册开关与认证源安全策略',
icon: Shield,
url: "/admin/settings",
color: "text-amber-500",
bgColor: "bg-amber-500/10",
borderColor: "hover:border-amber-500/30",
url: '/admin/settings',
color: 'text-amber-500',
bgColor: 'bg-amber-500/10',
borderColor: 'hover:border-amber-500/30',
},
{
title: "全局系统配置",
description: "动态管理平台运行时核心配置参数",
title: '全局系统配置',
description: '动态管理平台运行时核心配置参数',
icon: ShieldCheck,
url: "/admin/system",
color: "text-indigo-500",
bgColor: "bg-indigo-500/10",
borderColor: "hover:border-indigo-500/30",
url: '/admin/system',
color: 'text-indigo-500',
bgColor: 'bg-indigo-500/10',
borderColor: 'hover:border-indigo-500/30',
},
{
title: "用户权限管理",
description: "集中查询并管理系统注册用户的启用状态",
title: '用户权限管理',
description: '集中查询并管理系统注册用户的启用状态',
icon: User,
url: "/admin/users",
color: "text-rose-500",
bgColor: "bg-rose-500/10",
borderColor: "hover:border-rose-500/30",
url: '/admin/users',
color: 'text-rose-500',
bgColor: 'bg-rose-500/10',
borderColor: 'hover:border-rose-500/30',
},
{
title: "后台任务调度",
description: "分发与观测系统异步定时任务的执行情况",
title: '后台任务调度',
description: '分发与观测系统异步定时任务的执行情况',
icon: Layers,
url: "/admin/tasks",
color: "text-teal-500",
bgColor: "bg-teal-500/10",
borderColor: "hover:border-teal-500/30",
url: '/admin/tasks',
color: 'text-teal-500',
bgColor: 'bg-teal-500/10',
borderColor: 'hover:border-teal-500/30',
},
{
title: "系统日志",
description: "查看异步任务执行日志与系统运行状态详情",
title: '系统日志',
description: '查看异步任务执行日志与系统运行状态详情',
icon: Terminal,
url: "/admin/logs",
color: "text-cyan-500",
bgColor: "bg-cyan-500/10",
borderColor: "hover:border-cyan-500/30",
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">
<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" }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
className="overflow-hidden"
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">
通用的、现代化的后台管理系统脚手架, 开箱即用、完整基建、极易扩展,助您快速构建企业级应用。
<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">
<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"
size='sm'
className='bg-indigo-600 hover:bg-indigo-700 text-xs font-semibold px-4 shadow-sm h-7'
asChild
>
<Link href="/home">开始使用</Link>
<Link href='/home'>开始使用</Link>
</Button>
<Button
size="sm"
variant="ghost"
className="text-xs font-medium hover:bg-black/5 dark:hover:bg-white/5 h-7"
size='sm'
variant='ghost'
className='text-xs font-medium hover:bg-black/5 dark:hover:bg-white/5 h-7'
onClick={() => setIsBannerVisible(false)}
>
隐藏提示
@@ -134,9 +147,9 @@ export function HomeMain() {
</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">
<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}
@@ -144,32 +157,50 @@ export function HomeMain() {
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: idx * 0.05 }}
whileHover={{ y: -4 }}
className="h-full"
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" />
<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">
<CardTitle className='text-sm font-semibold mb-1 flex items-center gap-1'>
{link.title}
{link.external && <ExternalLink className="size-3 text-muted-foreground" />}
{link.external && (
<ExternalLink className='size-3 text-muted-foreground' />
)}
</CardTitle>
<p className="text-xs text-muted-foreground leading-normal font-normal">
<p className='text-xs text-muted-foreground leading-normal font-normal'>
{link.description}
</p>
</CardHeader>
<div className="px-5 pb-5">
<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" />
<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>
<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" />
立即进入 <ArrowRight className='size-3 ml-1' />
</Link>
</Button>
)}
@@ -182,12 +213,12 @@ export function HomeMain() {
{/* 管理面板 (仅管理员可见) */}
{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" />
<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">
<div className='grid grid-cols-1 md:grid-cols-3 gap-4'>
{adminLinks.map((link, idx) => (
<motion.div
key={link.title}
@@ -195,24 +226,32 @@ export function HomeMain() {
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: (idx + 4) * 0.05 }}
whileHover={{ y: -4 }}
className="h-full"
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" />
<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">
<CardTitle className='text-sm font-semibold mb-1'>
{link.title}
</CardTitle>
<p className="text-xs text-muted-foreground leading-normal font-normal">
<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>
<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" />
立即进入 <ArrowRight className='size-3 ml-1' />
</Link>
</Button>
</div>
@@ -223,5 +262,5 @@ export function HomeMain() {
</div>
)}
</div>
)
);
}
@@ -1,17 +1,34 @@
"use client"
'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 * 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 { 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,
@@ -19,7 +36,7 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
} from '@/components/ui/dialog';
import {
Breadcrumb,
BreadcrumbItem,
@@ -27,240 +44,283 @@ import {
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import {UserService} from "@/lib/services/user"
import type {CreateTokenResponse} from "@/lib/services/user"
import {useAuth} from "@/components/providers/auth-provider"
import {toast} from "sonner"
} 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)
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"],
queryKey: ['user', 'access-tokens'],
queryFn: () => UserService.getAccessTokens(),
})
});
// 创建 Token
const createTokenMutation = useMutation({
mutationFn: ({ name, isAdmin }: { name: string; isAdmin: boolean }) => UserService.createAccessToken(name, isAdmin),
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("访问令牌创建成功")
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 || "创建访问令牌失败")
toast.error(error.message || '创建访问令牌失败');
},
})
});
// 删除 Token
const deleteTokenMutation = useMutation({
mutationFn: (id: number) => UserService.deleteAccessToken(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["user", "access-tokens"] })
toast.success("访问令牌已撤销")
void queryClient.invalidateQueries({
queryKey: ['user', 'access-tokens'],
});
toast.success('访问令牌已撤销');
},
onError: (error: Error) => {
toast.error(error.message || "删除访问令牌失败")
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("访问令牌轮换成功,旧密钥已失效")
setNewCreatedToken(data);
setViewDialogOpen(true);
void queryClient.invalidateQueries({
queryKey: ['user', 'access-tokens'],
});
toast.success('访问令牌轮换成功,旧密钥已失效');
},
onError: (error: Error) => {
toast.error(error.message || "轮换访问令牌失败")
toast.error(error.message || '轮换访问令牌失败');
},
})
});
const handleCreateToken = (e: React.FormEvent) => {
e.preventDefault()
e.preventDefault();
if (!tokenName.trim()) {
toast.error("请输入令牌名称")
return
toast.error('请输入令牌名称');
return;
}
createTokenMutation.mutate({ name: tokenName.trim(), isAdmin: tokenIsAdmin })
}
createTokenMutation.mutate({
name: tokenName.trim(),
isAdmin: tokenIsAdmin,
});
};
const handleDeleteToken = (id: number, name: string) => {
if (window.confirm(`确定要删除并撤销令牌「${name}」吗?删除后此令牌将立即失效且不可恢复。`)) {
deleteTokenMutation.mutate(id)
if (
window.confirm(
`确定要删除并撤销令牌「${name}」吗?删除后此令牌将立即失效且不可恢复。`,
)
) {
deleteTokenMutation.mutate(id);
}
}
};
const handleRotateToken = (id: number, name: string) => {
if (window.confirm(`确定要轮换令牌「${name}」的密钥吗?轮换后系统将生成全新密钥,原令牌密钥将立即失效。`)) {
rotateTokenMutation.mutate(id)
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)
await navigator.clipboard.writeText(text);
setCopiedId(id);
toast.success('复制成功');
setTimeout(() => setCopiedId(null), 2000);
} catch {
toast.error("复制失败")
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",
})
}
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"
transition={{ duration: 0.35, ease: 'easeOut' }}
className='py-6 space-y-6 max-w-4xl mx-auto px-4'
>
<div className="font-semibold">
<div className='font-semibold'>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/settings" className="text-base text-primary">设置</Link>
<Link href='/settings' className='text-base text-primary'>
设置
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className="text-base font-semibold">访问令牌</BreadcrumbPage>
<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 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>
<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"
type='button'
onClick={() => setCreateDialogOpen(true)}
variant={'secondary'}
>
<Plus className="mr-1.5 size-4" />
<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">
<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>
<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">
<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-primary" />
<div className='flex items-center justify-center py-8'>
<Loader2 className='size-6 animate-spin text-primary' />
</div>
) : (accessTokensQuery.data ?? []).length > 0 ? (
<div className="space-y-3">
<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"
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>
<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
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
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">
<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">
<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">
<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-primary hover:bg-primary/5 rounded-lg h-8 px-2.5"
onClick={() => handleCopyText(token.masked_token, token.id)}
type='button'
variant='outline'
size='sm'
className='text-xs border-dashed text-muted-foreground hover:text-primary hover:bg-primary/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" />
<Check className='size-3.5 mr-1 text-emerald-500' />
) : (
<Copy className="size-3.5 mr-1" />
<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-primary hover:bg-primary/5 rounded-lg h-8 px-2.5"
type='button'
variant='outline'
size='sm'
className='text-xs border-dashed text-muted-foreground hover:text-primary hover:bg-primary/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' : ''}`} />
<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"
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" />
<Trash2 className='size-3.5 mr-1' />
撤销
</Button>
</div>
@@ -268,17 +328,17 @@ export function AccessTokenMain() {
))}
</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" />
<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"
type='button'
variant='outline'
size='sm'
className='border-dashed'
onClick={() => setCreateDialogOpen(true)}
>
<Plus className="mr-1 size-3.5" />
<Plus className='mr-1 size-3.5' />
生成第一个令牌
</Button>
</div>
@@ -288,39 +348,46 @@ export function AccessTokenMain() {
{/* 创建令牌 Dialog */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogContent className='sm:max-w-[425px]'>
<form onSubmit={handleCreateToken}>
<DialogHeader>
<DialogTitle className="text-base font-semibold">生成新令牌</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
<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>
<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"
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-primary focus:ring-0 focus-visible:ring-0"
className='rounded-xl border border-dashed focus:border-primary 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" />
<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">
<p className='text-[11px] text-muted-foreground leading-normal'>
开启后此令牌可访问 /admin/** 管理端点,默认关闭
</p>
</div>
<Switch
id="token-admin"
id='token-admin'
checked={tokenIsAdmin}
onCheckedChange={setTokenIsAdmin}
disabled={createTokenMutation.isPending}
@@ -328,28 +395,28 @@ export function AccessTokenMain() {
</div>
)}
</div>
<DialogFooter className="gap-2">
<DialogFooter className='gap-2'>
<Button
type="button"
variant="outline"
type='button'
variant='outline'
onClick={() => setCreateDialogOpen(false)}
disabled={createTokenMutation.isPending}
className="rounded-xl text-xs h-9 border-dashed"
className='rounded-xl text-xs h-9 border-dashed'
>
取消
</Button>
<Button
type="submit"
type='submit'
disabled={createTokenMutation.isPending}
variant={'secondary'}
>
{createTokenMutation.isPending ? (
<>
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
正在生成...
</>
) : (
"生成令牌"
'生成令牌'
)}
</Button>
</DialogFooter>
@@ -358,48 +425,51 @@ export function AccessTokenMain() {
</Dialog>
{/* 明文 Token 显示 Dialog (仅显示一次) */}
<Dialog open={viewDialogOpen} onOpenChange={(open) => {
if (!open) {
setNewCreatedToken(null)
setViewDialogOpen(false)
}
}}>
<DialogContent className="sm:max-w-[500px]">
<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 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">
<div className='space-y-4 py-2'>
{/* 明文 Token 文本框 */}
<div className="flex items-center gap-2 rounded-xl bg-primary/5 border border-dashed border-primary/30 p-3">
<span className="font-mono text-xs select-all break-all flex-1 text-primary font-semibold leading-relaxed">
<div className='flex items-center gap-2 rounded-xl bg-primary/5 border border-dashed border-primary/30 p-3'>
<span className='font-mono text-xs select-all break-all flex-1 text-primary font-semibold leading-relaxed'>
{newCreatedToken.token}
</span>
<Button
type="button"
size="icon"
variant="outline"
className="size-8 rounded-lg shrink-0 border-dashed text-primary hover:bg-primary/10 hover:border-primary/30 transition-colors"
type='button'
size='icon'
variant='outline'
className='size-8 rounded-lg shrink-0 border-dashed text-primary hover:bg-primary/10 hover:border-primary/30 transition-colors'
onClick={() => handleCopyText(newCreatedToken.token, 9999)}
>
{copiedId === 9999 ? (
<Check className="size-3.5 text-emerald-500" />
<Check className='size-3.5 text-emerald-500' />
) : (
<Copy className="size-3.5" />
<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">
<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>
@@ -409,12 +479,12 @@ export function AccessTokenMain() {
<DialogFooter>
<Button
type="button"
type='button'
onClick={() => {
setNewCreatedToken(null)
setViewDialogOpen(false)
setNewCreatedToken(null);
setViewDialogOpen(false);
}}
className="rounded-xl w-full"
className='rounded-xl w-full'
>
我已经复制并妥善保存
</Button>
@@ -422,5 +492,5 @@ export function AccessTokenMain() {
</DialogContent>
</Dialog>
</motion.div>
)
);
}
@@ -1,209 +1,251 @@
"use client"
'use client';
import * as React from "react"
import Link from "next/link"
import { Moon, Sun, Loader2, Check, Monitor } 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"
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)
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true)
}, [])
setMounted(true);
}, []);
const modes = [
{ id: "light", name: "明亮", icon: Sun },
{ id: "dark", name: "黑暗", icon: Moon },
{ id: "system", name: "自动", icon: Monitor }
]
{ id: 'light', name: '明亮', icon: Sun },
{ id: 'dark', name: '黑暗', icon: Moon },
{ id: 'system', name: '自动', icon: Monitor },
];
if (!mounted) {
return (
<div className="space-y-6">
<div className='space-y-6'>
<div>
<h2 className="font-medium text-sm text-foreground">主题模式</h2>
<p className="text-xs text-muted-foreground">
<h2 className='font-medium text-sm text-foreground'>主题模式</h2>
<p className='text-xs text-muted-foreground'>
选择应用程序的整体明暗主题
</p>
</div>
<div className="flex gap-2">
<div className='flex gap-2'>
{modes.map((mode) => {
const Icon = mode.icon
const Icon = mode.icon;
return (
<Button
key={mode.id}
variant="secondary"
size="sm"
className="flex-1 text-xs"
variant='secondary'
size='sm'
className='flex-1 text-xs'
>
{Icon && <Icon className="size-3 mr-1" />}
{Icon && <Icon className='size-3 mr-1' />}
{mode.name}
</Button>
)
);
})}
</div>
</div>
)
);
}
return (
<div className="space-y-6">
<div className='space-y-6'>
<div>
<h2 className="font-medium text-sm text-foreground">主题模式</h2>
<p className="text-xs text-muted-foreground">
<h2 className='font-medium text-sm text-foreground'>主题模式</h2>
<p className='text-xs text-muted-foreground'>
选择应用程序的整体明暗主题
</p>
</div>
<div className="flex gap-2">
<div className='flex gap-2'>
{modes.map((mode) => {
const Icon = mode.icon
const isActive = theme === mode.id
const Icon = mode.icon;
const isActive = theme === mode.id;
return (
<Button
key={mode.id}
variant={isActive ? "default" : "secondary"}
size="sm"
variant={isActive ? 'default' : 'secondary'}
size='sm'
onClick={() => setTheme(mode.id)}
className="flex-1 text-xs"
className='flex-1 text-xs'
>
{Icon && <Icon className="size-3 mr-1" />}
{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)
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)
}, [])
setMounted(true);
}, []);
const handleSetTheme = (themeId: string) => {
if (switching === themeId) return
setSwitching(themeId)
if (switching === themeId) return;
setSwitching(themeId);
try {
setCustomTheme(themeId)
const themeName = themes.find(t => t.id === themeId)?.name || themeId
toast.success(`外观已切换为 ${ themeName }`)
setCustomTheme(themeId);
const themeName = themes.find((t) => t.id === themeId)?.name || themeId;
toast.success(`外观已切换为 ${themeName}`);
} catch {
toast.error("切换失败")
toast.error('切换失败');
} finally {
setSwitching(null)
setSwitching(null);
}
}
};
const isDark = mounted && (mode === 'dark' || (mode === 'system' && typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches))
const isDark =
mounted &&
(mode === 'dark' ||
(mode === 'system' &&
typeof window !== 'undefined' &&
window.matchMedia('(prefers-color-scheme: dark)').matches));
return (
<div className="space-y-6">
<div className='space-y-6'>
<div>
<h2 className="font-medium text-sm text-foreground">界面外观</h2>
<p className="text-xs text-muted-foreground">
<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 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">
<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
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",
'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"
? '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'] }}
className='aspect-[4/3] w-full relative'
style={{
backgroundColor:
colors.background || colors['background'],
}}
>
<div className="flex h-full p-2 gap-2">
<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'] }}
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='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'] }}
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'] }}
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'] }}
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 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")}>
<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">
<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>
<Link href='/settings' className='text-base text-primary'>
设置
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className="text-base font-semibold">外观设置</BreadcrumbPage>
<BreadcrumbPage className='text-base font-semibold'>
外观设置
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
@@ -213,12 +255,14 @@ export function AppearanceMain() {
<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 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>
)
);
}
@@ -1,26 +1,32 @@
"use client"
'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"
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: "",
name: '',
type: 'oidc',
display_name: '',
is_active: false,
client_id: "",
client_secret: "",
openid_discovery_url: "",
scopes: "openid profile email",
icon_url: "",
}
client_id: '',
client_secret: '',
openid_discovery_url: '',
scopes: 'openid profile email',
icon_url: '',
};
export function AuthSourceModal({
isOpen,
@@ -28,13 +34,13 @@ export function AuthSourceModal({
onClose,
onChanged,
}: {
isOpen: boolean
source: AuthSource | null
onClose: () => void
onChanged: () => Promise<void>
isOpen: boolean;
source: AuthSource | null;
onClose: () => void;
onChanged: () => Promise<void>;
}) {
const [form, setForm] = useState<AuthSourceRequest>(emptyForm)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState<AuthSourceRequest>(emptyForm);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (isOpen) {
@@ -45,122 +51,152 @@ export function AuthSourceModal({
display_name: source.display_name,
is_active: source.is_active,
client_id: source.client_id,
client_secret: "",
client_secret: '',
openid_discovery_url: source.openid_discovery_url,
scopes: source.scopes || "openid profile email",
scopes: source.scopes || 'openid profile email',
icon_url: source.icon_url,
})
});
} else {
setForm(emptyForm)
setForm(emptyForm);
}
} else {
setForm(emptyForm)
setSaving(false)
setForm(emptyForm);
setSaving(false);
}
}, [isOpen, source])
}, [isOpen, source]);
const saveSource = async () => {
setSaving(true)
setSaving(true);
try {
if (source) {
await services.adminAuthSource.updateAuthSource(source.id, form)
await services.adminAuthSource.updateAuthSource(source.id, form);
} else {
await services.adminAuthSource.createAuthSource(form)
await services.adminAuthSource.createAuthSource(form);
}
await onChanged()
toast.success("认证源已保存")
onClose()
await onChanged();
toast.success('认证源已保存');
onClose();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存认证源失败")
toast.error(error instanceof Error ? error.message : '保存认证源失败');
} finally {
setSaving(false)
setSaving(false);
}
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-xl">
<DialogContent className='max-w-xl'>
<DialogHeader>
<DialogTitle>{source ? "编辑认证源" : "新增认证源"}</DialogTitle>
<DialogTitle>{source ? '编辑认证源' : '新增认证源'}</DialogTitle>
<DialogDescription>
{source ? "修改系统自定义的 OIDC 认证源参数。" : "配置新的自定义 OIDC 认证源。"}
{source
? '修改系统自定义的 OIDC 认证源参数。'
: '配置新的自定义 OIDC 认证源。'}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 md:grid-cols-2 pt-2">
<div className="space-y-2">
<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"
onChange={(e) =>
setForm((prev) => ({ ...prev, name: e.target.value }))
}
placeholder='例如: github'
/>
</div>
<div className="space-y-2">
<div className='space-y-2'>
<Label>展示名称</Label>
<Input
value={form.display_name}
onChange={(e) => setForm((prev) => ({ ...prev, display_name: e.target.value }))}
placeholder="例如: GitHub 登录"
onChange={(e) =>
setForm((prev) => ({ ...prev, display_name: e.target.value }))
}
placeholder='例如: GitHub 登录'
/>
</div>
<div className="space-y-2">
<div className='space-y-2'>
<Label>Client ID</Label>
<Input
value={form.client_id}
onChange={(e) => setForm((prev) => ({ ...prev, client_id: e.target.value }))}
onChange={(e) =>
setForm((prev) => ({ ...prev, client_id: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<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 ? "留空则保留原值" : ""}
onChange={(e) =>
setForm((prev) => ({ ...prev, client_secret: e.target.value }))
}
placeholder={source ? '留空则保留原值' : ''}
/>
</div>
<div className="space-y-2 md:col-span-2">
<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://..."
onChange={(e) =>
setForm((prev) => ({
...prev,
openid_discovery_url: e.target.value,
}))
}
placeholder='https://...'
/>
</div>
<div className="space-y-2">
<div className='space-y-2'>
<Label>Scopes</Label>
<Input
value={form.scopes}
onChange={(e) => setForm((prev) => ({ ...prev, scopes: e.target.value }))}
onChange={(e) =>
setForm((prev) => ({ ...prev, scopes: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<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://... 或留空"
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 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 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 }))} />
<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}>
<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
type='button'
onClick={saveSource}
disabled={saving}
variant='secondary'
>
{saving ? '保存中...' : '保存'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
);
}
+108 -64
View File
@@ -1,10 +1,10 @@
"use client"
'use client';
import {useMutation, useQuery} from "@tanstack/react-query"
import {ExternalLink, RefreshCw, Sparkles} from "lucide-react"
import {toast} from "sonner"
import { useMutation, useQuery } from '@tanstack/react-query';
import { ExternalLink, RefreshCw, Sparkles } from 'lucide-react';
import { toast } from 'sonner';
import services from "@/lib/services"
import services from '@/lib/services';
import {
AlertDialog,
AlertDialogAction,
@@ -14,54 +14,64 @@ import {
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"
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 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"],
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("升级包已校验完成,服务正在重启")
toast.success('升级包已校验完成,服务正在重启');
},
onError: (error: Error) => {
toast.error(error.message || "应用升级失败")
toast.error(error.message || '应用升级失败');
},
})
});
const update = updateQuery.data
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 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">
<CardTitle className='text-base font-semibold'>
应用更新
</CardTitle>
<CardDescription className='text-xs'>
检查上游 GitHub Actions Release 并升级当前服务
</CardDescription>
</div>
@@ -69,75 +79,109 @@ export function InfoTab() {
{update?.update_available ? (
<Badge>发现新版本</Badge>
) : update ? (
<Badge variant="secondary">已是最新</Badge>
<Badge variant='secondary'>已是最新</Badge>
) : null}
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4 pt-4">
<CardContent className='flex flex-col gap-4 pt-4'>
{updateQuery.isLoading ? (
<div className="flex min-h-32 items-center justify-center">
<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 || "无法获取上游版本信息"}
<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
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} />
<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">
<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" />}
<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" />
<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
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>
<AlertDialogTitle>
升级到 {update.latest_version}?
</AlertDialogTitle>
<AlertDialogDescription>
服务将下载并校验 {update.asset_name},随后替换当前二进制并重启。请确保安装目录可写,且服务允许原地重启。
服务将下载并校验 {update.asset_name}
,随后替换当前二进制并重启。请确保安装目录可写,且服务允许原地重启。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={() => applyUpdateMutation.mutate()}>
<AlertDialogAction
onClick={() => applyUpdateMutation.mutate()}
>
确认升级
</AlertDialogAction>
</AlertDialogFooter>
@@ -146,12 +190,12 @@ export function InfoTab() {
</div>
{!update.can_upgrade && (
<p className="text-xs text-muted-foreground">
{update.current_version === "dev"
? "开发构建没有可比较的 Release 版本,不能执行自动升级。"
<p className='text-xs text-muted-foreground'>
{update.current_version === 'dev'
? '开发构建没有可比较的 Release 版本,不能执行自动升级。'
: update.update_available
? "当前平台暂不支持自动替换二进制,请从 Release 页面手动升级。"
: "当前版本无需升级。"}
? '当前平台暂不支持自动替换二进制,请从 Release 页面手动升级。'
: '当前版本无需升级。'}
</p>
)}
</>
@@ -159,5 +203,5 @@ export function InfoTab() {
</CardContent>
</Card>
</div>
)
);
}
@@ -1,60 +1,74 @@
"use client"
'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"
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()
const { showBell, setShowBell } = useNotificationSettings();
return (
<div className="py-6 space-y-6">
<div className="font-semibold">
<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>
<Link href='/settings' className='text-base text-primary'>
设置
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className="text-base font-semibold">通知设置</BreadcrumbPage>
<BreadcrumbPage className='text-base font-semibold'>
通知设置
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="space-y-6">
<div className='space-y-6'>
<div>
<h2 className="font-medium text-sm text-foreground">通知显示</h2>
<p className="text-xs text-muted-foreground">
<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">
<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 className='text-xs text-muted-foreground'>
在顶部导航栏中显示通知铃铛图标
</p>
</div>
</div>
<Switch
id="show-bell"
id='show-bell'
checked={showBell}
onCheckedChange={setShowBell}
/>
</div>
</div>
</div>
)
);
}
@@ -1,158 +1,205 @@
"use client"
'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"
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>
configs: Record<string, SystemConfig>;
systemConfigsQuery: UseQueryResult<SystemConfig[], Error>;
}
export function OperationTab({ configs, systemConfigsQuery }: OperationTabProps) {
const queryClient = useQueryClient()
export function OperationTab({
configs,
systemConfigsQuery,
}: OperationTabProps) {
const queryClient = useQueryClient();
const uploadTypesQuery = useQuery({
queryKey: ["admin", "upload-types"],
queryKey: ['admin', 'upload-types'],
queryFn: () => services.adminSystemConfig.listUploadTypes(),
})
});
const updateWhitelistMutation = useMutation({
mutationFn: async (newValue: string) => {
const config = configs["file_access_whitelist"]
const config = configs['file_access_whitelist'];
if (!config) {
throw new Error("缺少配置项: file_access_whitelist")
throw new Error('缺少配置项: file_access_whitelist');
}
await services.adminSystemConfig.updateSystemConfig("file_access_whitelist", {
value: newValue,
description: config.description,
})
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("文件访问白名单已更新")
await queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
});
await queryClient.invalidateQueries({ queryKey: ['public-config'] });
toast.success('文件访问白名单已更新');
},
onError: (error: Error) => {
toast.error(error.message || "更新白名单失败")
toast.error(error.message || '更新白名单失败');
},
})
});
const whitelistConfig = configs["file_access_whitelist"]
const whitelistConfig = configs['file_access_whitelist'];
const currentWhitelist = useMemo<string[]>(() => {
if (!whitelistConfig?.value) return ["avatar"]
if (!whitelistConfig?.value) return ['avatar'];
try {
const parsed = JSON.parse(whitelistConfig.value)
if (Array.isArray(parsed)) return parsed
const parsed = JSON.parse(whitelistConfig.value);
if (Array.isArray(parsed)) return parsed;
} catch {
// 降级支持逗号分隔解析
return whitelistConfig.value.split(",").map(s => s.trim()).filter(Boolean)
return whitelistConfig.value
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}
return ["avatar"]
}, [whitelistConfig?.value])
return ['avatar'];
}, [whitelistConfig?.value]);
const handleAddType = (type: string) => {
if (!type || currentWhitelist.includes(type)) return
const newWhitelist = [...currentWhitelist, type]
updateWhitelistMutation.mutate(JSON.stringify(newWhitelist))
}
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 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])
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">
<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-primary/10 text-primary">
<KeyRound className="size-4" />
<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-primary/10 text-primary'>
<KeyRound className='size-4' />
</div>
<div>
<CardTitle className="text-base font-semibold">文件访问权限控制</CardTitle>
<CardDescription className="text-xs">配置免登录直接访问的文件业务类型。不在白名单内的文件将要求登录鉴权。</CardDescription>
<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>
<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=""
value=''
onValueChange={handleAddType}
disabled={updateWhitelistMutation.isPending || systemConfigsQuery.isPending || uploadTypesQuery.isPending}
disabled={
updateWhitelistMutation.isPending ||
systemConfigsQuery.isPending ||
uploadTypesQuery.isPending
}
>
<SelectTrigger className="w-[200px]" size="sm">
<SelectValue placeholder="选择业务类型..." />
<SelectTrigger className='w-[200px]' size='sm'>
<SelectValue placeholder='选择业务类型...' />
</SelectTrigger>
<SelectContent>
{availableTypes
.filter(t => !currentWhitelist.includes(t.value))
.map(t => (
.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>
{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-primary/30 transition-all duration-300 shadow-sm space-y-3">
<div className="flex items-center gap-2">
<ShieldAlert className="size-4 text-primary" />
<span className="font-medium text-sm text-foreground">当前免鉴权列表</span>
<div className='rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-primary/30 transition-all duration-300 shadow-sm space-y-3'>
<div className='flex items-center gap-2'>
<ShieldAlert className='size-4 text-primary' />
<span className='font-medium text-sm text-foreground'>
当前免鉴权列表
</span>
</div>
{currentWhitelist.length > 0 ? (
<div className="flex flex-wrap gap-2">
{currentWhitelist.map(type => (
<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-primary/10 text-primary dark:bg-primary/20 border border-primary/20"
variant='secondary'
className='px-2.5 py-1 text-xs gap-1.5 flex items-center bg-primary/10 text-primary dark:bg-primary/20 border border-primary/20'
>
{availableTypes.find(t => t.value === type)?.label || type}
{availableTypes.find((t) => t.value === type)?.label ||
type}
<button
type="button"
type='button'
onClick={() => handleRemoveType(type)}
disabled={updateWhitelistMutation.isPending || systemConfigsQuery.isPending}
className="rounded-full outline-hidden hover:bg-primary/20 p-0.5 text-primary cursor-pointer disabled:cursor-not-allowed"
disabled={
updateWhitelistMutation.isPending ||
systemConfigsQuery.isPending
}
className='rounded-full outline-hidden hover:bg-primary/20 p-0.5 text-primary cursor-pointer disabled:cursor-not-allowed'
>
<X className="size-3" />
<X className='size-3' />
</button>
</Badge>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">
<p className='text-xs text-muted-foreground'>
白名单已空,所有类型文件的访问都将需要登录。
</p>
)}
@@ -164,5 +211,5 @@ export function OperationTab({ configs, systemConfigsQuery }: OperationTabProps)
{/* 通知模板管理 */}
<TemplatesManager />
</div>
)
);
}
+186 -91
View File
@@ -1,7 +1,7 @@
"use client"
'use client';
import {useMemo, ComponentType} from "react"
import {useMutation, useQueryClient} from "@tanstack/react-query"
import { ComponentType, useMemo } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
Bell,
Code,
@@ -16,170 +16,265 @@ import {
Settings,
ShieldCheck,
Terminal,
UserRound
} from "lucide-react"
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"
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
path: string;
label: string;
description: string;
icon: ComponentType<{ className?: string }>;
readOnly?: boolean;
}
interface MenuGroup {
name: string
items: MenuItem[]
name: string;
items: MenuItem[];
}
const MENU_GROUPS: MenuGroup[] = [
{
name: "基础菜单",
name: '基础菜单',
items: [
{ path: "/home", label: "首页", description: "系统控制台/个人首页", icon: Home },
{ path: "/files", label: "我的文件", description: "用户个人文件管理与上传", icon: FolderOpen },
]
{
path: '/home',
label: '首页',
description: '系统控制台/个人首页',
icon: Home,
},
{
path: '/files',
label: '我的文件',
description: '用户个人文件管理与上传',
icon: FolderOpen,
},
],
},
{
name: "管理菜单",
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 },
]
{
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: "文档菜单",
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 },
]
}
]
{
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>
configs: Record<string, SystemConfig>;
}
export function OtherTab({ configs }: OtherTabProps) {
const queryClient = useQueryClient()
const queryClient = useQueryClient();
const menuDisplayConfig = useMemo(() => {
const raw = configs["menu_display_config"]?.value
if (!raw) return {} as Record<string, boolean>
const raw = configs['menu_display_config']?.value;
if (!raw) return {} as Record<string, boolean>;
try {
return JSON.parse(raw) as Record<string, boolean>
return JSON.parse(raw) as Record<string, boolean>;
} catch {
return {} as Record<string, boolean>
return {} as Record<string, boolean>;
}
}, [configs])
}, [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})",
})
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("目录显示配置已更新")
await queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
});
await queryClient.invalidateQueries({ queryKey: ['public-config'] });
toast.success('目录显示配置已更新');
},
onError: (error: Error) => {
toast.error(error.message || "更新配置失败")
toast.error(error.message || '更新配置失败');
},
})
});
const handleMenuToggle = (path: string, checked: boolean) => {
updateMenuConfigMutation.mutate({ path, enabled: checked })
}
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-primary/10 text-primary">
<LayoutList className="size-4" />
<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-primary/10 text-primary'>
<LayoutList className='size-4' />
</div>
<div>
<CardTitle className="text-base font-semibold">目录显示管理</CardTitle>
<CardDescription className="text-xs">
<CardTitle className='text-base font-semibold'>
目录显示管理
</CardTitle>
<CardDescription className='text-xs'>
配置系统左侧菜单的显示与隐藏状态,适用于所有登录用户。
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="pt-6 space-y-6">
<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">
<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 className='h-px bg-border/40 flex-1' />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<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
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-primary/30 transition-all duration-300 shadow-sm"
className='flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-primary/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-primary shrink-0" />}
<span className="font-medium text-sm text-foreground truncate">{item.label}</span>
<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-primary 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 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">
<p className='text-xs text-muted-foreground leading-normal line-clamp-2'>
{item.description}
</p>
</div>
<div className="flex items-center">
<div className='flex items-center'>
<Switch
checked={checked}
disabled={isReadOnly || updateMenuConfigMutation.isPending}
onCheckedChange={(val) => handleMenuToggle(item.path, val)}
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-primary/20 bg-primary/5 flex items-start gap-2.5">
<Info className="size-4 text-primary shrink-0 mt-0.5" />
<div className="text-xs text-muted-foreground leading-relaxed">
<span className="font-semibold text-foreground">安全提示:</span>
<div className='p-3.5 rounded-lg border border-dashed border-primary/20 bg-primary/5 flex items-start gap-2.5'>
<Info className='size-4 text-primary 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>
)
);
}
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,12 @@
"use client"
'use client';
import {useEffect, useState} from "react"
import {useMutation, useQuery, useQueryClient, type UseQueryResult} from "@tanstack/react-query"
import { useEffect, useState } from 'react';
import {
useMutation,
useQuery,
useQueryClient,
type UseQueryResult,
} from '@tanstack/react-query';
import {
Clock,
Fingerprint,
@@ -14,263 +19,309 @@ import {
Settings,
Shield,
Trash2,
UserPlus
} from "lucide-react"
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"
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 认证源进行系统登录。",
key: 'password_login_enabled',
title: '允许密码登录',
description: '关闭后仅保留第三方 OIDC 认证源进行系统登录。',
icon: Lock,
},
{
key: "registration_enabled",
title: "允许注册",
description: "关闭后系统将禁止新用户进行自主账号注册。",
key: 'registration_enabled',
title: '允许注册',
description: '关闭后系统将禁止新用户进行自主账号注册。',
icon: UserPlus,
},
{
key: "password_register_enabled",
title: "允许密码注册",
description: "关闭后只能通过管理员创建或第三方认证关联建号。",
key: 'password_register_enabled',
title: '允许密码注册',
description: '关闭后只能通过管理员创建或第三方认证关联建号。',
icon: Fingerprint,
},
{
key: "oidc_login_enabled",
title: "允许 OIDC 登录",
description: "关闭后所有的第三方 OIDC 认证登录入口都会被隐藏。",
key: 'oidc_login_enabled',
title: '允许 OIDC 登录',
description: '关闭后所有的第三方 OIDC 认证登录入口都会被隐藏。',
icon: Globe,
},
{
key: "email_login_verification_enabled",
title: "邮箱登录验证",
description: "开启后,使用账号密码登录时需要通过邮箱接收并验证 6 位验证码。",
key: 'email_login_verification_enabled',
title: '邮箱登录验证',
description:
'开启后,使用账号密码登录时需要通过邮箱接收并验证 6 位验证码。',
icon: Mail,
},
{
key: "email_register_verification_enabled",
title: "邮箱注册验证",
description: "开启后,用户注册账号时需要通过邮箱接收并验证 6 位验证码。",
key: 'email_register_verification_enabled',
title: '邮箱注册验证',
description: '开启后,用户注册账号时需要通过邮箱接收并验证 6 位验证码。',
icon: Mail,
},
] as const
] as const;
interface SecurityTabProps {
configs: Record<string, SystemConfig>
systemConfigsQuery: UseQueryResult<SystemConfig[], Error>
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 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 [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 [sessionTTL, setSessionTTL] = useState('168');
const [customHours, setCustomHours] = useState('');
const authSourcesQuery = useQuery({
queryKey: ["auth", "sources"],
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 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("")
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)
setSessionTTL('custom');
setCustomHours(ttlVal);
}
}
}, [systemConfigsQuery.data, configs])
}, [systemConfigsQuery.data, configs]);
const updateTTLMutation = useMutation({
mutationFn: async (value: string) => {
const config = configs["login_session_ttl_hours"]
const config = configs['login_session_ttl_hours'];
if (!config) {
throw new Error("缺少配置项: login_session_ttl_hours")
throw new Error('缺少配置项: login_session_ttl_hours');
}
await services.adminSystemConfig.updateSystemConfig("login_session_ttl_hours", {
value: value,
description: config.description,
})
await services.adminSystemConfig.updateSystemConfig(
'login_session_ttl_hours',
{
value: value,
description: config.description,
},
);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
toast.success("登录状态保持时间已更新")
await queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
});
toast.success('登录状态保持时间已更新');
},
onError: (error: Error) => {
toast.error(error.message || "更新配置失败")
toast.error(error.message || '更新配置失败');
},
})
});
const handleTTLChange = (val: string) => {
setSessionTTL(val)
if (val !== "custom") {
updateTTLMutation.mutate(val)
setSessionTTL(val);
if (val !== 'custom') {
updateTTLMutation.mutate(val);
}
}
};
const handleCustomBlur = () => {
const parsed = parseInt(customHours, 10)
const parsed = parseInt(customHours, 10);
if (isNaN(parsed) || parsed <= 0) {
toast.error("请输入有效的过期小时数(正整数)")
toast.error('请输入有效的过期小时数(正整数)');
// 重置为原本的值
const originalVal = configs["login_session_ttl_hours"]?.value || "0"
setCustomHours(originalVal === "custom" || ["0", "168", "720", "-1"].includes(originalVal) ? "" : originalVal)
return
const originalVal = configs['login_session_ttl_hours']?.value || '0';
setCustomHours(
originalVal === 'custom' ||
['0', '168', '720', '-1'].includes(originalVal)
? ''
: originalVal,
);
return;
}
updateTTLMutation.mutate(parsed.toString())
}
updateTTLMutation.mutate(parsed.toString());
};
const updateConfigMutation = useMutation({
mutationFn: async ({ key, value }: { key: string; value: boolean }) => {
const config = configs[key]
const config = configs[key];
if (!config) {
throw new Error(`缺少配置项: ${key}`)
throw new Error(`缺少配置项: ${key}`);
}
await services.adminSystemConfig.updateSystemConfig(key, {
value: value ? "true" : "false",
value: value ? 'true' : 'false',
description: config.description,
})
});
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
await queryClient.invalidateQueries({ queryKey: ["public-config"] })
toast.success("系统安全配置已更新")
await queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
});
await queryClient.invalidateQueries({ queryKey: ['public-config'] });
toast.success('系统安全配置已更新');
},
onError: (error: Error) => {
toast.error(error.message || "更新配置失败")
toast.error(error.message || '更新配置失败');
},
})
});
const toggleSourceMutation = useMutation({
mutationFn: async (source: AuthSource) => {
await services.adminAuthSource.toggleAuthSource(source.id, { is_active: !source.is_active })
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("认证源状态已更新")
await queryClient.invalidateQueries({ queryKey: ['auth', 'sources'] });
await queryClient.invalidateQueries({
queryKey: ['auth', 'public-sources'],
});
toast.success('认证源状态已更新');
},
onError: (error: Error) => {
toast.error(error.message || "切换状态失败")
toast.error(error.message || '切换状态失败');
},
})
});
const deleteSourceMutation = useMutation({
mutationFn: async (sourceId: string) => {
await services.adminAuthSource.deleteAuthSource(sourceId)
await services.adminAuthSource.deleteAuthSource(sourceId);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["auth", "sources"] })
await queryClient.invalidateQueries({ queryKey: ["auth", "public-sources"] })
toast.success("认证源已删除")
await queryClient.invalidateQueries({ queryKey: ['auth', 'sources'] });
await queryClient.invalidateQueries({
queryKey: ['auth', 'public-sources'],
});
toast.success('认证源已删除');
},
onError: (error: Error) => {
toast.error(error.message || "删除认证源失败")
toast.error(error.message || '删除认证源失败');
},
})
});
const handleToggle = (key: string, checked: boolean) => {
updateConfigMutation.mutate({ key, value: checked })
}
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" },
]
{ 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]
const currentCfg = configs[update.key];
await services.adminSystemConfig.updateSystemConfig(update.key, {
value: update.value,
description: currentCfg?.description || "",
})
description: currentCfg?.description || '',
});
}
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
toast.success("人机验证配置已成功保存")
await queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
});
toast.success('人机验证配置已成功保存');
},
onError: (error: Error) => {
toast.error(error.message || "保存配置失败")
toast.error(error.message || '保存配置失败');
},
})
});
const handleCapSave = (e: React.FormEvent) => {
e.preventDefault()
saveCapMutation.mutate()
}
e.preventDefault();
saveCapMutation.mutate();
};
return (
<div className="space-y-6">
<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-primary/10 text-primary">
<Settings className="size-4" />
<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-primary/10 text-primary'>
<Settings className='size-4' />
</div>
<div>
<CardTitle className="text-base font-semibold">系统登录/注册设置</CardTitle>
<CardDescription className="text-xs">配置系统的登录限制与用户自主注册权限</CardDescription>
<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">
<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
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-primary/30 transition-all duration-300 shadow-sm"
className='flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-primary/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-primary" />}
<span className="font-medium text-sm text-foreground">{item.title}</span>
<div className='space-y-1'>
<div className='flex items-center gap-2'>
{Icon && <Icon className='size-4 text-primary' />}
<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>
<p className='text-xs text-muted-foreground leading-relaxed pr-2'>
{item.description}
</p>
</div>
<Switch
checked={checked}
@@ -278,56 +329,56 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
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-primary/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-primary" />
<span className="font-medium text-sm text-foreground">登录状态保持时间</span>
<div className='flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-primary/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-primary' />
<span className='font-medium text-sm text-foreground'>
登录状态保持时间
</span>
</div>
<p className="text-xs text-muted-foreground leading-relaxed pr-2">
<p className='text-xs text-muted-foreground leading-relaxed pr-2'>
配置用户登录会话在浏览器中的保持期限。设置为“关闭”则在浏览器关闭后自动退登。
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<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 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>
<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" && (
{sessionTTL === 'custom' && (
<Input
type="number"
type='number'
min={1}
value={customHours}
onChange={(e) => setCustomHours(e.target.value)}
onBlur={handleCustomBlur}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleCustomBlur()
if (e.key === 'Enter') {
handleCustomBlur();
}
}}
placeholder="小时"
placeholder='小时'
disabled={updateTTLMutation.isPending}
className="w-20 bg-card border-dashed text-xs h-8 px-2"
className='w-20 bg-card border-dashed text-xs h-8 px-2'
/>
)}
</div>
@@ -337,116 +388,134 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
</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-primary/10 text-primary">
<Globe className="size-4" />
<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-primary/10 text-primary'>
<Globe className='size-4' />
</div>
<div>
<CardTitle className="text-base font-semibold">认证源管理</CardTitle>
<CardDescription className="text-xs">添加、修改并启用系统自定义的 OIDC 认证源</CardDescription>
<CardTitle className='text-base font-semibold'>
认证源管理
</CardTitle>
<CardDescription className='text-xs'>
添加、修改并启用系统自定义的 OIDC 认证源
</CardDescription>
</div>
</div>
<Button
type="button"
size="sm"
type='button'
size='sm'
onClick={() => {
setSelectedSource(null)
setAuthSourceModalOpen(true)
setSelectedSource(null);
setAuthSourceModalOpen(true);
}}
variant="secondary"
variant='secondary'
>
<Plus className="mr-1.5 size-3.5" />
<Plus className='mr-1.5 size-3.5' />
新增认证源
</Button>
</CardHeader>
<CardContent className="pt-6 space-y-3">
<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 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"
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 ? "已启用" : "已禁用"}
<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">
<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-primary/5 text-primary border-primary/10"
: "bg-rose-500/5 text-rose-500 border-rose-500/10"
}`}>
{source.client_secret_configured ? "Secret 已配置" : "Secret 未配置"}
<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-primary/5 text-primary border-primary/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">
<div className='flex items-center gap-2'>
<Switch
checked={source.is_active}
disabled={toggleSourceMutation.isPending}
className="scale-90 mr-2"
onCheckedChange={() => toggleSourceMutation.mutate(source)}
className='scale-90 mr-2'
onCheckedChange={() =>
toggleSourceMutation.mutate(source)
}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
type='button'
variant='ghost'
size='icon'
className='size-8 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-lg transition-colors'
onClick={() => {
setSelectedSource(source)
setAuthSourceModalOpen(true)
setSelectedSource(source);
setAuthSourceModalOpen(true);
}}
>
<Pencil className="size-4" />
<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"
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)
if (
window.confirm(
`确定删除认证源「${source.display_name || source.name}」吗?`,
)
) {
deleteSourceMutation.mutate(source.id);
}
}}
>
<Trash2 className="size-4" />
<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">
<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"
type='button'
size='sm'
variant='outline'
onClick={() => {
setSelectedSource(null)
setAuthSourceModalOpen(true)
setSelectedSource(null);
setAuthSourceModalOpen(true);
}}
className="border-dashed"
className='border-dashed'
>
<Plus className="mr-1.5 size-3.5" />
<Plus className='mr-1.5 size-3.5' />
新增认证源
</Button>
</div>
@@ -455,123 +524,163 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
</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-primary/10 text-primary">
<Shield className="size-4" />
<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-primary/10 text-primary'>
<Shield className='size-4' />
</div>
<div>
<CardTitle className="text-base font-semibold">人机验证配置 (Cap CAPTCHA)</CardTitle>
<CardDescription className="text-xs">配置基于 Proof-of-Work (PoW) 的无感人机验证,保护系统登录免受暴力破解和撞库攻击</CardDescription>
<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"}
checked={configs['cap_login_enabled']?.value === 'true'}
disabled={updateConfigMutation.isPending}
onCheckedChange={(checked) => handleToggle("cap_login_enabled", checked)}
onCheckedChange={(checked) =>
handleToggle('cap_login_enabled', checked)
}
/>
</CardHeader>
<CardContent className="pt-6">
<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 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}
/>
<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>
<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"
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"
placeholder='50'
className='bg-card border-dashed text-xs'
/>
<p className="text-[10px] text-muted-foreground leading-normal">客户端需求解的难题总数。默认 1,推荐 1 至 5</p>
<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>
<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"
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"
placeholder='4'
className='bg-card border-dashed text-xs'
/>
<p className="text-[10px] text-muted-foreground leading-normal">PoW 前缀哈希位数,每加 1 计算时间翻倍。默认 4,推荐 4</p>
<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>
<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"
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"
placeholder='32'
className='bg-card border-dashed text-xs'
/>
<p className="text-[10px] text-muted-foreground leading-normal">难题盐值混淆字符长度。默认 32</p>
<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>
<div className='space-y-1.5'>
<Label
htmlFor='cap_challenge_ttl'
className='text-xs font-semibold'
>
难题超时时长 (秒)
</Label>
<Input
id="cap_challenge_ttl"
type="number"
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"
placeholder='600'
className='bg-card border-dashed text-xs'
/>
<p className="text-[10px] text-muted-foreground leading-normal">难题有效期限。默认 600 秒 (10 分钟)</p>
<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>
<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"
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"
placeholder='1200'
className='bg-card border-dashed text-xs'
/>
<p className="text-[10px] text-muted-foreground leading-normal">PoW 计算求解通过后,签发的登录凭证有效时长。默认 1200 秒 (20 分钟)</p>
<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">
<div className='flex justify-end pt-4 border-t border-dashed'>
<Button
type="submit"
size="sm"
type='submit'
size='sm'
disabled={saveCapMutation.isPending}
>
{saveCapMutation.isPending ? (
<>
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
保存中...
</>
) : (
"保存配置"
'保存配置'
)}
</Button>
</div>
@@ -584,11 +693,15 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
source={selectedSource}
onClose={() => setAuthSourceModalOpen(false)}
onChanged={async () => {
await queryClient.invalidateQueries({ queryKey: ["auth", "sources"] })
await queryClient.invalidateQueries({ queryKey: ["auth", "public-sources"] })
await authSourcesQuery.refetch()
await queryClient.invalidateQueries({
queryKey: ['auth', 'sources'],
});
await queryClient.invalidateQueries({
queryKey: ['auth', 'public-sources'],
});
await authSourcesQuery.refetch();
}}
/>
</div>
)
);
}
+296 -227
View File
@@ -1,152 +1,178 @@
"use client"
'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 { 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"
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>
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("")
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 || "")
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])
}, [systemConfigsQuery.data, configs]);
const handleDetectAddress = () => {
if (typeof window !== "undefined") {
setServerAddress(window.location.origin)
toast.success("已自动获取当前域名并填充")
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]
const config = configs[key];
if (!config) {
throw new Error(`缺少配置项: ${key}`)
throw new Error(`缺少配置项: ${key}`);
}
await services.adminSystemConfig.updateSystemConfig(key, {
value: value ? "true" : "false",
value: value ? 'true' : 'false',
description: config.description,
})
});
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
await queryClient.invalidateQueries({ queryKey: ["public-config"] })
toast.success("配置已更新")
await queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
});
await queryClient.invalidateQueries({ queryKey: ['public-config'] });
toast.success('配置已更新');
},
onError: (error: Error) => {
toast.error(error.message || "更新配置失败")
toast.error(error.message || '更新配置失败');
},
})
});
const saveSystemMutation = useMutation({
mutationFn: async () => {
const currentAddrCfg = configs["server_address"]
const currentSiteCfg = configs["site_name"]
const currentAddrCfg = configs['server_address'];
const currentSiteCfg = configs['site_name'];
await Promise.all([
services.adminSystemConfig.updateSystemConfig("server_address", {
services.adminSystemConfig.updateSystemConfig('server_address', {
value: serverAddress,
description: currentAddrCfg?.description || "服务器地址",
description: currentAddrCfg?.description || '服务器地址',
}),
services.adminSystemConfig.updateSystemConfig("site_name", {
services.adminSystemConfig.updateSystemConfig('site_name', {
value: siteName,
description: currentSiteCfg?.description || "系统平台的展示名称",
description: currentSiteCfg?.description || '系统平台的展示名称',
}),
])
]);
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] }),
queryClient.invalidateQueries({ queryKey: ["public-config"] }),
])
toast.success("通用配置已成功保存")
queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
}),
queryClient.invalidateQueries({ queryKey: ['public-config'] }),
]);
toast.success('通用配置已成功保存');
},
onError: (error: Error) => {
toast.error(error.message || "保存配置失败")
toast.error(error.message || '保存配置失败');
},
})
});
const handleSystemSave = (e: React.FormEvent) => {
e.preventDefault()
saveSystemMutation.mutate()
}
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 },
]
{ 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 === "******")) {
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
if (currentCfg && currentCfg.value === '******') {
continue;
}
}
await services.adminSystemConfig.updateSystemConfig(update.key, {
value: update.value,
description: currentCfg?.description || "",
})
description: currentCfg?.description || '',
});
}
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
toast.success("SMTP 邮件配置已成功保存")
await queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
});
toast.success('SMTP 邮件配置已成功保存');
},
onError: (error: Error) => {
toast.error(error.message || "保存配置失败")
toast.error(error.message || '保存配置失败');
},
})
});
const handleSmtpSave = (e: React.FormEvent) => {
e.preventDefault()
saveSmtpMutation.mutate()
}
e.preventDefault();
saveSmtpMutation.mutate();
};
const testSmtpMutation = useMutation({
mutationFn: async () => {
setSmtpTestLog("正在发起连接测试...\n")
setSmtpTestSuccess(null)
setSmtpTestError("")
setSmtpTestLog('正在发起连接测试...\n');
setSmtpTestSuccess(null);
setSmtpTestError('');
const res = await services.adminSystemConfig.testSMTP({
smtp_host: smtpHost,
@@ -154,151 +180,167 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
smtp_username: smtpUsername,
smtp_password: smtpPassword,
to: smtpTestTo,
})
return res
});
return res;
},
onSuccess: (data) => {
setSmtpTestLog(data.log)
setSmtpTestLog(data.log);
if (data.success) {
setSmtpTestSuccess(true)
toast.success("测试邮件发送成功")
setSmtpTestSuccess(true);
toast.success('测试邮件发送成功');
} else {
setSmtpTestSuccess(false)
setSmtpTestError(data.error || "发送失败,请检查配置和日志。")
toast.error("测试邮件发送失败")
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 || "测试请求发送失败")
setSmtpTestSuccess(false);
setSmtpTestError(error.message || '请求发送失败');
setSmtpTestLog((prev) => prev + `\n[请求错误] ${error.message}\n`);
toast.error(error.message || '测试请求发送失败');
},
})
});
const handleSmtpTestSubmit = (e: React.FormEvent) => {
e.preventDefault()
e.preventDefault();
if (!smtpTestTo) {
toast.error("请输入目标邮箱地址")
return
toast.error('请输入目标邮箱地址');
return;
}
testSmtpMutation.mutate()
}
testSmtpMutation.mutate();
};
const indexingEnabled = configs["search_engine_indexing_enabled"]?.value === "true"
const indexingEnabled =
configs['search_engine_indexing_enabled']?.value === 'true';
return (
<div className="space-y-8">
<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-primary/10 p-1.5 text-primary">
<Server className="size-4" />
<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-primary/10 p-1.5 text-primary'>
<Server className='size-4' />
</div>
<div>
<CardTitle className="text-base font-semibold">通用设置</CardTitle>
<CardDescription className="text-xs">
<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">
<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"
id='site_name'
type='text'
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
placeholder="例如: Wavelet"
className="border-dashed bg-card text-xs"
placeholder='例如: Wavelet'
className='border-dashed bg-card text-xs'
/>
<p className="text-[11px] leading-relaxed text-muted-foreground">
<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">
<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"
type='button'
variant='link'
size='sm'
onClick={handleDetectAddress}
className="h-auto px-0 text-xs"
className='h-auto px-0 text-xs'
>
<Sparkles data-icon="inline-start" />
<Sparkles data-icon='inline-start' />
使用当前域名
</Button>
</div>
<Input
id="server_address"
type="url"
id='server_address'
type='url'
value={serverAddress}
onChange={(e) => setServerAddress(e.target.value)}
placeholder="例如: https://example.com"
className="border-dashed bg-card text-xs"
placeholder='例如: https://example.com'
className='border-dashed bg-card text-xs'
/>
<p className="text-[11px] leading-relaxed text-muted-foreground">
<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 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 ? "允许收录" : "禁止收录"}
<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">
<p className='text-[11px] leading-relaxed text-muted-foreground'>
控制 Google、Baidu、Bing 等搜索引擎是否可以索引公开页面。
</p>
</div>
</div>
<Switch
aria-label="允许搜索引擎抓取与收录"
aria-label='允许搜索引擎抓取与收录'
checked={indexingEnabled}
disabled={updateConfigMutation.isPending || systemConfigsQuery.isPending}
disabled={
updateConfigMutation.isPending || systemConfigsQuery.isPending
}
onCheckedChange={(checked) =>
updateConfigMutation.mutate({ key: "search_engine_indexing_enabled", value: 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 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"
type='submit'
size='sm'
disabled={saveSystemMutation.isPending}
>
{saveSystemMutation.isPending ? (
<>
<Loader2 data-icon="inline-start" className="animate-spin" />
<Loader2
data-icon='inline-start'
className='animate-spin'
/>
保存中...
</>
) : (
"保存配置"
'保存配置'
)}
</Button>
</div>
@@ -307,98 +349,120 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
</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-primary/10 text-primary">
<Mail className="size-4" />
<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-primary/10 text-primary'>
<Mail className='size-4' />
</div>
<div>
<CardTitle className="text-base font-semibold">SMTP 邮件设置</CardTitle>
<CardDescription className="text-xs">配置系统的邮件发送服务 (SMTP)</CardDescription>
<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>
<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"
id='smtp_host'
type='text'
value={smtpHost}
onChange={(e) => setSmtpHost(e.target.value)}
placeholder="例如: smtp.example.com"
className="bg-card border-dashed text-xs"
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>
<div className='space-y-1.5'>
<Label htmlFor='smtp_port' className='text-xs font-semibold'>
SMTP 端口
</Label>
<Input
id="smtp_port"
type="number"
id='smtp_port'
type='number'
value={smtpPort}
onChange={(e) => setSmtpPort(e.target.value)}
placeholder="例如: 587 或 465"
className="bg-card border-dashed text-xs"
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>
<div className='space-y-1.5'>
<Label
htmlFor='smtp_username'
className='text-xs font-semibold'
>
SMTP 账户
</Label>
<Input
id="smtp_username"
type="text"
id='smtp_username'
type='text'
value={smtpUsername}
onChange={(e) => setSmtpUsername(e.target.value)}
placeholder="例如: sender@example.com"
className="bg-card border-dashed text-xs"
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>
<div className='space-y-1.5'>
<Label
htmlFor='smtp_password'
className='text-xs font-semibold'
>
SMTP 访问凭证
</Label>
<Input
id="smtp_password"
type="password"
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"
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">
<div className='flex justify-end gap-2 pt-4 border-t border-dashed'>
<Button
type="button"
variant="outline"
size="sm"
type='button'
variant='outline'
size='sm'
onClick={() => {
setSmtpTestOpen(true)
setSmtpTestTo("")
setSmtpTestLog("")
setSmtpTestSuccess(null)
setSmtpTestError("")
setSmtpTestOpen(true);
setSmtpTestTo('');
setSmtpTestLog('');
setSmtpTestSuccess(null);
setSmtpTestError('');
}}
disabled={saveSmtpMutation.isPending}
>
测试发件
</Button>
<Button
type="submit"
size="sm"
type='submit'
size='sm'
disabled={saveSmtpMutation.isPending}
>
{saveSmtpMutation.isPending ? (
<>
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
保存中...
</>
) : (
"保存配置"
'保存配置'
)}
</Button>
</div>
@@ -407,72 +471,77 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
</Card>
<Dialog open={smtpTestOpen} onOpenChange={setSmtpTestOpen}>
<DialogContent className="max-w-lg border border-dashed">
<DialogContent className='max-w-lg border border-dashed'>
<DialogHeader>
<DialogTitle className="text-base font-semibold">SMTP 发件测试</DialogTitle>
<DialogDescription className="text-xs">
输入接收测试邮件的邮箱地址。系统将使用您在表单中当前填写的 SMTP 配置进行发件测试。
<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>
<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"
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"
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">
<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 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">
<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">
<DialogFooter className='gap-2 sm:gap-0 border-t border-dashed pt-4'>
<Button
type="button"
variant="ghost"
size="sm"
type='button'
variant='ghost'
size='sm'
onClick={() => setSmtpTestOpen(false)}
disabled={testSmtpMutation.isPending}
>
关闭
</Button>
<Button
type="submit"
size="sm"
type='submit'
size='sm'
disabled={testSmtpMutation.isPending}
>
{testSmtpMutation.isPending ? (
<>
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
测试中...
</>
) : (
"开始测试"
'开始测试'
)}
</Button>
</DialogFooter>
@@ -480,5 +549,5 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
</DialogContent>
</Dialog>
</div>
)
);
}
+201 -172
View File
@@ -1,36 +1,51 @@
"use client"
'use client';
import {useState} from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {FileText, Loader2, Pencil, Plus, Trash2} from "lucide-react"
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"
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)
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 [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"],
queryKey: ['admin', 'templates'],
queryFn: () => services.adminTemplate.listTemplates(),
})
});
const createTemplateMutation = useMutation({
mutationFn: async () => {
@@ -41,17 +56,17 @@ export function TemplatesManager() {
subject,
content,
description,
})
});
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "templates"] })
toast.success("通知模板已成功创建")
setModalOpen(false)
await queryClient.invalidateQueries({ queryKey: ['admin', 'templates'] });
toast.success('通知模板已成功创建');
setModalOpen(false);
},
onError: (error: Error) => {
toast.error(error.message || "创建模板失败")
toast.error(error.message || '创建模板失败');
},
})
});
const updateTemplateMutation = useMutation({
mutationFn: async (key: string) => {
@@ -61,164 +76,178 @@ export function TemplatesManager() {
subject,
content,
description,
})
});
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "templates"] })
toast.success("通知模板已成功保存")
setModalOpen(false)
await queryClient.invalidateQueries({ queryKey: ['admin', 'templates'] });
toast.success('通知模板已成功保存');
setModalOpen(false);
},
onError: (error: Error) => {
toast.error(error.message || "修改模板失败")
toast.error(error.message || '修改模板失败');
},
})
});
const deleteTemplateMutation = useMutation({
mutationFn: async (key: string) => {
await services.adminTemplate.deleteTemplate(key)
await services.adminTemplate.deleteTemplate(key);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "templates"] })
toast.success("通知模板已删除")
await queryClient.invalidateQueries({ queryKey: ['admin', 'templates'] });
toast.success('通知模板已删除');
},
onError: (error: Error) => {
toast.error(error.message || "删除模板失败")
toast.error(error.message || '删除模板失败');
},
})
});
const handleOpenCreate = () => {
setSelectedTemplate(null)
setKey("")
setName("")
setType("email")
setSubject("")
setContent("")
setDescription("")
setModalOpen(true)
}
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)
}
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()
e.preventDefault();
if (selectedTemplate) {
updateTemplateMutation.mutate(selectedTemplate.key)
updateTemplateMutation.mutate(selectedTemplate.key);
} else {
createTemplateMutation.mutate()
createTemplateMutation.mutate();
}
}
};
const isPending = createTemplateMutation.isPending || updateTemplateMutation.isPending
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-primary/10 text-primary">
<FileText className="size-4" />
<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-primary/10 text-primary'>
<FileText className='size-4' />
</div>
<div>
<CardTitle className="text-base font-semibold">通知模板管理</CardTitle>
<CardDescription className="text-xs">
<CardTitle className='text-base font-semibold'>
通知模板管理
</CardTitle>
<CardDescription className='text-xs'>
配置与编辑系统各类场景的通知邮件/短信模板,支持动态变量渲染
</CardDescription>
</div>
</div>
<Button
type="button"
size="sm"
type='button'
size='sm'
onClick={handleOpenCreate}
variant="secondary"
variant='secondary'
>
<Plus className="mr-1.5 size-3.5" />
<Plus className='mr-1.5 size-3.5' />
新增模板
</Button>
</CardHeader>
<CardContent className="pt-6 space-y-4">
<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 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">
<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-primary/30 transition-all duration-300 shadow-sm"
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-primary/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-primary/10 text-primary border-primary/20"
: "bg-amber-500/10 text-amber-500 border-amber-500/20"
}`}>
{tmpl.is_system ? "系统内置" : "自定义"}
<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 border-border/50 bg-muted/50 text-muted-foreground font-mono">
<span
className={`text-[10px] px-2 py-0.5 rounded-full border font-medium ${
tmpl.is_system
? 'bg-primary/10 text-primary border-primary/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-primary bg-primary/5 px-1.5 py-0.5 rounded">{tmpl.key}</span>
<div className='text-xs text-muted-foreground'>
标识符:{' '}
<span className='font-mono text-primary bg-primary/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">
<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">
<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-primary hover:bg-primary/10 rounded-lg transition-colors"
type='button'
variant='ghost'
size='icon'
className='size-8 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-lg transition-colors'
onClick={() => handleOpenEdit(tmpl)}
>
<Pencil className="size-4" />
<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}
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)
if (
window.confirm(`确定删除模板「${tmpl.name}」吗?`)
) {
deleteTemplateMutation.mutate(tmpl.key);
}
}}
>
<Trash2 className="size-4" />
<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">
<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"
type='button'
size='sm'
variant='outline'
onClick={handleOpenCreate}
className="border-dashed"
className='border-dashed'
>
<Plus className="mr-1.5 size-3.5" />
<Plus className='mr-1.5 size-3.5' />
新增模板
</Button>
</div>
@@ -227,142 +256,142 @@ export function TemplatesManager() {
</Card>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="max-w-xl border border-dashed">
<DialogContent className='max-w-xl border border-dashed'>
<DialogHeader>
<DialogTitle className="text-base font-semibold">
{selectedTemplate ? "编辑通知模板" : "新增通知模板"}
<DialogTitle className='text-base font-semibold'>
{selectedTemplate ? '编辑通知模板' : '新增通知模板'}
</DialogTitle>
<DialogDescription className="text-xs">
配置模板渲染逻辑,支持 Go `text/template` 语法(例如 `{"{{.Code}}"}` 表示验证码变量)。
<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">
<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"
id='tmpl_key'
type='text'
required
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="例如: login_email"
className="bg-card border-dashed text-xs"
placeholder='例如: login_email'
className='bg-card border-dashed text-xs'
disabled={!!selectedTemplate}
/>
<p className="text-[10px] text-muted-foreground leading-normal">
<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">
<div className='space-y-1.5'>
<Label htmlFor='tmpl_name' className='text-xs font-semibold'>
模板名称
</Label>
<Input
id="tmpl_name"
type="text"
id='tmpl_name'
type='text'
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如: 登录验证码邮件"
className="bg-card border-dashed text-xs"
placeholder='例如: 登录验证码邮件'
className='bg-card border-dashed text-xs'
/>
<p className="text-[10px] text-muted-foreground leading-normal">
<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">
<div className='space-y-1.5'>
<Label htmlFor='tmpl_type' className='text-xs font-semibold'>
模板类型
</Label>
<Input
id="tmpl_type"
type="text"
id='tmpl_type'
type='text'
required
value={type}
onChange={(e) => setType(e.target.value)}
placeholder="email"
className="bg-card border-dashed text-xs"
placeholder='email'
className='bg-card border-dashed text-xs'
/>
<p className="text-[10px] text-muted-foreground leading-normal">
<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">
<div className='space-y-1.5'>
<Label htmlFor='tmpl_subject' className='text-xs font-semibold'>
模板主题 (Subject)
</Label>
<Input
id="tmpl_subject"
type="text"
id='tmpl_subject'
type='text'
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="例如: Wavelet 登录验证码"
className="bg-card border-dashed text-xs"
placeholder='例如: Wavelet 登录验证码'
className='bg-card border-dashed text-xs'
/>
<p className="text-[10px] text-muted-foreground leading-normal">
<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">
<div className='space-y-1.5'>
<Label
htmlFor='tmpl_description'
className='text-xs font-semibold'
>
模板说明与变量描述
</Label>
<Input
id="tmpl_description"
type="text"
id='tmpl_description'
type='text'
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="例如: 包含变量:{{.Code}},5分钟内有效"
className="bg-card border-dashed text-xs"
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">
<div className='space-y-1.5'>
<Label htmlFor='tmpl_content' className='text-xs font-semibold'>
模板正文内容 (Content)
</Label>
<Textarea
id="tmpl_content"
id='tmpl_content'
required
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="<h3>正文标题</h3><p>内容段落,可用变量 {{.Code}}</p>"
placeholder='<h3>正文标题</h3><p>内容段落,可用变量 {{.Code}}</p>'
rows={8}
className="bg-card border-dashed text-xs font-mono"
className='bg-card border-dashed text-xs font-mono'
/>
</div>
<DialogFooter className="gap-2 sm:gap-0 border-t border-dashed pt-4">
<DialogFooter className='gap-2 sm:gap-0 border-t border-dashed pt-4'>
<Button
type="button"
variant="ghost"
size="sm"
type='button'
variant='ghost'
size='sm'
onClick={() => setModalOpen(false)}
disabled={isPending}
>
取消
</Button>
<Button
type="submit"
size="sm"
disabled={isPending}
>
<Button type='submit' size='sm' disabled={isPending}>
{isPending ? (
<>
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
保存中...
</>
) : (
"保存配置"
'保存配置'
)}
</Button>
</DialogFooter>
@@ -370,5 +399,5 @@ export function TemplatesManager() {
</DialogContent>
</Dialog>
</div>
)
);
}
+208 -155
View File
@@ -1,7 +1,7 @@
"use client"
'use client';
import * as React from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
ChevronLeft,
ChevronRight,
@@ -17,14 +17,14 @@ import {
Plus,
Search,
Trash2,
Upload
} from "lucide-react"
import {toast} from "sonner"
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 { 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,
@@ -34,232 +34,264 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
} 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"
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 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",
})
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 布局)
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])
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),
})
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)
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)
return services.upload.uploadFile(
file,
'generic',
undefined,
uploadAccessMode,
);
},
onMutate: () => {
setUploading(true)
setUploading(true);
},
onSuccess: () => {
toast.success("文件上传成功")
void queryClient.invalidateQueries({ queryKey: ["user-files"] })
toast.success('文件上传成功');
void queryClient.invalidateQueries({ queryKey: ['user-files'] });
},
onError: (err: Error) => {
toast.error(err.message || "上传失败")
toast.error(err.message || '上传失败');
},
onSettled: () => {
setUploading(false)
setUploading(false);
if (fileInputRef.current) {
fileInputRef.current.value = ""
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)
toast.success('文件已成功删除');
void queryClient.invalidateQueries({ queryKey: ['user-files'] });
setDeleteTarget(null);
},
onError: (err: Error) => {
toast.error(err.message || "删除失败")
}
})
toast.error(err.message || '删除失败');
},
});
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
const file = e.target.files?.[0];
if (file) {
uploadMutation.mutate(file)
uploadMutation.mutate(file);
}
}
};
const triggerUploadClick = () => {
fileInputRef.current?.click()
}
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()
}
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 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" />
<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="搜索文件名..."
placeholder='搜索文件名...'
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className="pl-9 h-9"
className='pl-9 h-9'
/>
</div>
<div className="flex items-center gap-3 self-end sm:self-auto">
<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">
<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"
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" />
<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"
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" />
<Globe className='mr-1 size-3 text-sky-500' />
公开
</Button>
</div>
<input
type="file"
type='file'
ref={fileInputRef}
onChange={handleFileChange}
className="hidden"
className='hidden'
disabled={uploading}
/>
<Button
onClick={triggerUploadClick}
disabled={uploading}
className="h-9 gap-1.5 text-xs font-medium"
className='h-9 gap-1.5 text-xs font-medium'
>
{uploading ? (
<Loader2 className="size-3.5 animate-spin" />
<Loader2 className='size-3.5 animate-spin' />
) : (
<Plus className="size-3.5" />
<Plus className='size-3.5' />
)}
{uploading ? "正在上传..." : "上传文件"}
{uploading ? '正在上传...' : '上传文件'}
</Button>
</div>
</div>
{/* 文件展示区 */}
{isPending ? (
<div className="flex items-center justify-center py-24">
<Loader2 className="size-8 animate-spin text-primary" />
<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" />
<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 ? "未搜索到匹配的文件" : "上传您的第一个文件,支持图片、视频、文档和压缩包等格式。"}
<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
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">
<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"
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/") ? (
<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"
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">
<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
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
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>
)}
@@ -267,38 +299,48 @@ export function UserFileManager() {
</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}>
<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">
<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>
<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 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">
<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="下载文件"
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" />
<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="删除文件"
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" />
<Trash2 className='size-3.5' />
</Button>
</div>
</div>
@@ -310,57 +352,68 @@ export function UserFileManager() {
{/* 分页 */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-4">
<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"
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" />
<ChevronLeft className='mr-1 size-3.5' />
上一页
</Button>
<span className="text-xs text-muted-foreground font-medium px-2">
<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"
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" />
<ChevronRight className='ml-1 size-3.5' />
</Button>
</div>
)}
{/* 删除确认 Dialog */}
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent suppressHydrationWarning>
<AlertDialogHeader>
<AlertDialogTitle>确认删除文件</AlertDialogTitle>
<AlertDialogDescription>
确定要删除文件{" "}
<span className="font-semibold text-foreground">「{deleteTarget?.file_name}」</span>{" "}
确定要删除文件{' '}
<span className='font-semibold text-foreground'>
「{deleteTarget?.file_name}」
</span>{' '}
吗?删除后此文件将无法恢复。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
<AlertDialogCancel disabled={deleteMutation.isPending}>
取消
</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
onClick={() =>
deleteTarget && deleteMutation.mutate(deleteTarget.id)
}
disabled={deleteMutation.isPending}
className="bg-destructive hover:bg-destructive/90 text-destructive-foreground"
className='bg-destructive hover:bg-destructive/90 text-destructive-foreground'
>
{deleteMutation.isPending && <Loader2 className="mr-1.5 size-3.5 animate-spin" />}
{deleteMutation.isPending && (
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
)}
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
);
}
+92 -56
View File
@@ -1,9 +1,9 @@
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";
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;
@@ -12,7 +12,9 @@ export interface DeveloperSectionProps {
/**
* Developer Section - 开发者体验展示
*/
export const DeveloperSection = React.memo(function DeveloperSection({ className }: DeveloperSectionProps) {
export const DeveloperSection = React.memo(function DeveloperSection({
className,
}: DeveloperSectionProps) {
const [copied, setCopied] = React.useState(false);
const codeContent = `# 注册用户
@@ -31,13 +33,13 @@ curl -X POST https://api.example.com/api/v1/auth/register \\
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback for older browsers
const textArea = document.createElement("textarea");
const textArea = document.createElement('textarea');
textArea.value = codeContent;
textArea.style.position = "fixed";
textArea.style.left = "-9999px";
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.execCommand('copy');
document.body.removeChild(textArea);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
@@ -45,97 +47,131 @@ curl -X POST https://api.example.com/api/v1/auth/register \\
};
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" />
<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">
<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"
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 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" />
<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 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" />}
<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">&quot;Content-Type: application/json&quot;</span> \{'\n'}
{' '}-d <span className="text-orange-400">&quot;username=dev&amp;password=***&quot;</span>
<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'>
&quot;Content-Type: application/json&quot;
</span>{' '}
\{'\n'}
{' '}-d{' '}
<span className='text-orange-400'>
&quot;username=dev&amp;password=***&quot;
</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" />
<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">
<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 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 className='text-muted-foreground text-lg leading-relaxed mb-8'>
标准化的 RESTful API 接口,完整的 TypeScript
类型定义,详细的文档和 Swagger
UI。无论使用什么编程语言,只需几行代码,即可快速集成。
</p>
<ul className="space-y-4 mb-8">
<ul className='space-y-4 mb-8'>
{[
"RESTful API,语义清晰,易于理解",
"OpenAPI / Swagger 接口文档",
"TypeScript 类型定义,开发友好",
"丰富的代码示例和集成指南"
'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" />
<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" />
<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>
);
+103 -51
View File
@@ -1,7 +1,7 @@
import * as React from "react";
import {cn} from "@/lib/utils";
import Link from "next/link";
import {Github, LucideIcon, WavesIcon} from "lucide-react";
import * as React from 'react';
import { cn } from '@/lib/utils';
import Link from 'next/link';
import { Github, LucideIcon, WavesIcon } from 'lucide-react';
export interface FooterSectionProps {
className?: string;
@@ -10,66 +10,106 @@ export interface FooterSectionProps {
/**
* Footer Section - 页脚
*/
export const FooterSection = React.memo(function FooterSection({ className }: FooterSectionProps) {
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">
<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" />
<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'>
<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='text-2xl font-bold tracking-tight text-foreground'>
Wavelet
</span>
<span className="text-2xl font-bold tracking-tight text-foreground">Wavelet</span>
</Link>
<p className="text-muted-foreground text-base leading-relaxed max-w-sm">
通用的、现代化的后台管理系统脚手架。基于 Go + React 构建,开箱即用、完整基建、极易扩展。
<p className='text-muted-foreground text-base leading-relaxed max-w-sm'>
通用的、现代化的后台管理系统脚手架。基于 Go + React
构建,开箱即用、完整基建、极易扩展。
</p>
<div className="flex gap-4 pt-2">
<SocialLink icon={Github} href="https://github.com/Rain-kl/Wavelet/" />
<div className='flex gap-4 pt-2'>
<SocialLink
icon={Github}
href='https://github.com/Rain-kl/Wavelet/'
/>
</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="/home">仪表盘</FooterLink></li>
<li><FooterLink href="/settings">个人设置</FooterLink></li>
<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='/home'>仪表盘</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/Wavelet">源代码</FooterLink></li>
<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/Wavelet'>
源代码
</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/Wavelet/issues">GitHub Issues</FooterLink></li>
<li><FooterLink href="https://github.com/Rain-kl/Wavelet/discussions">讨论</FooterLink></li>
<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/Wavelet/issues'>
GitHub Issues
</FooterLink>
</li>
<li>
<FooterLink href='https://github.com/Rain-kl/Wavelet/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">
<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 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">
<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>
@@ -77,21 +117,33 @@ export const FooterSection = React.memo(function FooterSection({ className }: Fo
);
});
function SocialLink({ icon: Icon, href }: { icon: LucideIcon, href: string }) {
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
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 }) {
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">
<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 className='absolute left-0 -bottom-0.5 w-0 h-px bg-foreground transition-all duration-300 group-hover:w-full' />
</span>
</Link>
)
);
}
+88 -66
View File
@@ -1,9 +1,9 @@
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";
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;
@@ -12,10 +12,11 @@ export interface HeroSectionProps {
/**
* Hero Section - 首页 Hero 展示
*/
export const HeroSection = React.memo(function HeroSection({ className }: HeroSectionProps) {
export const HeroSection = React.memo(function HeroSection({
className,
}: HeroSectionProps) {
return (
<section className={cn("w-full", className)}>
<section className={cn('w-full', className)}>
<motion.div
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
@@ -23,35 +24,41 @@ export const HeroSection = React.memo(function HeroSection({ className }: HeroSe
transition={{
delay: 0.3,
duration: 0.8,
ease: "easeInOut",
ease: 'easeInOut',
}}
className="relative z-10 w-full h-screen flex flex-col justify-center px-6"
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">
<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"
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'
>
Wavelet<br />
<span className="bg-clip-text text-primary">
Modern Platform
</span>
Wavelet
<br />
<span className='bg-clip-text text-primary'>Modern Platform</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"
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" />
<br className='hidden md:block' />
开箱即用、完整基建、极易扩展,助您快速构建应用。
</motion.p>
@@ -59,24 +66,28 @@ export const HeroSection = React.memo(function HeroSection({ className }: HeroSe
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"
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="/home" className="w-full sm:w-auto">
<Link href='/home' 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"
size='lg'
className='w-full rounded-full bg-primary hover:bg-primary/90 font-medium transition-all active:scale-95'
>
立即开始
<ArrowRight className="size-4" />
<ArrowRight className='size-4' />
</Button>
</Link>
<Link href="/docs/how-to-use" className="w-full sm:w-auto">
<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"
variant='secondary'
size='lg'
className='w-full rounded-full font-medium active:scale-95'
>
了解更多
</Button>
@@ -88,78 +99,89 @@ export const HeroSection = React.memo(function HeroSection({ className }: HeroSe
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"
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" />
<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" />
<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" />
<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">
<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"
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='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" />
<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 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 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">
<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 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"
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 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">
<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>
@@ -1,21 +1,21 @@
/**
* 圣诞节主题装饰组件归档
* 包含圣诞树、圣诞帽图标以及下雪效果的集成组件
*
*
* 使用示例:
* ```tsx
* import { ChristmasDecorations } from "@/components/layout/avater-style/Christmas"
*
*
* function MyComponent() {
* const [showChristmas, setShowChristmas] = useState(false)
*
*
* return (
* <>
* {showChristmas && <ChristmasDecorations.SnowEffect />}
* <div onClick={() => setShowChristmas(!showChristmas)}>
* <ChristmasDecorations.Hat className="w-8 h-8" />
* <ChristmasDecorations.Tree
* className="w-12 h-12"
* <ChristmasDecorations.Tree
* className="w-12 h-12"
* show={showChristmas}
* />
* </div>
@@ -25,64 +25,85 @@
* ```
*/
import { cn } from "@/lib/utils"
import { SnowEffect } from "@/components/ui/snow-effect"
import { cn } from '@/lib/utils';
import { SnowEffect } from '@/components/ui/snow-effect';
/**
* 圣诞树图标组件
*/
export const ChristmasTree = ({
className,
show = true
show = true,
}: {
className?: string
show?: boolean
className?: string;
show?: boolean;
}) => (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={cn(
"transition-all duration-500 ease-out",
show ? "opacity-90 scale-100 translate-y-0" : "opacity-0 scale-50 translate-y-4",
className
'transition-all duration-500 ease-out',
show
? 'opacity-90 scale-100 translate-y-0'
: 'opacity-0 scale-50 translate-y-4',
className,
)}
>
<rect x="10" y="18" width="4" height="4" fill="#92400E" rx="0.5" />
<path d="M12 4L8 9H16L12 4Z" fill="#15803D" />
<path d="M12 8L7 14H17L12 8Z" fill="#16A34A" />
<path d="M12 12L6 19H18L12 12Z" fill="#22C55E" />
<circle cx="10" cy="10" r="0.8" fill="#EF4444" />
<circle cx="14" cy="11" r="0.8" fill="#F59E0B" />
<circle cx="9" cy="15" r="0.8" fill="#3B82F6" />
<circle cx="15" cy="16" r="0.8" fill="#EF4444" />
<circle cx="12" cy="14" r="0.8" fill="#F59E0B" />
<path d="M12 2L12.5 3.5L14 4L12.5 4.5L12 6L11.5 4.5L10 4L11.5 3.5L12 2Z" fill="#FBBF24" />
<rect x='10' y='18' width='4' height='4' fill='#92400E' rx='0.5' />
<path d='M12 4L8 9H16L12 4Z' fill='#15803D' />
<path d='M12 8L7 14H17L12 8Z' fill='#16A34A' />
<path d='M12 12L6 19H18L12 12Z' fill='#22C55E' />
<circle cx='10' cy='10' r='0.8' fill='#EF4444' />
<circle cx='14' cy='11' r='0.8' fill='#F59E0B' />
<circle cx='9' cy='15' r='0.8' fill='#3B82F6' />
<circle cx='15' cy='16' r='0.8' fill='#EF4444' />
<circle cx='12' cy='14' r='0.8' fill='#F59E0B' />
<path
d='M12 2L12.5 3.5L14 4L12.5 4.5L12 6L11.5 4.5L10 4L11.5 3.5L12 2Z'
fill='#FBBF24'
/>
</svg>
)
);
/**
* 圣诞帽图标组件
*/
export const ChristmasHat = ({ className }: { className?: string }) => (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox='0 0 24 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={className}
>
<path
d="M12 3C12 3 5 10 5 15C5 17.5 7 19 8 19H16C17 19 19 17.5 19 15C19 10 12 3 12 3Z"
fill="#EF4444"
stroke="#B91C1C"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
d='M12 3C12 3 5 10 5 15C5 17.5 7 19 8 19H16C17 19 19 17.5 19 15C19 10 12 3 12 3Z'
fill='#EF4444'
stroke='#B91C1C'
strokeWidth='1.5'
strokeLinecap='round'
strokeLinejoin='round'
/>
<circle
cx='12'
cy='3'
r='2.5'
fill='white'
stroke='#E5E7EB'
strokeWidth='1.5'
/>
<rect
x='4'
y='16'
width='16'
height='5'
rx='2.5'
fill='white'
stroke='#E5E7EB'
strokeWidth='1.5'
/>
<circle cx="12" cy="3" r="2.5" fill="white" stroke="#E5E7EB" strokeWidth="1.5" />
<rect x="4" y="16" width="16" height="5" rx="2.5" fill="white" stroke="#E5E7EB" strokeWidth="1.5" />
</svg>
)
);
/**
* 圣诞节装饰集合
@@ -92,6 +113,6 @@ export const ChristmasDecorations = {
Tree: ChristmasTree,
Hat: ChristmasHat,
SnowEffect: SnowEffect,
}
};
export default ChristmasDecorations
export default ChristmasDecorations;
@@ -1,21 +1,21 @@
/**
* 元旦/新年主题装饰组件归档
* 包含灯笼、福字以及烟花效果的集成组件
*
*
* 使用示例:
* ```tsx
* import { NewYearDecorations } from "@/components/layout/avater-style/NewYear"
*
*
* function MyComponent() {
* const [showNewYear, setShowNewYear] = useState(false)
*
*
* return (
* <>
* {showNewYear && <NewYearDecorations.FireworksEffect />}
* <div onClick={() => setShowNewYear(!showNewYear)}>
* <NewYearDecorations.Lantern className="w-8 h-8" />
* <NewYearDecorations.Fu
* className="w-12 h-12"
* <NewYearDecorations.Fu
* className="w-12 h-12"
* show={showNewYear}
* />
* </div>
@@ -25,42 +25,107 @@
* ```
*/
import { cn } from "@/lib/utils"
import { FireworksEffect } from "@/components/ui/fireworks-effect"
import { cn } from '@/lib/utils';
import { FireworksEffect } from '@/components/ui/fireworks-effect';
/**
* 红灯笼组件 - 默认头像装饰
*/
export const Lantern = ({ className }: { className?: string }) => (
<svg
viewBox="0 0 1024 1024"
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox='0 0 1024 1024'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={className}
>
<path d="M512 80V160" stroke="#FFD700" strokeWidth="32" strokeLinecap="round" />
<path
d='M512 80V160'
stroke='#FFD700'
strokeWidth='32'
strokeLinecap='round'
/>
<ellipse cx="512" cy="420" rx="300" ry="260" fill="#D92424" />
<ellipse cx="512" cy="420" rx="300" ry="260" stroke="#961C1C" strokeWidth="10" />
<ellipse cx='512' cy='420' rx='300' ry='260' fill='#D92424' />
<ellipse
cx='512'
cy='420'
rx='300'
ry='260'
stroke='#961C1C'
strokeWidth='10'
/>
<path d="M340 180H684C700 180 712 192 712 208V220H312V208C312 192 324 180 340 180Z" fill="#FFD700" />
<path d="M340 620H684C700 620 712 632 712 648V660H312V648C312 632 324 620 340 620Z" fill="#FFD700" />
<path
d='M340 180H684C700 180 712 192 712 208V220H312V208C312 192 324 180 340 180Z'
fill='#FFD700'
/>
<path
d='M340 620H684C700 620 712 632 712 648V660H312V648C312 632 324 620 340 620Z'
fill='#FFD700'
/>
<path d="M512 160C512 160 620 250 620 420C620 590 512 680 512 680" stroke="#FFD700" strokeWidth="20" strokeLinecap="round" strokeOpacity="0.4" />
<path d="M512 160C512 160 404 250 404 420C404 590 512 680 512 680" stroke="#FFD700" strokeWidth="20" strokeLinecap="round" strokeOpacity="0.4" />
<path d="M512 160V680" stroke="#FFD700" strokeWidth="20" strokeOpacity="0.4" />
<path
d='M512 160C512 160 620 250 620 420C620 590 512 680 512 680'
stroke='#FFD700'
strokeWidth='20'
strokeLinecap='round'
strokeOpacity='0.4'
/>
<path
d='M512 160C512 160 404 250 404 420C404 590 512 680 512 680'
stroke='#FFD700'
strokeWidth='20'
strokeLinecap='round'
strokeOpacity='0.4'
/>
<path
d='M512 160V680'
stroke='#FFD700'
strokeWidth='20'
strokeOpacity='0.4'
/>
<circle cx="512" cy="420" r="80" fill="#FFD700" fillOpacity="0.9" />
<text x="512" y="450" fontSize="80" fill="#D92424" textAnchor="middle" fontWeight="bold" fontFamily="serif">福</text>
<circle cx='512' cy='420' r='80' fill='#FFD700' fillOpacity='0.9' />
<text
x='512'
y='450'
fontSize='80'
fill='#D92424'
textAnchor='middle'
fontWeight='bold'
fontFamily='serif'
>
福
</text>
<path d="M512 660V720" stroke="#FFD700" strokeWidth="20" />
<path d="M480 720H544" stroke="#D92424" strokeWidth="40" strokeLinecap="round" />
<path d='M512 660V720' stroke='#FFD700' strokeWidth='20' />
<path
d='M480 720H544'
stroke='#D92424'
strokeWidth='40'
strokeLinecap='round'
/>
<path d="M492 740V900" stroke="#D92424" strokeWidth="16" strokeLinecap="round" />
<path d="M512 740V940" stroke="#D92424" strokeWidth="16" strokeLinecap="round" />
<path d="M532 740V900" stroke="#D92424" strokeWidth="16" strokeLinecap="round" />
<path
d='M492 740V900'
stroke='#D92424'
strokeWidth='16'
strokeLinecap='round'
/>
<path
d='M512 740V940'
stroke='#D92424'
strokeWidth='16'
strokeLinecap='round'
/>
<path
d='M532 740V900'
stroke='#D92424'
strokeWidth='16'
strokeLinecap='round'
/>
</svg>
)
);
/**
* "福"字挂件 - 点击出现的装饰
@@ -68,46 +133,68 @@ export const Lantern = ({ className }: { className?: string }) => (
*/
export const FuCharacter = ({
className,
show = true
show = true,
}: {
className?: string
show?: boolean
className?: string;
show?: boolean;
}) => (
<svg
viewBox="0 0 100 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox='0 0 100 100'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={cn(
"transition-all duration-500 ease-out origin-center",
show ? "opacity-100 scale-100 rotate-0" : "opacity-0 scale-50 rotate-[-15deg]",
className
'transition-all duration-500 ease-out origin-center',
show
? 'opacity-100 scale-100 rotate-0'
: 'opacity-0 scale-50 rotate-[-15deg]',
className,
)}
>
<path d="M50 0V15" stroke="#F59E0B" strokeWidth="2" />
<g transform="rotate(25 50 50)">
<rect x="20" y="20" width="60" height="60" rx="2" fill="#D92424" stroke="#F59E0B" strokeWidth="1.5" />
<rect x="23" y="23" width="54" height="54" rx="1" stroke="#F59E0B" strokeWidth="1" strokeOpacity="0.5" strokeDasharray="4 2" fill="none" />
<path d='M50 0V15' stroke='#F59E0B' strokeWidth='2' />
<g transform='rotate(25 50 50)'>
<rect
x='20'
y='20'
width='60'
height='60'
rx='2'
fill='#D92424'
stroke='#F59E0B'
strokeWidth='1.5'
/>
<rect
x='23'
y='23'
width='54'
height='54'
rx='1'
stroke='#F59E0B'
strokeWidth='1'
strokeOpacity='0.5'
strokeDasharray='4 2'
fill='none'
/>
<text
x="50"
y="60"
fontSize="32"
fill="#FFD700"
textAnchor="middle"
fontWeight="bold"
x='50'
y='60'
fontSize='32'
fill='#FFD700'
textAnchor='middle'
fontWeight='bold'
fontFamily="'YouYuan', 'Yuanti SC', 'STYuanti', 'HanyiYuanti', 'Comic Sans MS', sans-serif"
style={{ letterSpacing: "0px" }}
transform="rotate(130 50 50)"
style={{ letterSpacing: '0px' }}
transform='rotate(130 50 50)'
>
福
</text>
</g>
<path d="M50 85V95" stroke="#D92424" strokeWidth="3" />
<circle cx="50" cy="95" r="2" fill="#F59E0B" />
<path d="M50 95L47 100M50 95L53 100" stroke="#D92424" strokeWidth="1" />
<path d='M50 85V95' stroke='#D92424' strokeWidth='3' />
<circle cx='50' cy='95' r='2' fill='#F59E0B' />
<path d='M50 95L47 100M50 95L53 100' stroke='#D92424' strokeWidth='1' />
</svg>
)
);
/**
* 元旦/新年装饰集合
@@ -116,6 +203,6 @@ export const NewYearDecorations = {
Lantern,
Fu: FuCharacter,
FireworksEffect,
}
};
export default NewYearDecorations
export default NewYearDecorations;
@@ -1,48 +1,54 @@
import { AvatarDecorationTheme } from "./types"
import { ChristmasDecorations } from "./Christmas"
import { NewYearDecorations } from "./NewYear"
import { AvatarDecorationTheme } from './types';
import { ChristmasDecorations } from './Christmas';
import { NewYearDecorations } from './NewYear';
export const DecorationThemes: Record<string, AvatarDecorationTheme> = {
christmas: {
key: "christmas",
key: 'christmas',
MainDecoration: ChristmasDecorations.Hat,
InteractionDecoration: ChristmasDecorations.Tree,
BackgroundEffect: ChristmasDecorations.SnowEffect,
smallMainDecorationConfig: {
className: "absolute -top-1 -left-1 w-3 h-3 z-20 rotate-[-20deg] drop-shadow-sm"
className:
'absolute -top-1 -left-1 w-3 h-3 z-20 rotate-[-20deg] drop-shadow-sm',
},
largeMainDecorationConfig: {
className: "absolute -top-2 -left-1 w-5 h-5 z-20 rotate-[-20deg] drop-shadow-sm"
className:
'absolute -top-2 -left-1 w-5 h-5 z-20 rotate-[-20deg] drop-shadow-sm',
},
interactionDecorationConfig: {
className: "absolute -top-2 right-2 w-12 h-12 z-0 rotate-[10deg] transition-all duration-500 ease-out"
}
className:
'absolute -top-2 right-2 w-12 h-12 z-0 rotate-[10deg] transition-all duration-500 ease-out',
},
},
newyear: {
key: "newyear",
key: 'newyear',
MainDecoration: NewYearDecorations.Lantern,
InteractionDecoration: NewYearDecorations.Fu,
BackgroundEffect: NewYearDecorations.FireworksEffect,
smallMainDecorationConfig: {
className: "absolute -top-2 -right-1 w-8 h-8 z-20 rotate-[10deg] drop-shadow-md pointer-events-none"
className:
'absolute -top-2 -right-1 w-8 h-8 z-20 rotate-[10deg] drop-shadow-md pointer-events-none',
},
largeMainDecorationConfig: {
className: "absolute -top-3 -right-3 w-8 h-8 z-20 rotate-[10deg] drop-shadow-md pointer-events-none"
className:
'absolute -top-3 -right-3 w-8 h-8 z-20 rotate-[10deg] drop-shadow-md pointer-events-none',
},
interactionDecorationConfig: {
className: "absolute top-6 left-4 w-10 h-10 z-20 -rotate-[5deg] transition-all duration-500 ease-out"
}
className:
'absolute top-6 left-4 w-10 h-10 z-20 -rotate-[5deg] transition-all duration-500 ease-out',
},
},
none: {
key: "none",
key: 'none',
MainDecoration: () => null,
InteractionDecoration: () => null,
}
}
},
};
/* 可选值: 'christmas', 'newyear', 'none' */
export const CURRENT_THEME_KEY: string = "none"
export const CURRENT_THEME_KEY: string = 'none';
export const getCurrentTheme = (): AvatarDecorationTheme => {
return DecorationThemes[CURRENT_THEME_KEY] || DecorationThemes.none
}
return DecorationThemes[CURRENT_THEME_KEY] || DecorationThemes.none;
};
@@ -1,17 +1,17 @@
import { ComponentType } from "react"
import { ComponentType } from 'react';
export interface AvatarDecorationTheme {
key: string
MainDecoration: ComponentType<{ className?: string }>
InteractionDecoration: ComponentType<{ className?: string; show?: boolean }>
BackgroundEffect?: ComponentType
key: string;
MainDecoration: ComponentType<{ className?: string }>;
InteractionDecoration: ComponentType<{ className?: string; show?: boolean }>;
BackgroundEffect?: ComponentType;
smallMainDecorationConfig?: {
className?: string
}
className?: string;
};
largeMainDecorationConfig?: {
className?: string
}
className?: string;
};
interactionDecorationConfig?: {
className?: string
}
className?: string;
};
}
+54 -51
View File
@@ -1,45 +1,44 @@
import * as React from "react"
import { FileTextIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import * as React from 'react';
import { FileTextIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
/**
* 空状态展示组件属性
* 用于统一显示无数据、无内容等空状态
*/
export interface EmptyStateProps extends React.ComponentProps<"div"> {
title?: string
description?: string
icon?: React.ComponentType<{ className?: string }>
actionText?: string
onAction?: () => void
iconSize?: "sm" | "md" | "lg"
export interface EmptyStateProps extends React.ComponentProps<'div'> {
title?: string;
description?: string;
icon?: React.ComponentType<{ className?: string }>;
actionText?: string;
onAction?: () => void;
iconSize?: 'sm' | 'md' | 'lg';
}
/**
* 空状态展示组件
* 用于统一显示无数据、无内容等空状态
*
*
* @example
* ```tsx
* // 基础用法
* <EmptyState
* title="暂无数据"
* description="当前没有任何记录"
* <EmptyState
* title="暂无数据"
* description="当前没有任何记录"
* />
*
*
* // 带操作按钮
* <EmptyState
* <EmptyState
* title="暂无交易记录"
* description="还没有任何交易"
* actionText="创建交易"
* onAction={() => router.push('/create')}
* />
*
*
* // 自定义图标
* <EmptyState
* <EmptyState
* icon={BarChartIcon}
* description="未发现提现记录"
* />
@@ -54,49 +53,48 @@ export function EmptyState({
actionText,
onAction,
className,
iconSize = "md",
iconSize = 'md',
}: EmptyStateProps) {
const iconSizes = { sm: "size-10", md: "size-12", lg: "size-16" }
const iconInnerSizes = { sm: "size-5", md: "size-6", lg: "size-8" }
const iconSizes = { sm: 'size-10', md: 'size-12', lg: 'size-16' };
const iconInnerSizes = { sm: 'size-5', md: 'size-6', lg: 'size-8' };
return (
<div className={cn("flex flex-col items-center justify-center py-12 text-center", className)}>
<div className={cn(
"rounded-full bg-muted flex items-center justify-center mb-4",
iconSizes[iconSize],
)}>
<Icon className={cn("text-muted-foreground", iconInnerSizes[iconSize])} />
<div
className={cn(
'flex flex-col items-center justify-center py-12 text-center',
className,
)}
>
<div
className={cn(
'rounded-full bg-muted flex items-center justify-center mb-4',
iconSizes[iconSize],
)}
>
<Icon
className={cn('text-muted-foreground', iconInnerSizes[iconSize])}
/>
</div>
{title && (
<h3 className="text-base font-medium mb-1">
{title}
</h3>
)}
{title && <h3 className='text-base font-medium mb-1'>{title}</h3>}
{description && (
<p className="text-sm text-muted-foreground max-w-md">
{description}
</p>
<p className='text-sm text-muted-foreground max-w-md'>{description}</p>
)}
{onAction && actionText && (
<Button
onClick={onAction}
variant="outline"
className="mt-4"
>
<Button onClick={onAction} variant='outline' className='mt-4'>
{actionText}
</Button>
)}
</div>
)
);
}
/**
* 带边框的空状态组件
* 适合在卡片或容器内使用
*
*
* @example
* ```tsx
* <EmptyStateWithBorder
@@ -109,16 +107,16 @@ export function EmptyState({
*/
export function EmptyStateWithBorder(props: EmptyStateProps) {
return (
<div className="border border-dashed rounded-lg">
<div className='border border-dashed rounded-lg'>
<EmptyState {...props} />
</div>
)
);
}
/**
* 简化版空状态组件(内联使用)
* 适合在较小的区域显示
*
*
* @example
* ```tsx
* <EmptyInline
@@ -130,14 +128,19 @@ export function EmptyStateWithBorder(props: EmptyStateProps) {
* @returns {React.ReactNode} 简化版空状态组件
*/
export function EmptyInline({
message = "暂无数据",
message = '暂无数据',
icon: Icon = FileTextIcon,
className,
}: Pick<EmptyStateProps, 'icon' | 'className'> & { message?: string }) {
return (
<div className={cn("flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground", className)}>
<Icon className="size-4" />
<div
className={cn(
'flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground',
className,
)}
>
<Icon className='size-4' />
<span>{message}</span>
</div>
)
);
}
+67 -60
View File
@@ -1,45 +1,44 @@
import * as React from "react"
import { AlertCircle } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import * as React from 'react';
import { AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
/**
* 错误展示组件属性
* 用于统一显示加载失败、请求失败等错误状态
*/
export interface ErrorDisplayProps {
title?: string
message?: string
error?: Error | null
onRetry?: () => void
retryText?: string
icon?: React.ComponentType<{ className?: string }>
className?: string
showStack?: boolean
title?: string;
message?: string;
error?: Error | null;
onRetry?: () => void;
retryText?: string;
icon?: React.ComponentType<{ className?: string }>;
className?: string;
showStack?: boolean;
}
/**
* 错误展示组件
* 用于统一显示加载失败、请求失败等错误状态
*
*
* @example
* ```tsx
* // 基础用法
* <ErrorDisplay
* title="加载失败"
* message="无法获取数据,请稍后重试"
* <ErrorDisplay
* title="加载失败"
* message="无法获取数据,请稍后重试"
* />
*
*
* // 带重试按钮
* <ErrorDisplay
* <ErrorDisplay
* error={error}
* onRetry={() => refetch()}
* onRetry={() => refetch()}
* />
*
*
* // 自定义样式
* <ErrorDisplay
* <ErrorDisplay
* title="网络错误"
* message="请检查您的网络连接"
* className="min-h-[400px]"
@@ -49,61 +48,64 @@ export interface ErrorDisplayProps {
* @returns {React.ReactNode} 错误展示组件
*/
export function ErrorDisplay({
title = "加载失败",
title = '加载失败',
message,
error,
onRetry,
retryText = "重试",
retryText = '重试',
icon: Icon = AlertCircle,
className,
showStack = false,
}: ErrorDisplayProps) {
const errorMessage = message || error?.message || "发生未知错误,请稍后重试"
const errorMessage = message || error?.message || '发生未知错误,请稍后重试';
return (
<div className={cn("flex flex-col items-center justify-center py-12 text-center", className)}>
<div className="size-12 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center mb-4">
<Icon className="size-6 text-red-600 dark:text-red-400" />
<div
className={cn(
'flex flex-col items-center justify-center py-12 text-center',
className,
)}
>
<div className='size-12 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center mb-4'>
<Icon className='size-6 text-red-600 dark:text-red-400' />
</div>
<h3 className="text-lg font-semibold mb-2">{title}</h3>
<h3 className='text-lg font-semibold mb-2'>{title}</h3>
<p className="text-sm text-muted-foreground max-w-md mb-4">{errorMessage}</p>
<p className='text-sm text-muted-foreground max-w-md mb-4'>
{errorMessage}
</p>
{onRetry && (
<Button
onClick={onRetry}
variant="outline"
className="mt-2"
>
<Button onClick={onRetry} variant='outline' className='mt-2'>
{retryText}
</Button>
)}
{showStack && error?.stack && process.env.NODE_ENV === 'development' && (
<details className="mt-6 text-left max-w-2xl w-full">
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">
<details className='mt-6 text-left max-w-2xl w-full'>
<summary className='cursor-pointer text-xs text-muted-foreground hover:text-foreground'>
查看详细错误信息
</summary>
<pre className="mt-2 p-4 bg-muted rounded-lg text-xs overflow-auto">
<pre className='mt-2 p-4 bg-muted rounded-lg text-xs overflow-auto'>
{error.stack}
</pre>
</details>
)}
</div>
)
);
}
/**
* 区域级错误组件
* 适合在较小的区域显示错误
*
*
* @example
* ```tsx
* <ErrorInline
* message="发生错误"
* error={error}
* onRetry={() => refetch()}
* <ErrorInline
* message="发生错误"
* error={error}
* onRetry={() => refetch()}
* />
* ```
*/
@@ -113,47 +115,52 @@ export function ErrorInline({
onRetry,
className,
}: Omit<ErrorDisplayProps, 'title' | 'icon' | 'showStack'>) {
const errorMessage = message || error?.message || "发生未知错误"
const errorMessage = message || error?.message || '发生未知错误';
return (
<div className={cn("flex items-center gap-2 text-sm text-red-600 dark:text-red-400", className)}>
<AlertCircle className="size-3 shrink-0 text-destructive" />
<span className="flex-1 text-xs">{errorMessage}</span>
<div
className={cn(
'flex items-center gap-2 text-sm text-red-600 dark:text-red-400',
className,
)}
>
<AlertCircle className='size-3 shrink-0 text-destructive' />
<span className='flex-1 text-xs'>{errorMessage}</span>
{onRetry && (
<Button
onClick={onRetry}
variant="secondary"
size="sm"
className="h-6 px-4 text-xs"
variant='secondary'
size='sm'
className='h-6 px-4 text-xs'
>
重试
</Button>
)}
</div>
)
);
}
/**
* 页面级错误组件
* 适合作为页面主要内容的错误状态
*
*
* @example
* ```tsx
* <ErrorPage
* title="加载失败"
* message="无法获取数据,请稍后重试"
* error={error}
* onRetry={() => refetch()}
* <ErrorPage
* title="加载失败"
* message="无法获取数据,请稍后重试"
* error={error}
* onRetry={() => refetch()}
* />
* ```
*/
export function ErrorPage(props: ErrorDisplayProps) {
return (
<div className="flex items-center justify-center min-h-[calc(100vh-8rem)]">
<div className='flex items-center justify-center min-h-[calc(100vh-8rem)]'>
<ErrorDisplay
{...props}
showStack={props.showStack ?? process.env.NODE_ENV === 'development'}
/>
</div>
)
);
}
+184 -111
View File
@@ -1,51 +1,65 @@
"use client"
import {memo, useEffect, useState} from "react"
import {AnimatePresence, motion} from "motion/react"
import {Button} from "@/components/ui/button"
import {Bell, Info, Maximize2, Minimize2, Moon, Search, Settings, Sun} from "lucide-react"
import {useUser} from "@/contexts/user-context"
import {useBellRing} from "@/contexts/bell-ring-context"
import {useNotificationSettings} from "@/contexts/notification-settings-context"
import {SidebarTrigger} from "@/components/ui/sidebar"
import {useTheme} from "next-themes"
import {useRouter} from "next/navigation"
import {SearchDialog} from "@/components/layout/search-dialog"
import {Kbd} from "@/components/ui/kbd"
'use client';
import { memo, useEffect, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { Button } from '@/components/ui/button';
import {
Bell,
Info,
Maximize2,
Minimize2,
Moon,
Search,
Settings,
Sun,
} from 'lucide-react';
import { useUser } from '@/contexts/user-context';
import { useBellRing } from '@/contexts/bell-ring-context';
import { useNotificationSettings } from '@/contexts/notification-settings-context';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { useTheme } from 'next-themes';
import { useRouter } from 'next/navigation';
import { SearchDialog } from '@/components/layout/search-dialog';
import { Kbd } from '@/components/ui/kbd';
/**
* 铃铛按钮组件
*/
const BellButton = memo(function BellButton() {
const { isRinging } = useBellRing()
const { showBell, isMounted } = useNotificationSettings()
const [isClickAnimating, setIsClickAnimating] = useState(false)
const { isRinging } = useBellRing();
const { showBell, isMounted } = useNotificationSettings();
const [isClickAnimating, setIsClickAnimating] = useState(false);
if (!isMounted) return null
if (!showBell) return null
if (!isMounted) return null;
if (!showBell) return null;
const handleClick = () => {
setIsClickAnimating(true)
setTimeout(() => setIsClickAnimating(false), 600)
}
setIsClickAnimating(true);
setTimeout(() => setIsClickAnimating(false), 600);
};
return (
<Button
variant="ghost"
size="icon"
className="size-9 text-muted-foreground hover:text-foreground"
variant='ghost'
size='icon'
className='size-9 text-muted-foreground hover:text-foreground'
onClick={handleClick}
>
<Bell
className="size-[18px]"
style={(isRinging || isClickAnimating) ? { animation: 'var(--animate-bell-ring)', transformOrigin: 'top center' } : undefined}
className='size-[18px]'
style={
isRinging || isClickAnimating
? {
animation: 'var(--animate-bell-ring)',
transformOrigin: 'top center',
}
: undefined
}
/>
<span className="sr-only">通知</span>
<span className='sr-only'>通知</span>
</Button>
)
})
);
});
/**
* 站点头部组件
@@ -56,158 +70,217 @@ const BellButton = memo(function BellButton() {
* <SiteHeader />
* ```
*/
export function SiteHeader({ isFullWidth = false, onToggleFullWidth }: { isFullWidth?: boolean, onToggleFullWidth?: (value: boolean) => void }) {
const { user } = useUser()
const { setTheme, resolvedTheme } = useTheme()
const router = useRouter()
const [mounted, setMounted] = useState(false)
const [searchOpen, setSearchOpen] = useState(false)
export function SiteHeader({
isFullWidth = false,
onToggleFullWidth,
}: {
isFullWidth?: boolean;
onToggleFullWidth?: (value: boolean) => void;
}) {
const { user } = useUser();
const { setTheme, resolvedTheme } = useTheme();
const router = useRouter();
const [mounted, setMounted] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const [metaKey, setMetaKey] = useState("⌘")
const [metaKey, setMetaKey] = useState('⌘');
useEffect(() => {
setMounted(true)
if (typeof navigator !== 'undefined' && !navigator.userAgent?.includes("Mac")) {
setMetaKey("Ctrl")
setMounted(true);
if (
typeof navigator !== 'undefined' &&
!navigator.userAgent?.includes('Mac')
) {
setMetaKey('Ctrl');
}
}, [])
}, []);
return (
<div className="flex flex-col w-full">
<div className='flex flex-col w-full'>
<AppBanner />
{user?.need_change_password && (
<div className="bg-amber-500/10 border-b border-amber-500/20 px-4 py-3 text-amber-500 text-xs sm:text-sm flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Info className="size-4 shrink-0 text-amber-500" />
<div className='bg-amber-500/10 border-b border-amber-500/20 px-4 py-3 text-amber-500 text-xs sm:text-sm flex items-center justify-between gap-4'>
<div className='flex items-center gap-2'>
<Info className='size-4 shrink-0 text-amber-500' />
<span>为了您的账号安全,请立即修改密码!</span>
</div>
<Button
variant="outline"
size="sm"
className="text-amber-500 hover:text-amber-600 border-amber-500/30 hover:border-amber-500/50 bg-transparent shrink-0 h-8 px-3 text-xs"
onClick={() => router.push("/settings/profile")}
variant='outline'
size='sm'
className='text-amber-500 hover:text-amber-600 border-amber-500/30 hover:border-amber-500/50 bg-transparent shrink-0 h-8 px-3 text-xs'
onClick={() => router.push('/settings/profile')}
>
去修改密码
</Button>
</div>
)}
<header className="flex h-(--header-height) shrink-0 items-center bg-background px-4 md:px-0">
<div className="flex w-full items-center justify-between md:hidden">
<div className="flex items-center gap-2">
<header className='flex h-(--header-height) shrink-0 items-center bg-background px-4 md:px-0'>
<div className='flex w-full items-center justify-between md:hidden'>
<div className='flex items-center gap-2'>
<SidebarTrigger />
<span className="text-sm font-medium truncate max-w-[120px]">
{user?.nickname || user?.username || "Guest"}
<span className='text-sm font-medium truncate max-w-[120px]'>
{user?.nickname || user?.username || 'Guest'}
</span>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => setSearchOpen(true)}>
<Search className="size-[18px]" />
<span className="sr-only">搜索</span>
<div className='flex items-center gap-1'>
<Button
variant='ghost'
size='icon'
className='size-9 text-muted-foreground hover:text-foreground'
onClick={() => setSearchOpen(true)}
>
<Search className='size-[18px]' />
<span className='sr-only'>搜索</span>
</Button>
<BellButton />
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => router.push('/settings')}>
<Settings className="size-[18px]" />
<span className="sr-only">设置</span>
<Button
variant='ghost'
size='icon'
className='size-9 text-muted-foreground hover:text-foreground'
onClick={() => router.push('/settings')}
>
<Settings className='size-[18px]' />
<span className='sr-only'>设置</span>
</Button>
</div>
</div>
<div className={`hidden md:flex w-full items-center gap-4 transition-all duration-300 ease-in-out ${!isFullWidth ? "max-w-[1320px]" : "max-w-full"} mx-auto md:px-8 lg:px-12`}>
<div className="relative w-64 cursor-pointer" onClick={() => setSearchOpen(true)}>
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<div className="flex h-8 items-center rounded-md border border-border/60 bg-muted/70 pl-10 pr-2.5 text-sm text-muted-foreground transition-colors hover:border-border hover:bg-muted">
<div
className={`hidden md:flex w-full items-center gap-4 transition-all duration-300 ease-in-out ${!isFullWidth ? 'max-w-[1320px]' : 'max-w-full'} mx-auto md:px-8 lg:px-12`}
>
<div
className='relative w-64 cursor-pointer'
onClick={() => setSearchOpen(true)}
>
<Search className='absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground' />
<div className='flex h-8 items-center rounded-md border border-border/60 bg-muted/70 pl-10 pr-2.5 text-sm text-muted-foreground transition-colors hover:border-border hover:bg-muted'>
<span>搜索</span>
<Kbd className="ml-auto gap-0.5 font-mono">
<Kbd className='ml-auto gap-0.5 font-mono'>
<span>{metaKey}</span>
<span>K</span>
</Kbd>
</div>
</div>
<div className="ml-auto flex items-center gap-1">
<div className='ml-auto flex items-center gap-1'>
<BellButton />
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => router.push('/settings')}>
<Settings className="size-[18px]" />
<span className="sr-only">设置</span>
<Button
variant='ghost'
size='icon'
className='size-9 text-muted-foreground hover:text-foreground'
onClick={() => router.push('/settings')}
>
<Settings className='size-[18px]' />
<span className='sr-only'>设置</span>
</Button>
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => onToggleFullWidth?.(!isFullWidth)}>
{isFullWidth ? <Minimize2 className="size-[18px]" /> : <Maximize2 className="size-[18px]" />}
<span className="sr-only">切换全宽</span>
<Button
variant='ghost'
size='icon'
className='size-9 text-muted-foreground hover:text-foreground'
onClick={() => onToggleFullWidth?.(!isFullWidth)}
>
{isFullWidth ? (
<Minimize2 className='size-[18px]' />
) : (
<Maximize2 className='size-[18px]' />
)}
<span className='sr-only'>切换全宽</span>
</Button>
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}>
{mounted ? (resolvedTheme === 'dark' ? <Sun className="size-[18px]" /> : <Moon className="size-[18px]" />) : <Moon className="size-[18px]" />}
<span className="sr-only">主题切换</span>
<Button
variant='ghost'
size='icon'
className='size-9 text-muted-foreground hover:text-foreground'
onClick={() =>
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')
}
>
{mounted ? (
resolvedTheme === 'dark' ? (
<Sun className='size-[18px]' />
) : (
<Moon className='size-[18px]' />
)
) : (
<Moon className='size-[18px]' />
)}
<span className='sr-only'>主题切换</span>
</Button>
</div>
</div>
{mounted && <SearchDialog open={searchOpen} onOpenChange={setSearchOpen} />}
{mounted && (
<SearchDialog open={searchOpen} onOpenChange={setSearchOpen} />
)}
</header>
</div>
)
);
}
function AppBanner() {
const [isVisible, setIsVisible] = useState(false)
const bannerKey = 'ldc-banner-dismissed-script'
const [isVisible, setIsVisible] = useState(false);
const bannerKey = 'ldc-banner-dismissed-script';
useEffect(() => {
const dismissed = localStorage.getItem(bannerKey)
const dismissed = localStorage.getItem(bannerKey);
if (!dismissed) {
setIsVisible(true)
setIsVisible(true);
}
}, [])
}, []);
const handleDismiss = () => {
localStorage.setItem(bannerKey, 'true')
setIsVisible(false)
}
localStorage.setItem(bannerKey, 'true');
setIsVisible(false);
};
return (
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
className="relative flex w-full items-center justify-center bg-muted/50 overflow-hidden"
transition={{ duration: 0.3, ease: 'easeInOut' }}
className='relative flex w-full items-center justify-center bg-muted/50 overflow-hidden'
>
<div className="flex w-full items-center justify-center px-4 pr-10 py-2 text-xs md:text-[13px] font-medium text-muted-foreground">
<p className="flex flex-wrap items-center justify-center gap-x-2 text-center">
<span className="text-foreground">最新通知</span>
<a href="https://example.com" target="_blank" className="underline underline-offset-4 hover:text-foreground">
<div className='flex w-full items-center justify-center px-4 pr-10 py-2 text-xs md:text-[13px] font-medium text-muted-foreground'>
<p className='flex flex-wrap items-center justify-center gap-x-2 text-center'>
<span className='text-foreground'>最新通知</span>
<a
href='https://example.com'
target='_blank'
className='underline underline-offset-4 hover:text-foreground'
>
查看详情
</a>
</p>
<Button
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 size-6 -translate-y-1/2 text-muted-foreground hover:text-foreground shrink-0"
variant='ghost'
size='icon'
className='absolute right-2 top-1/2 size-6 -translate-y-1/2 text-muted-foreground hover:text-foreground shrink-0'
onClick={handleDismiss}
>
<span className="sr-only">Dismiss</span>
<span className='sr-only'>Dismiss</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-x"
xmlns='http://www.w3.org/2000/svg'
width='14'
height='14'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
className='lucide lucide-x'
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
<path d='M18 6 6 18' />
<path d='m6 6 12 12' />
</svg>
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
)
);
}
+57 -42
View File
@@ -1,7 +1,7 @@
import * as React from "react"
import {motion} from "motion/react"
import {cn} from "@/lib/utils"
import {FileTextIcon} from "lucide-react"
import * as React from 'react';
import { motion } from 'motion/react';
import { cn } from '@/lib/utils';
import { FileTextIcon } from 'lucide-react';
/**
* 加载页面组件
@@ -16,11 +16,17 @@ import {FileTextIcon} from "lucide-react"
* @returns {React.ReactNode} 加载页面组件
*/
export function AppleSpinner({ className = "size-8 text-muted-foreground" }: { className?: string }) {
const spokes = Array.from({ length: 12 })
export function AppleSpinner({
className = 'size-8 text-muted-foreground',
}: {
className?: string;
}) {
const spokes = Array.from({ length: 12 });
return (
<div className={cn("relative flex items-center justify-center", className)}>
<style dangerouslySetInnerHTML={{ __html: `
<div className={cn('relative flex items-center justify-center', className)}>
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes apple-spinner-fade {
0% { opacity: 1; }
100% { opacity: 0.15; }
@@ -28,22 +34,24 @@ export function AppleSpinner({ className = "size-8 text-muted-foreground" }: { c
.apple-spinner-spoke {
animation: apple-spinner-fade 1.2s linear infinite;
}
`}} />
<div className="relative size-full">
`,
}}
/>
<div className='relative size-full'>
{spokes.map((_, i) => (
<div
key={i}
className="apple-spinner-spoke absolute top-0 left-[46%] w-[8%] h-[28%] bg-current rounded-full"
className='apple-spinner-spoke absolute top-0 left-[46%] w-[8%] h-[28%] bg-current rounded-full'
style={{
transform: `rotate(${i * 30}deg)`,
transformOrigin: "50% 178.5%",
transformOrigin: '50% 178.5%',
animationDelay: `${(i - 12) * 0.1}s`,
}}
/>
))}
</div>
</div>
)
);
}
/**
@@ -53,26 +61,26 @@ export function AppleSpinner({ className = "size-8 text-muted-foreground" }: { c
*/
export function LoadingPage(props: { text?: string; badgeText?: string }) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _ = props
const _ = props;
return (
<div className="absolute inset-0 z-50 overflow-hidden font-sans bg-background/80 backdrop-blur-md text-foreground flex items-center justify-center">
<div className='absolute inset-0 z-50 overflow-hidden font-sans bg-background/80 backdrop-blur-md text-foreground flex items-center justify-center'>
<motion.div
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="flex items-center justify-center"
transition={{ duration: 0.25, ease: 'easeOut' }}
className='flex items-center justify-center'
>
<AppleSpinner className="size-9 text-muted-foreground/80" />
<AppleSpinner className='size-9 text-muted-foreground/80' />
</motion.div>
</div>
)
);
}
interface LoadingStateProps extends React.ComponentProps<"div"> {
title?: string
description?: string
icon?: React.ComponentType<{ className?: string }>
iconSize?: "sm" | "md" | "lg"
interface LoadingStateProps extends React.ComponentProps<'div'> {
title?: string;
description?: string;
icon?: React.ComponentType<{ className?: string }>;
iconSize?: 'sm' | 'md' | 'lg';
}
/**
@@ -80,37 +88,44 @@ interface LoadingStateProps extends React.ComponentProps<"div"> {
* 用于统一显示加载中的状态
*/
export function LoadingState({
title = "加载中",
description = "正在获取活动数据...",
title = '加载中',
description = '正在获取活动数据...',
icon: Icon = FileTextIcon,
className,
iconSize = "md",
iconSize = 'md',
}: LoadingStateProps) {
const iconSizes = { sm: "size-8", md: "size-10", lg: "size-14" }
const iconInnerSizes = { sm: "size-4", md: "size-5", lg: "size-7" }
const iconSizes = { sm: 'size-8', md: 'size-10', lg: 'size-14' };
const iconInnerSizes = { sm: 'size-4', md: 'size-5', lg: 'size-7' };
return (
<div className={cn("flex flex-col items-center justify-center py-12 text-center", className)}>
<div className={cn(
"rounded-full bg-muted flex items-center justify-center mb-2 animate-pulse",
iconSizes[iconSize]
)}>
<Icon className={cn("text-muted-foreground", iconInnerSizes[iconSize])} />
<div
className={cn(
'flex flex-col items-center justify-center py-12 text-center',
className,
)}
>
<div
className={cn(
'rounded-full bg-muted flex items-center justify-center mb-2 animate-pulse',
iconSizes[iconSize],
)}
>
<Icon
className={cn('text-muted-foreground', iconInnerSizes[iconSize])}
/>
</div>
{title && (
<h3 className="text-sm font-medium mb-1 animate-pulse">
{title}
</h3>
<h3 className='text-sm font-medium mb-1 animate-pulse'>{title}</h3>
)}
{description && (
<p className="text-xs text-muted-foreground max-w-md animate-pulse">
<p className='text-xs text-muted-foreground max-w-md animate-pulse'>
{description}
</p>
)}
</div>
)
);
}
/**
@@ -118,8 +133,8 @@ export function LoadingState({
*/
export function LoadingStateWithBorder(props: LoadingStateProps) {
return (
<div className="border border-dashed rounded-lg">
<div className='border border-dashed rounded-lg'>
<LoadingState {...props} />
</div>
)
);
}
+7 -7
View File
@@ -1,17 +1,17 @@
"use client"
'use client';
import {usePublicConfig} from "@/hooks/use-public-config"
import { usePublicConfig } from '@/hooks/use-public-config';
export function RobotsMeta() {
const { config } = usePublicConfig()
const { config } = usePublicConfig();
// Default to noindex, nofollow if config is loading or if it's explicitly disabled
const enabled = config?.search_engine_indexing_enabled === "true"
const enabled = config?.search_engine_indexing_enabled === 'true';
return (
<meta
name="robots"
content={enabled ? "index, follow" : "noindex, nofollow"}
name='robots'
content={enabled ? 'index, follow' : 'noindex, nofollow'}
/>
)
);
}
+136 -122
View File
@@ -1,7 +1,7 @@
"use client"
'use client';
import {useCallback, useEffect, useState} from "react"
import {useRouter} from "next/navigation"
import { useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
CommandDialog,
CommandEmpty,
@@ -9,15 +9,15 @@ import {
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {Kbd, KbdGroup} from "@/components/ui/kbd"
import {type SearchItem, searchItems} from "@/lib/utils/search-data"
import {FileText, Home, Settings, Shield} from "lucide-react"
import {useUser} from "@/contexts/user-context"
} from '@/components/ui/command';
import { Kbd, KbdGroup } from '@/components/ui/kbd';
import { type SearchItem, searchItems } from '@/lib/utils/search-data';
import { FileText, Home, Settings, Shield } from 'lucide-react';
import { useUser } from '@/contexts/user-context';
interface SearchDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
open: boolean;
onOpenChange: (open: boolean) => void;
}
const categoryIcons = {
@@ -25,175 +25,185 @@ const categoryIcons = {
feature: FileText,
setting: Settings,
admin: Shield,
}
};
const categoryLabels = {
page: '页面',
feature: '功能',
setting: '设置',
admin: '管理',
}
};
const getTips = (metaKey: string) => [
(
<>
<span className="text-muted-foreground/80 lowercase">Tips: 还可以使用</span>
<Kbd className="mx-1">/</Kbd>
<span className="text-muted-foreground/80 lowercase">来打开此界面</span>
</>
),
(
<>
<span className="text-muted-foreground/80 lowercase">Tips: 使用</span>
<Kbd className="mx-1">↑</Kbd>
<Kbd className="mx-1">↓</Kbd>
<span className="text-muted-foreground/80 lowercase">来切换选中项</span>
</>
),
(
<>
<span className="text-muted-foreground/80 lowercase">Tips: 按住</span>
<Kbd className="mx-1">{metaKey}</Kbd>
<span className="text-muted-foreground/80 lowercase">+</span>
<Kbd className="mx-1">↵</Kbd>
<span className="text-muted-foreground/80 lowercase">在新标签页打开</span>
</>
),
(
<>
<span className="text-muted-foreground/80 lowercase">你知道吗:搜索功能还在持续升级中</span>
</>
)
]
<>
<span className='text-muted-foreground/80 lowercase'>Tips: 还可以使用</span>
<Kbd className='mx-1'>/</Kbd>
<span className='text-muted-foreground/80 lowercase'>来打开此界面</span>
</>,
<>
<span className='text-muted-foreground/80 lowercase'>Tips: 使用</span>
<Kbd className='mx-1'>↑</Kbd>
<Kbd className='mx-1'>↓</Kbd>
<span className='text-muted-foreground/80 lowercase'>来切换选中项</span>
</>,
<>
<span className='text-muted-foreground/80 lowercase'>Tips: 按住</span>
<Kbd className='mx-1'>{metaKey}</Kbd>
<span className='text-muted-foreground/80 lowercase'>+</span>
<Kbd className='mx-1'>↵</Kbd>
<span className='text-muted-foreground/80 lowercase'>在新标签页打开</span>
</>,
<>
<span className='text-muted-foreground/80 lowercase'>
你知道吗:搜索功能还在持续升级中
</span>
</>,
];
export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
const router = useRouter()
const { user } = useUser()
const [search, setSearch] = useState('')
const [currentTip, setCurrentTip] = useState<React.ReactNode>(null)
const [results, setResults] = useState<SearchItem[]>([])
const [metaKey, setMetaKey] = useState("⌘")
const [isCtrlPressed, setIsCtrlPressed] = useState(false)
const router = useRouter();
const { user } = useUser();
const [search, setSearch] = useState('');
const [currentTip, setCurrentTip] = useState<React.ReactNode>(null);
const [results, setResults] = useState<SearchItem[]>([]);
const [metaKey, setMetaKey] = useState('⌘');
const [isCtrlPressed, setIsCtrlPressed] = useState(false);
useEffect(() => {
if (typeof navigator !== 'undefined' && !navigator.userAgent?.includes("Mac")) {
setMetaKey("Ctrl")
if (
typeof navigator !== 'undefined' &&
!navigator.userAgent?.includes('Mac')
) {
setMetaKey('Ctrl');
}
}, [])
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Control' || e.key === 'Meta') {
setIsCtrlPressed(true)
setIsCtrlPressed(true);
}
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Control' || e.key === 'Meta') {
setIsCtrlPressed(false)
setIsCtrlPressed(false);
}
}
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
}
}, [])
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, []);
useEffect(() => {
if (open) {
const tips = getTips(metaKey)
const randomTip = tips[Math.floor(Math.random() * tips.length)]
setCurrentTip(randomTip)
const tips = getTips(metaKey);
const randomTip = tips[Math.floor(Math.random() * tips.length)];
setCurrentTip(randomTip);
}
}, [open, metaKey])
}, [open, metaKey]);
useEffect(() => {
const items = searchItems(search, user?.is_admin)
setResults(items)
}, [search, user?.is_admin])
const items = searchItems(search, user?.is_admin);
setResults(items);
}, [search, user?.is_admin]);
const handleSelect = useCallback((item: SearchItem, openInNewTab = false) => {
onOpenChange(false)
if (openInNewTab) {
window.open(item.url, '_blank')
} else {
router.push(item.url)
}
setSearch('')
}, [onOpenChange, router])
const handleSelect = useCallback(
(item: SearchItem, openInNewTab = false) => {
onOpenChange(false);
if (openInNewTab) {
window.open(item.url, '_blank');
} else {
router.push(item.url);
}
setSearch('');
},
[onOpenChange, router],
);
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
onOpenChange(!open)
e.preventDefault();
onOpenChange(!open);
}
if (e.key === '/'&& !e.ctrlKey && !e.metaKey && !e.altKey) {
const target = e.target as HTMLElement
const isEditing = target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable ||
target.closest('[contenteditable="true"]')
if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey) {
const target = e.target as HTMLElement;
const isEditing =
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable ||
target.closest('[contenteditable="true"]');
if (!isEditing) {
e.preventDefault()
onOpenChange(true)
e.preventDefault();
onOpenChange(true);
}
}
}
};
document.addEventListener('keydown', down)
return () => document.removeEventListener('keydown', down)
}, [open, onOpenChange])
document.addEventListener('keydown', down);
return () => document.removeEventListener('keydown', down);
}, [open, onOpenChange]);
// Group results by category
const groupedResults = results.reduce((acc, item) => {
if (!acc[item.category]) {
acc[item.category] = []
}
acc[item.category].push(item)
return acc
}, {} as Record<string, SearchItem[]>)
const groupedResults = results.reduce(
(acc, item) => {
if (!acc[item.category]) {
acc[item.category] = [];
}
acc[item.category].push(item);
return acc;
},
{} as Record<string, SearchItem[]>,
);
return (
<CommandDialog
open={open}
onOpenChange={onOpenChange}
shouldFilter={false}
className="max-w-[calc(100%-1.5rem)] sm:max-w-[500px] md:max-w-[540px]"
className='max-w-[calc(100%-1.5rem)] sm:max-w-[500px] md:max-w-[540px]'
>
<CommandInput
placeholder="搜索页面和功能..."
placeholder='搜索页面和功能...'
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandEmpty>没有找到相关内容,换个词试试?</CommandEmpty>
{Object.entries(groupedResults).map(([category, items]) => {
const Icon = categoryIcons[category as keyof typeof categoryIcons]
const Icon = categoryIcons[category as keyof typeof categoryIcons];
return (
<CommandGroup key={category} heading={categoryLabels[category as keyof typeof categoryLabels]}>
<CommandGroup
key={category}
heading={categoryLabels[category as keyof typeof categoryLabels]}
>
{items.map((item) => (
<CommandItem
key={item.id}
value={item.title}
onSelect={() => handleSelect(item, isCtrlPressed)}
className="flex items-center justify-between"
className='flex items-center justify-between'
>
<div className="flex min-w-0 items-center gap-3">
<div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted/70">
<Icon className="size-4" />
<div className='flex min-w-0 items-center gap-3'>
<div className='flex size-7 shrink-0 items-center justify-center rounded-md bg-muted/70'>
<Icon className='size-4' />
</div>
<div className="flex min-w-0 flex-col">
<div className='flex min-w-0 flex-col'>
<span>
{item.matchRange ? (
<>
{item.title.substring(0, item.matchRange[0])}
<span className="text-primary font-bold">
{item.title.substring(item.matchRange[0], item.matchRange[1] + 1)}
<span className='text-primary font-bold'>
{item.title.substring(
item.matchRange[0],
item.matchRange[1] + 1,
)}
</span>
{item.title.substring(item.matchRange[1] + 1)}
</>
@@ -201,34 +211,38 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
item.title
)}
</span>
<span className="truncate text-[11px] leading-4 text-muted-foreground">{item.description}</span>
<span className='truncate text-[11px] leading-4 text-muted-foreground'>
{item.description}
</span>
</div>
</div>
<span className="ml-3 shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-[10px] leading-none font-medium text-muted-foreground">
{categoryLabels[item.category as keyof typeof categoryLabels]}
<span className='ml-3 shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-[10px] leading-none font-medium text-muted-foreground'>
{
categoryLabels[
item.category as keyof typeof categoryLabels
]
}
</span>
</CommandItem>
))}
</CommandGroup>
)
);
})}
</CommandList>
<div className="hidden border-t bg-muted/20 px-4 py-2.5 md:flex items-center gap-4 text-[10px] text-muted-foreground uppercase tracking-wider font-medium select-none">
<div className="flex items-center gap-1">
<div className='hidden border-t bg-muted/20 px-4 py-2.5 md:flex items-center gap-4 text-[10px] text-muted-foreground uppercase tracking-wider font-medium select-none'>
<div className='flex items-center gap-1'>
<KbdGroup>
{isCtrlPressed && <Kbd>{metaKey}</Kbd>}
<Kbd>↵</Kbd>
</KbdGroup>
<span>{isCtrlPressed ? '在新标签页打开' : '打开'}</span>
</div>
<div className="flex items-center gap-1">
<div className='flex items-center gap-1'>
<Kbd>Esc</Kbd>
<span>关闭搜索界面</span>
</div>
<div className="ml-auto flex items-center gap-1">
{currentTip}
</div>
<div className='ml-auto flex items-center gap-1'>{currentTip}</div>
</div>
</CommandDialog>
)
);
}
+225 -166
View File
@@ -1,16 +1,16 @@
"use client"
'use client';
import * as React from "react"
import Link from "next/link"
import {usePathname, useRouter} from "next/navigation"
import {APP_BUILD_DATE, APP_VERSION} from "@/lib/app-info"
import {toast} from "sonner"
import {Button} from "@/components/ui/button"
import {Spinner} from "@/components/ui/spinner"
import {AnimateIcon} from "@/components/animate-ui/icons/icon"
import {ChevronLeft} from "@/components/animate-ui/icons/chevron-left"
import {ChevronRight} from "@/components/animate-ui/icons/chevron-right"
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
import * as React from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { APP_BUILD_DATE, APP_VERSION } from '@/lib/app-info';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { AnimateIcon } from '@/components/animate-ui/icons/icon';
import { ChevronLeft } from '@/components/animate-ui/icons/chevron-left';
import { ChevronRight } from '@/components/animate-ui/icons/chevron-right';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
DropdownMenu,
DropdownMenuContent,
@@ -18,8 +18,8 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {getCurrentTheme} from "@/components/layout/avater-style/registry"
} from '@/components/ui/dropdown-menu';
import { getCurrentTheme } from '@/components/layout/avater-style/registry';
import {
Sidebar,
SidebarContent,
@@ -32,7 +32,7 @@ import {
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
} from '@/components/ui/sidebar';
import {
AlertDialog,
AlertDialogAction,
@@ -42,7 +42,7 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
} from '@/components/ui/alert-dialog';
import {
ArrowUpRight,
Bell,
@@ -60,48 +60,59 @@ import {
ShieldCheck,
Terminal,
UserRound,
} from "lucide-react"
} from 'lucide-react';
import {useUser} from "@/contexts/user-context"
import {usePublicConfig} from "@/hooks/use-public-config"
import { useUser } from '@/contexts/user-context';
import { usePublicConfig } from '@/hooks/use-public-config';
/* 导航数据 */
const data = {
navMain: [
{ title: "首页", url: "/home", icon: Home },
{ title: "我的文件", url: "/files", icon: FolderOpen },
{ title: '首页', url: '/home', icon: Home },
{ title: '我的文件', url: '/files', icon: FolderOpen },
],
admin: [
{ title: "用户管理", url: "/admin/users", icon: UserRound },
{ title: "任务管理", url: "/admin/tasks", icon: Layers },
{ title: "存储管理", url: "/admin/files", icon: FolderOpen },
{ title: "数据管理", url: "/admin/database", icon: Database },
{ title: "通知推送", url: "/admin/push", icon: Bell },
{ title: "系统日志", url: "/admin/logs", icon: Terminal },
{ title: "系统配置", url: "/admin/system", icon: ShieldCheck },
{ title: "系统设置", url: "/admin/settings", icon: Settings },
{ title: '用户管理', url: '/admin/users', icon: UserRound },
{ title: '任务管理', url: '/admin/tasks', icon: Layers },
{ title: '存储管理', url: '/admin/files', icon: FolderOpen },
{ title: '数据管理', url: '/admin/database', icon: Database },
{ title: '通知推送', url: '/admin/push', icon: Bell },
{ title: '系统日志', url: '/admin/logs', icon: Terminal },
{ title: '系统配置', url: '/admin/system', icon: ShieldCheck },
{ title: '系统设置', url: '/admin/settings', icon: Settings },
],
document: [
{ title: "规范示例", url: "/admin/demo", icon: Code },
{ title: "接口文档", url: "/docs/api", icon: CreditCard, external: true },
{ title: "使用文档", url: "/docs/how-to-use", icon: FileText, external: true },
{ title: '规范示例', url: '/admin/demo', icon: Code },
{ title: '接口文档', url: '/docs/api', icon: CreditCard, external: true },
{
title: '使用文档',
url: '/docs/how-to-use',
icon: FileText,
external: true,
},
],
}
};
function parseMenuDisplayConfig(raw: string | undefined): Record<string, boolean> {
if (!raw) return {}
function parseMenuDisplayConfig(
raw: string | undefined,
): Record<string, boolean> {
if (!raw) return {};
try {
const parsed: unknown = JSON.parse(raw)
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
return {};
return Object.entries(parsed).reduce<Record<string, boolean>>((result, [key, value]) => {
if (typeof value === "boolean") {
result[key] = value
}
return result
}, {})
return Object.entries(parsed).reduce<Record<string, boolean>>(
(result, [key, value]) => {
if (typeof value === 'boolean') {
result[key] = value;
}
return result;
},
{},
);
} catch {
return {}
return {};
}
}
@@ -116,133 +127,152 @@ function parseMenuDisplayConfig(raw: string | undefined): Record<string, boolean
* @returns {React.ReactNode} 应用侧边栏组件
*/
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { toggleSidebar, state, isMobile, setOpenMobile } = useSidebar()
const {user, logout, loading: userLoading} = useUser()
const { config } = usePublicConfig()
const [showLogoutDialog, setShowLogoutDialog] = React.useState(false)
const [isLoggingOut, setIsLoggingOut] = React.useState(false)
const pathname = usePathname()
const router = useRouter()
const { toggleSidebar, state, isMobile, setOpenMobile } = useSidebar();
const { user, logout, loading: userLoading } = useUser();
const { config } = usePublicConfig();
const [showLogoutDialog, setShowLogoutDialog] = React.useState(false);
const [isLoggingOut, setIsLoggingOut] = React.useState(false);
const pathname = usePathname();
const router = useRouter();
const currentTheme = getCurrentTheme()
const currentTheme = getCurrentTheme();
const {
MainDecoration,
InteractionDecoration,
BackgroundEffect,
smallMainDecorationConfig,
largeMainDecorationConfig,
interactionDecorationConfig
} = currentTheme
const hasAvatarDecoration = currentTheme.key !== "none"
interactionDecorationConfig,
} = currentTheme;
const hasAvatarDecoration = currentTheme.key !== 'none';
const [showEffect, setShowEffect] = React.useState(false)
const [userMenuOpen, setUserMenuOpen] = React.useState(false)
const userMenuLastToggle = React.useRef(0)
const [showEffect, setShowEffect] = React.useState(false);
const [userMenuOpen, setUserMenuOpen] = React.useState(false);
const userMenuLastToggle = React.useRef(0);
const handleUserMenuOpenChange = React.useCallback((open: boolean) => {
const now = Date.now()
if (now - userMenuLastToggle.current < 65) return
userMenuLastToggle.current = now
setUserMenuOpen(open)
}, [])
const now = Date.now();
if (now - userMenuLastToggle.current < 65) return;
userMenuLastToggle.current = now;
setUserMenuOpen(open);
}, []);
const handleCloseSidebar = React.useCallback(() => {
if (isMobile) {
setOpenMobile(false)
setOpenMobile(false);
}
}, [isMobile, setOpenMobile])
}, [isMobile, setOpenMobile]);
const handleLogout = async () => {
setIsLoggingOut(true)
setIsLoggingOut(true);
try {
await logout()
setShowLogoutDialog(false)
await logout();
setShowLogoutDialog(false);
} catch (error) {
toast.error("登出失败", {
description: error instanceof Error ? error.message : "登出时发生错误,请重试"
})
setIsLoggingOut(false)
toast.error('登出失败', {
description:
error instanceof Error ? error.message : '登出时发生错误,请重试',
});
setIsLoggingOut(false);
}
}
};
const navMainFiltered = React.useMemo(() => {
const displayConfig = parseMenuDisplayConfig(config?.menu_display_config)
return data.navMain.filter((item) => displayConfig[item.url] !== false)
}, [config])
const displayConfig = parseMenuDisplayConfig(config?.menu_display_config);
return data.navMain.filter((item) => displayConfig[item.url] !== false);
}, [config]);
const adminFiltered = React.useMemo(() => {
const displayConfig = parseMenuDisplayConfig(config?.menu_display_config)
return data.admin.filter((item) => displayConfig[item.url] !== false)
}, [config])
const displayConfig = parseMenuDisplayConfig(config?.menu_display_config);
return data.admin.filter((item) => displayConfig[item.url] !== false);
}, [config]);
const documentFiltered = React.useMemo(() => {
const displayConfig = parseMenuDisplayConfig(config?.menu_display_config)
return data.document.filter((item) => displayConfig[item.url] !== false)
}, [config])
const displayConfig = parseMenuDisplayConfig(config?.menu_display_config);
return data.document.filter((item) => displayConfig[item.url] !== false);
}, [config]);
return (
<>
{hasAvatarDecoration && showEffect && BackgroundEffect && <BackgroundEffect />}
<Sidebar collapsible="icon" {...props} className="px-2 relative border-r border-border/40 group-data-[collapsible=icon]">
{hasAvatarDecoration && showEffect && BackgroundEffect && (
<BackgroundEffect />
)}
<Sidebar
collapsible='icon'
{...props}
className='px-2 relative border-r border-border/40 group-data-[collapsible=icon]'
>
<Button
onClick={toggleSidebar}
variant="ghost"
size="icon"
className="absolute top-1/2 -right-6 w-2 h-4 text-muted-foreground hover:bg-background hidden md:flex"
variant='ghost'
size='icon'
className='absolute top-1/2 -right-6 w-2 h-4 text-muted-foreground hover:bg-background hidden md:flex'
>
{state === "expanded" ? (
{state === 'expanded' ? (
<AnimateIcon animateOnHover>
<ChevronLeft className="size-6" />
<ChevronLeft className='size-6' />
</AnimateIcon>
) : (
<AnimateIcon animateOnHover>
<ChevronRight className="size-6" />
<ChevronRight className='size-6' />
</AnimateIcon>
)}
</Button>
<SidebarHeader className="py-4 md:-ml-2">
<DropdownMenu open={userMenuOpen} onOpenChange={handleUserMenuOpenChange}>
<SidebarHeader className='py-4 md:-ml-2'>
<DropdownMenu
open={userMenuOpen}
onOpenChange={handleUserMenuOpenChange}
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="flex h-12 hover:bg-accent group-data-[collapsible=icon]:ml-2"
variant='ghost'
className='flex h-12 hover:bg-accent group-data-[collapsible=icon]:ml-2'
suppressHydrationWarning
>
<div className="relative overflow-visible">
{hasAvatarDecoration && <MainDecoration className={smallMainDecorationConfig?.className} />}
<Avatar className="size-6 rounded relative z-10">
<AvatarImage
src={user?.avatar_url}
alt={user?.nickname}
<div className='relative overflow-visible'>
{hasAvatarDecoration && (
<MainDecoration
className={smallMainDecorationConfig?.className}
/>
<AvatarFallback className="rounded bg-muted text-sm">
{user?.nickname?.charAt(0)?.toUpperCase() || "L"}
)}
<Avatar className='size-6 rounded relative z-10'>
<AvatarImage src={user?.avatar_url} alt={user?.nickname} />
<AvatarFallback className='rounded bg-muted text-sm'>
{user?.nickname?.charAt(0)?.toUpperCase() || 'L'}
</AvatarFallback>
</Avatar>
</div>
<div className="flex flex-col items-start flex-1 text-left group-data-[collapsible=icon]:hidden">
<span className="text-xs font-medium truncate w-full text-left ml-2">
{user?.nickname || user?.username || "Unknown User"}
<div className='flex flex-col items-start flex-1 text-left group-data-[collapsible=icon]:hidden'>
<span className='text-xs font-medium truncate w-full text-left ml-2'>
{user?.nickname || user?.username || 'Unknown User'}
</span>
<span className="text-[11px] font-medium text-muted-foreground/100 truncate w-full text-left ml-2">
{user?.is_admin ? "系统管理员" : "普通用户"}
<span className='text-[11px] font-medium text-muted-foreground/100 truncate w-full text-left ml-2'>
{user?.is_admin ? '系统管理员' : '普通用户'}
</span>
</div>
<ChevronDown className="size-4 text-muted-foreground ml-auto group-data-[collapsible=icon]:hidden" />
<ChevronDown className='size-4 text-muted-foreground ml-auto group-data-[collapsible=icon]:hidden' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className={isMobile || state === "collapsed" ? "ml-1 w-68 z-[200]" : "w-64 z-[200]"}
align="start"
side={isMobile || state === "collapsed" ? "bottom" : "right"}
className={
isMobile || state === 'collapsed'
? 'ml-1 w-68 z-[200]'
: 'w-64 z-[200]'
}
align='start'
side={isMobile || state === 'collapsed' ? 'bottom' : 'right'}
sideOffset={4}
>
<DropdownMenuLabel>
<div className="flex flex-col items-center mb-4 gap-1 pt-4">
<div className='flex flex-col items-center mb-4 gap-1 pt-4'>
<div
className={`relative w-32 h-16 flex items-center justify-center overflow-visible select-none transition-transform ${hasAvatarDecoration ? "cursor-pointer active:scale-95" : ""}`}
onClick={hasAvatarDecoration ? () => setShowEffect(!showEffect) : undefined}
className={`relative w-32 h-16 flex items-center justify-center overflow-visible select-none transition-transform ${hasAvatarDecoration ? 'cursor-pointer active:scale-95' : ''}`}
onClick={
hasAvatarDecoration
? () => setShowEffect(!showEffect)
: undefined
}
>
{hasAvatarDecoration && (
<InteractionDecoration
@@ -250,63 +280,80 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
className={interactionDecorationConfig?.className}
/>
)}
<div className="relative z-10">
{hasAvatarDecoration && <MainDecoration className={largeMainDecorationConfig?.className} />}
<Avatar className="size-14 rounded-full pointer-events-none">
<div className='relative z-10'>
{hasAvatarDecoration && (
<MainDecoration
className={largeMainDecorationConfig?.className}
/>
)}
<Avatar className='size-14 rounded-full pointer-events-none'>
<AvatarImage
src={user?.avatar_url}
alt={user?.nickname}
/>
<AvatarFallback className="rounded-full bg-transparent text-lg">
{user?.nickname?.charAt(0)?.toUpperCase() || "L"}
<AvatarFallback className='rounded-full bg-transparent text-lg'>
{user?.nickname?.charAt(0)?.toUpperCase() || 'L'}
</AvatarFallback>
</Avatar>
</div>
</div>
<span className="text-base font-semibold truncate mt-2">
<span className='text-base font-semibold truncate mt-2'>
{userLoading
? "加载中..."
: user?.nickname || user?.username || "未登录"}
? '加载中...'
: user?.nickname || user?.username || '未登录'}
</span>
<span className="text-xs font-base text-muted-foreground">
{userLoading ? "正在同步账户" : user?.is_admin ? "系统管理员" : "普通用户"}
<span className='text-xs font-base text-muted-foreground'>
{userLoading
? '正在同步账户'
: user?.is_admin
? '系统管理员'
: '普通用户'}
</span>
</div>
</DropdownMenuLabel>
<DropdownMenuItem onClick={() => {
router.push("/settings/profile")
handleCloseSidebar()
}}>
<UserRound className="mr-2 size-4" />
<DropdownMenuItem
onClick={() => {
router.push('/settings/profile');
handleCloseSidebar();
}}
>
<UserRound className='mr-2 size-4' />
<span>我的资料</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => {
router.push("/settings")
handleCloseSidebar()
}}>
<Settings className="mr-2 size-4" />
<DropdownMenuItem
onClick={() => {
router.push('/settings');
handleCloseSidebar();
}}
>
<Settings className='mr-2 size-4' />
<span>设置</span>
</DropdownMenuItem>
<DropdownMenuSeparator className="my-2" />
<DropdownMenuItem onClick={() => {
router.push("/docs/how-to-use")
handleCloseSidebar()
}}>
<FileQuestionMark className="mr-2 size-4" />
<DropdownMenuSeparator className='my-2' />
<DropdownMenuItem
onClick={() => {
router.push('/docs/how-to-use');
handleCloseSidebar();
}}
>
<FileQuestionMark className='mr-2 size-4' />
<span>使用帮助</span>
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive hover:bg-destructive/50" onClick={() => setShowLogoutDialog(true)}>
<LogOut className="mr-2 size-4 text-destructive" />
<DropdownMenuItem
className='text-destructive hover:bg-destructive/50'
onClick={() => setShowLogoutDialog(true)}
>
<LogOut className='mr-2 size-4 text-destructive' />
<span>退出登录</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarHeader>
<SidebarContent className="group-data-[collapsible=icon]">
<SidebarContent className='group-data-[collapsible=icon]'>
{navMainFiltered.length > 0 && (
<SidebarGroup className="py-0">
<SidebarGroupContent className="py-1">
<SidebarMenu className="gap-1">
<SidebarGroup className='py-0'>
<SidebarGroupContent className='py-1'>
<SidebarMenu className='gap-1'>
{navMainFiltered.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
@@ -327,17 +374,20 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
)}
{user?.is_admin && adminFiltered.length > 0 && (
<SidebarGroup className="py-0 pt-4">
<SidebarGroupLabel className="text-xs font-normal text-muted-foreground">
<SidebarGroup className='py-0 pt-4'>
<SidebarGroupLabel className='text-xs font-normal text-muted-foreground'>
管理
</SidebarGroupLabel>
<SidebarGroupContent className="py-1">
<SidebarMenu className="gap-1">
<SidebarGroupContent className='py-1'>
<SidebarMenu className='gap-1'>
{adminFiltered.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
isActive={pathname === item.url || pathname.startsWith(`${item.url}/`)}
isActive={
pathname === item.url ||
pathname.startsWith(`${item.url}/`)
}
asChild
>
<Link href={item.url} onClick={handleCloseSidebar}>
@@ -353,12 +403,12 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
)}
{documentFiltered.length > 0 && (
<SidebarGroup className="py-0 pt-4">
<SidebarGroupLabel className="text-xs font-normal text-muted-foreground">
<SidebarGroup className='py-0 pt-4'>
<SidebarGroupLabel className='text-xs font-normal text-muted-foreground'>
文档库
</SidebarGroupLabel>
<SidebarGroupContent className="py-1">
<SidebarMenu className="gap-1">
<SidebarGroupContent className='py-1'>
<SidebarMenu className='gap-1'>
{documentFiltered.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
@@ -367,15 +417,20 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
asChild
>
{item.external ? (
<Link href={item.url} target="_blank" rel="noopener noreferrer" onClick={handleCloseSidebar}>
<Link
href={item.url}
target='_blank'
rel='noopener noreferrer'
onClick={handleCloseSidebar}
>
{item.icon && <item.icon />}
<span className="flex-1">{item.title}</span>
<ArrowUpRight className="size-3 text-muted-foreground" />
<span className='flex-1'>{item.title}</span>
<ArrowUpRight className='size-3 text-muted-foreground' />
</Link>
) : (
<Link href={item.url} onClick={handleCloseSidebar}>
{item.icon && <item.icon />}
<span className="flex-1">{item.title}</span>
<span className='flex-1'>{item.title}</span>
</Link>
)}
</SidebarMenuButton>
@@ -385,33 +440,37 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarGroupContent>
</SidebarGroup>
)}
</SidebarContent>
<SidebarFooter className="mt-auto px-3 py-3 group-data-[collapsible=icon]:hidden">
<div className="border-t border-border/60 pt-3 text-[11px] leading-5 text-muted-foreground">
<SidebarFooter className='mt-auto px-3 py-3 group-data-[collapsible=icon]:hidden'>
<div className='border-t border-border/60 pt-3 text-[11px] leading-5 text-muted-foreground'>
<div>Version {APP_VERSION}</div>
{APP_BUILD_DATE && <div>Build At {APP_BUILD_DATE}</div>}
</div>
</SidebarFooter>
</Sidebar>
<AlertDialog open={showLogoutDialog} onOpenChange={(open) => !isLoggingOut && setShowLogoutDialog(open)}>
<AlertDialog
open={showLogoutDialog}
onOpenChange={(open) => !isLoggingOut && setShowLogoutDialog(open)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认登出</AlertDialogTitle>
<AlertDialogDescription>
{isLoggingOut ? '正在登出,请稍候...' : '您确定要登出当前账户吗?'}
{isLoggingOut
? '正在登出,请稍候...'
: '您确定要登出当前账户吗?'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoggingOut}>取消</AlertDialogCancel>
<AlertDialogAction onClick={handleLogout} disabled={isLoggingOut}>
{isLoggingOut && <Spinner className="mr-2" />}
{isLoggingOut && <Spinner className='mr-2' />}
{isLoggingOut ? '登出中...' : '确认登出'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
);
}
@@ -1,11 +1,11 @@
"use client"
'use client';
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import * as React from 'react';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
+4 -5
View File
@@ -1,10 +1,9 @@
import { motion } from "motion/react"
import { motion } from 'motion/react';
/**
* 页面过渡组件
* 用于统一显示页面过渡效果
*
*
* @example
* ```tsx
* <PageTransition>
@@ -21,10 +20,10 @@ export function PageTransition({ children }: { children: React.ReactNode }) {
animate={{ opacity: 1 }}
transition={{
duration: 0.6,
ease: "easeOut",
ease: 'easeOut',
}}
>
{children}
</motion.div>
)
);
}
@@ -1,6 +1,6 @@
"use client"
'use client';
import { useUser } from "@/contexts/user-context"
import { useUser } from '@/contexts/user-context';
/**
* Auth provider bridge hook
@@ -9,5 +9,5 @@ import { useUser } from "@/contexts/user-context"
* Delegates to the canonical UserContext under the hood.
*/
export function useAuth() {
return useUser()
return useUser();
}
@@ -1,7 +1,7 @@
"use client"
'use client';
import {QueryClient, QueryClientProvider} from "@tanstack/react-query"
import {useState} from "react"
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
export function AppQueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
@@ -15,9 +15,9 @@ export function AppQueryProvider({ children }: { children: React.ReactNode }) {
},
},
}),
)
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
);
}
+23 -23
View File
@@ -1,36 +1,36 @@
"use client"
'use client';
import {useQuery} from '@tanstack/react-query'
import {useEffect} from 'react'
import {ConfigService} from '@/lib/services/config'
import {usePathname} from 'next/navigation'
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 pathname = usePathname();
const publicConfigQuery = useQuery({
queryKey: ["public-config"],
queryKey: ['public-config'],
queryFn: () => ConfigService.getPublicConfig(),
})
});
useEffect(() => {
const siteName = publicConfigQuery.data?.site_name || "Wavelet Platform"
const siteName = publicConfigQuery.data?.site_name || 'Wavelet Platform';
// 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 === "/home") {
suffix = " - 控制台"
} else if (pathname === "/") {
suffix = ""
let suffix = '';
if (pathname === '/login') {
suffix = ' - 登录';
} else if (pathname === '/register') {
suffix = ' - 注册';
} else if (pathname.startsWith('/admin')) {
suffix = ' - 后台管理';
} else if (pathname === '/home') {
suffix = ' - 控制台';
} else if (pathname === '/') {
suffix = '';
}
document.title = `${siteName}${suffix}`
}, [publicConfigQuery.data?.site_name, pathname])
document.title = `${siteName}${suffix}`;
}, [publicConfigQuery.data?.site_name, pathname]);
return null
return null;
}
+20 -20
View File
@@ -1,15 +1,15 @@
"use client"
'use client';
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import * as React from 'react';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDownIcon } from 'lucide-react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
return <AccordionPrimitive.Root data-slot='accordion' {...props} />;
}
function AccordionItem({
@@ -18,11 +18,11 @@ function AccordionItem({
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
data-slot='accordion-item'
className={cn('border-b last:border-b-0', className)}
{...props}
/>
)
);
}
function AccordionTrigger({
@@ -31,20 +31,20 @@ function AccordionTrigger({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Header className='flex'>
<AccordionPrimitive.Trigger
data-slot="accordion-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
'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" />
<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({
@@ -54,13 +54,13 @@ function AccordionContent({
}: 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"
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>
<div className={cn('pt-0 pb-4', className)}>{children}</div>
</AccordionPrimitive.Content>
)
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
+46 -38
View File
@@ -1,31 +1,31 @@
"use client"
'use client';
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
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"
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} />
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} />
)
<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} />
)
<AlertDialogPrimitive.Portal data-slot='alert-dialog-portal' {...props} />
);
}
function AlertDialogOverlay({
@@ -34,14 +34,14 @@ function AlertDialogOverlay({
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-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
'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({
@@ -52,44 +52,44 @@ function AlertDialogContent({
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-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
'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">) {
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
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">) {
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-dialog-footer"
data-slot='alert-dialog-footer'
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className,
)}
{...props}
/>
)
);
}
function AlertDialogTitle({
@@ -98,11 +98,11 @@ function AlertDialogTitle({
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
data-slot='alert-dialog-title'
className={cn('text-lg font-semibold', className)}
{...props}
/>
)
);
}
function AlertDialogDescription({
@@ -111,11 +111,11 @@ function AlertDialogDescription({
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
data-slot='alert-dialog-description'
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
);
}
function AlertDialogAction({
@@ -124,10 +124,14 @@ function AlertDialogAction({
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants({ variant: "destructive", size: "sm"}), "text-xs", className)}
className={cn(
buttonVariants({ variant: 'destructive', size: 'sm' }),
'text-xs',
className,
)}
{...props}
/>
)
);
}
function AlertDialogCancel({
@@ -136,10 +140,14 @@ function AlertDialogCancel({
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "text-xs", className)}
className={cn(
buttonVariants({ variant: 'ghost', size: 'sm' }),
'text-xs',
className,
)}
{...props}
/>
)
);
}
export {
@@ -154,4 +162,4 @@ export {
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
};
+24 -24
View File
@@ -1,6 +1,6 @@
"use client";
import { cn } from "@/lib/utils";
import React, { ReactNode, useEffect, useState } from "react";
'use client';
import { cn } from '@/lib/utils';
import React, { ReactNode, useEffect, useState } from 'react';
interface AuroraBackgroundProps extends React.HTMLProps<HTMLDivElement> {
children: ReactNode;
@@ -21,11 +21,11 @@ export const AuroraBackground = ({
};
// Add event listener for page visibility changes
document.addEventListener("visibilitychange", handleVisibilityChange);
document.addEventListener('visibilitychange', handleVisibilityChange);
// Cleanup
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
@@ -33,32 +33,32 @@ export const AuroraBackground = ({
<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",
'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"
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%)",
'--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",
'--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",
visibility: isVisible ? 'visible' : 'hidden',
} as React.CSSProperties
}
>
@@ -68,11 +68,11 @@ export const AuroraBackground = ({
`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%)]`,
`[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",
animationPlayState: isVisible ? 'running' : 'paused',
}}
></div>
</div>
+16 -16
View File
@@ -1,9 +1,9 @@
"use client"
'use client';
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Avatar({
className,
@@ -11,14 +11,14 @@ function Avatar({
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-slot='avatar'
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
className,
)}
{...props}
/>
)
);
}
function AvatarImage({
@@ -27,11 +27,11 @@ function AvatarImage({
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
data-slot='avatar-image'
className={cn('aspect-square size-full', className)}
{...props}
/>
)
);
}
function AvatarFallback({
@@ -40,14 +40,14 @@ function AvatarFallback({
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
data-slot='avatar-fallback'
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
'bg-muted flex size-full items-center justify-center rounded-full',
className,
)}
{...props}
/>
)
);
}
export { Avatar, AvatarImage, AvatarFallback }
export { Avatar, AvatarImage, AvatarFallback };
+17 -17
View File
@@ -1,46 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
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"
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",
'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",
'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",
'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",
'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",
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
},
},
defaultVariants: {
variant: "default",
variant: 'default',
},
}
)
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
}: React.ComponentProps<'span'> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
const Comp = asChild ? Slot : 'span';
return (
<Comp
data-slot="badge"
data-slot='badge'
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };
+43 -43
View File
@@ -1,101 +1,101 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
return <nav aria-label='breadcrumb' data-slot='breadcrumb' {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
return (
<ol
data-slot="breadcrumb-list"
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
'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">) {
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
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
}: React.ComponentProps<'a'> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "a"
const Comp = asChild ? Slot : 'a';
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
data-slot='breadcrumb-link'
className={cn('hover:text-foreground transition-colors', className)}
{...props}
/>
)
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
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)}
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">) {
}: React.ComponentProps<'li'>) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
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">) {
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
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>
<MoreHorizontal className='size-4' />
<span className='sr-only'>More</span>
</span>
)
);
}
export {
@@ -106,4 +106,4 @@ export {
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
};
+27 -27
View File
@@ -1,41 +1,41 @@
import * as React from "react"
import {Slot} from "@radix-ui/react-slot"
import {cva, type VariantProps} from "class-variance-authority"
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"
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",
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",
'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",
'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",
'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",
'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",
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",
variant: 'default',
size: 'default',
},
}
)
},
);
function Button({
className,
@@ -43,19 +43,19 @@ function Button({
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot="button"
data-slot='button'
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
);
}
export { Button, buttonVariants }
export { Button, buttonVariants };
+83 -83
View File
@@ -1,178 +1,178 @@
"use client"
'use client';
import * as React from "react"
import * as React from 'react';
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
} from 'lucide-react';
import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker';
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import { cn } from '@/lib/utils';
import { Button, buttonVariants } from '@/components/ui/button';
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
captionLayout = 'label',
buttonVariant = 'ghost',
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
}) {
const defaultClassNames = getDefaultClassNames()
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",
'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
className,
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
date.toLocaleString('default', { month: 'short' }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
root: cn('w-fit', defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
'flex gap-4 flex-col md:flex-row relative',
defaultClassNames.months,
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
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
'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
'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
'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
'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
'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
'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
'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
'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),
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
'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: cn('flex w-full mt-2', defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
'select-none w-(--cell-size)',
defaultClassNames.week_number_header,
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
'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",
'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
? '[&: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
'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),
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
'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
'text-muted-foreground aria-selected:text-muted-foreground',
defaultClassNames.outside,
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
'text-muted-foreground opacity-50',
defaultClassNames.disabled,
),
hidden: cn("invisible", defaultClassNames.hidden),
hidden: cn('invisible', defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
data-slot='calendar'
ref={rootRef}
className={cn(className)}
{...props}
/>
)
);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
if (orientation === 'left') {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
<ChevronLeftIcon className={cn('size-4', className)} {...props} />
);
}
if (orientation === "right") {
if (orientation === 'right') {
return (
<ChevronRightIcon
className={cn("size-4", className)}
className={cn('size-4', className)}
{...props}
/>
)
);
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
<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">
<div className='flex size-(--cell-size) items-center justify-center text-center'>
{children}
</div>
</td>
)
);
},
...components,
}}
{...props}
/>
)
);
}
function CalendarDayButton({
@@ -181,18 +181,18 @@ function CalendarDayButton({
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null)
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
ref={ref}
variant="ghost"
size="icon"
variant='ghost'
size='icon'
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
@@ -204,13 +204,13 @@ function CalendarDayButton({
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",
'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
className,
)}
{...props}
/>
)
);
}
export { Calendar, CalendarDayButton }
export { Calendar, CalendarDayButton };
+34 -34
View File
@@ -1,84 +1,84 @@
import * as React from "react"
import * as React from 'react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Card({ className, ...props }: React.ComponentProps<"div">) {
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
data-slot='card'
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-4 shadow-sm",
className
'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">) {
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
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
'@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">) {
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
data-slot='card-title'
className={cn('leading-none font-semibold', className)}
{...props}
/>
)
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
data-slot='card-description'
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
data-slot='card-action'
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className,
)}
{...props}
/>
)
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-content"
className={cn("px-4", className)}
data-slot='card-content'
className={cn('px-4', className)}
{...props}
/>
)
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
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)}
data-slot='card-footer'
className={cn('flex items-center px-4 [.border-t]:pt-6', className)}
{...props}
/>
)
);
}
export {
@@ -89,4 +89,4 @@ export {
CardAction,
CardDescription,
CardContent,
}
};
+105 -105
View File
@@ -1,108 +1,108 @@
"use client"
'use client';
import * as React from "react"
import * as React from 'react';
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
} from 'embla-carousel-react';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
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 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
}
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
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)
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext)
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
throw new Error('useCarousel must be used within a <Carousel />');
}
return context
return context;
}
function Carousel({
orientation = "horizontal",
orientation = 'horizontal',
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
}: React.ComponentProps<'div'> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
axis: orientation === 'horizontal' ? 'x' : 'y',
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
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())
}, [])
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
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()
if (event.key === 'ArrowLeft') {
event.preventDefault();
scrollPrev();
} else if (event.key === 'ArrowRight') {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext]
)
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
if (!api || !setApi) return;
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
if (!api) return;
onSelect(api);
api.on('reInit', onSelect);
api.on('select', onSelect);
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
api?.off('select', onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
@@ -111,7 +111,7 @@ function Carousel({
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
scrollPrev,
scrollNext,
canScrollPrev,
@@ -120,115 +120,115 @@ function Carousel({
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
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()
function CarouselContent({ className, ...props }: React.ComponentProps<'div'>) {
const { carouselRef, orientation } = useCarousel();
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
className='overflow-hidden'
data-slot='carousel-content'
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
'flex',
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
className,
)}
{...props}
/>
</div>
)
);
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
function CarouselItem({ className, ...props }: React.ComponentProps<'div'>) {
const { orientation } = useCarousel();
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
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
'min-w-0 shrink-0 grow-0 basis-full',
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
className,
)}
{...props}
/>
)
);
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
variant = 'outline',
size = 'icon',
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
data-slot="carousel-previous"
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
'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>
<span className='sr-only'>Previous slide</span>
</Button>
)
);
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
variant = 'outline',
size = 'icon',
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
data-slot="carousel-next"
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
'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>
<span className='sr-only'>Next slide</span>
</Button>
)
);
}
export {
@@ -238,4 +238,4 @@ export {
CarouselItem,
CarouselPrevious,
CarouselNext,
}
};
+123 -121
View File
@@ -1,37 +1,37 @@
"use client"
'use client';
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import * as React from 'react';
import * as RechartsPrimitive from 'recharts';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
const THEMES = { light: '', dark: '.dark' } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
);
};
type ChartContextProps = {
config: ChartConfig
}
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null)
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext)
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
throw new Error('useChart must be used within a <ChartContainer />');
}
return context
return context;
}
function ChartContainer({
@@ -40,29 +40,31 @@ function ChartContainer({
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
}: React.ComponentProps<'div'> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
>['children'];
}) {
const uniqueId = React.useId()
const [mounted, setMounted] = React.useState(false)
const uniqueId = React.useId();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true)
}, [])
setMounted(true);
}, []);
const chartId = mounted ? `chart-${ id || uniqueId.replace(/:/g, "") }` : "chart-"
const chartId = mounted
? `chart-${id || uniqueId.replace(/:/g, '')}`
: 'chart-';
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
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
className,
)}
{...props}
>
@@ -72,16 +74,16 @@ function ChartContainer({
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
([, config]) => config.theme || config.color,
);
if (!colorConfig.length) {
return null
return null;
}
return (
@@ -90,31 +92,31 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
__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") }
${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"),
.join('\n'),
}}
/>
)
}
);
};
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
indicator = 'dot',
hideLabel = false,
hideIndicator = false,
label,
@@ -125,41 +127,41 @@ function ChartTooltipContent({
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
React.ComponentProps<'div'> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: 'line' | 'dot' | 'dashed';
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart()
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
return null;
}
const [item] = payload
const key = `${ labelKey || item?.dataKey || item?.name || "value" }`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
!labelKey && typeof label === 'string'
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
<div className={cn('font-medium', labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
);
}
if (!value) {
return null
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
return <div className={cn('font-medium', labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
@@ -168,36 +170,36 @@ function ChartTooltipContent({
labelClassName,
config,
labelKey,
])
]);
if (!active || !payload?.length) {
return null
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot"
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
'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">
<div className='grid gap-1.5'>
{payload
.filter((item) => item.type !== "none")
.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
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"
'[&>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 ? (
@@ -210,19 +212,19 @@ function ChartTooltipContent({
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
'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",
}
'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,
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
} as React.CSSProperties
}
/>
@@ -230,18 +232,18 @@ function ChartTooltipContent({
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
'flex flex-1 justify-between leading-none',
nestLabel ? 'items-end' : 'items-center',
)}
>
<div className="grid gap-1.5">
<div className='grid gap-1.5'>
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
<span className='text-muted-foreground'>
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
<span className='text-foreground font-mono font-medium tabular-nums'>
{item.value.toLocaleString()}
</span>
)}
@@ -249,58 +251,58 @@ function ChartTooltipContent({
</>
)}
</div>
)
);
})}
</div>
</div>
)
);
}
const ChartLegend = RechartsPrimitive.Legend
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
verticalAlign = 'bottom',
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}: React.ComponentProps<'div'> &
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart()
const { config } = useChart();
if (!payload?.length) {
return null
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
'flex items-center justify-center gap-4',
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
className,
)}
>
{payload
.filter((item) => item.type !== "none")
.filter((item) => item.type !== 'none')
.map((item) => {
const key = `${ nameKey || item.dataKey || "value" }`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
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"
'[&>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]"
className='h-2 w-2 shrink-0 rounded-[2px]'
style={{
backgroundColor: item.color,
}}
@@ -308,49 +310,49 @@ function ChartLegendContent({
)}
{itemConfig?.label}
</div>
)
);
})}
</div>
)
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
key: string,
) {
if (typeof payload !== "object" || payload === null) {
return undefined
if (typeof payload !== 'object' || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
'payload' in payload &&
typeof payload.payload === 'object' &&
payload.payload !== null
? payload.payload
: undefined
: undefined;
let configLabelKey: string = key
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
typeof payload[key as keyof typeof payload] === 'string'
) {
configLabelKey = payload[key as keyof typeof payload] as string
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
: config[key as keyof typeof config];
}
export {
@@ -360,4 +362,4 @@ export {
ChartLegend,
ChartLegendContent,
ChartStyle,
}
};
+13 -13
View File
@@ -1,10 +1,10 @@
"use client"
'use client';
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { CheckIcon } from 'lucide-react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Checkbox({
className,
@@ -12,21 +12,21 @@ function Checkbox({
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
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
'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"
data-slot='checkbox-indicator'
className='grid place-content-center text-current transition-none'
>
<CheckIcon className="size-3.5" />
<CheckIcon className='size-3.5' />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
);
}
export { Checkbox }
export { Checkbox };
+32 -27
View File
@@ -1,61 +1,66 @@
"use client"
'use client';
import * as React from "react"
import { Check, Copy } from "lucide-react"
import { cn } from "@/lib/utils"
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
code: string;
language?: string;
showLineNumbers?: boolean;
}
export function CodeBlock({
code,
language = "bash",
language = 'bash',
className,
...props
}: CodeBlockProps) {
const [hasCopied, setHasCopied] = React.useState(false)
const [hasCopied, setHasCopied] = React.useState(false);
const onCopy = () => {
navigator.clipboard.writeText(code)
setHasCopied(true)
setTimeout(() => setHasCopied(false), 2000)
}
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
'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 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"
className='text-muted-foreground hover:text-white transition-colors'
>
{hasCopied ? (
<Check className="w-3.5 h-3.5" />
<Check className='w-3.5 h-3.5' />
) : (
<Copy 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 }`)}>
<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>
)
);
}
+66 -66
View File
@@ -1,18 +1,18 @@
"use client"
'use client';
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { motion } from "motion/react"
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 { cn } from '@/lib/utils';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
} from '@/components/ui/dialog';
function Command({
className,
@@ -20,50 +20,50 @@ function Command({
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
data-slot='command'
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
'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...",
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
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
shouldFilter?: boolean;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogHeader className='sr-only'>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0 shadow-2xl", className)}
className={cn('overflow-hidden p-0 shadow-2xl', className)}
showCloseButton={showCloseButton}
>
<Command
<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"
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({
@@ -72,20 +72,20 @@ function CommandInput({
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-12 items-center gap-2 border-b px-4"
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" />
<SearchIcon className='size-5 shrink-0 opacity-50' />
<CommandPrimitive.Input
data-slot="command-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
'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({
@@ -93,40 +93,40 @@ function CommandList({
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
const ref = React.useRef<HTMLDivElement>(null)
const [height, setHeight] = React.useState<number | "auto">("auto")
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)
setHeight(entry.contentRect.height);
}
})
resizeObserver.observe(ref.current)
return () => resizeObserver.disconnect()
});
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"
className='overflow-hidden'
>
<CommandPrimitive.List
ref={ref}
data-slot="command-list"
data-slot='command-list'
className={cn(
"max-h-[380px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
'max-h-[380px] scroll-py-1 overflow-x-hidden overflow-y-auto',
className,
)}
{...props}
>
{children}
</CommandPrimitive.List>
</motion.div>
)
);
}
function CommandEmpty({
@@ -134,11 +134,11 @@ function CommandEmpty({
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
data-slot='command-empty'
className='py-6 text-center text-sm'
{...props}
/>
)
);
}
function CommandGroup({
@@ -147,14 +147,14 @@ function CommandGroup({
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-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
'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({
@@ -163,11 +163,11 @@ function CommandSeparator({
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
data-slot='command-separator'
className={cn('bg-border -mx-1 h-px', className)}
{...props}
/>
)
);
}
function CommandItem({
@@ -176,30 +176,30 @@ function CommandItem({
}: 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}
/>
)
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">) {
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="command-shortcut"
data-slot='command-shortcut'
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
'text-muted-foreground ml-auto text-xs tracking-widest',
className,
)}
{...props}
/>
)
);
}
export {
@@ -212,4 +212,4 @@ export {
CommandItem,
CommandShortcut,
CommandSeparator,
}
};
+42 -46
View File
@@ -1,33 +1,33 @@
"use client"
'use client';
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { XIcon } from 'lucide-react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
return <DialogPrimitive.Root data-slot='dialog' {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
return <DialogPrimitive.Trigger data-slot='dialog-trigger' {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
return <DialogPrimitive.Portal data-slot='dialog-portal' {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
return <DialogPrimitive.Close data-slot='dialog-close' {...props} />;
}
function DialogOverlay({
@@ -36,14 +36,14 @@ function DialogOverlay({
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-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
'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({
@@ -52,62 +52,55 @@ function DialogContent({
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
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"
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">
<DialogPortal data-slot='dialog-portal'>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
commonStyle,
mobileBase,
desktopBase,
className
)}
data-slot='dialog-content'
className={cn(commonStyle, mobileBase, desktopBase, className)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-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>
<span className='sr-only'>Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
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)}
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">) {
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-row justify-end gap-2",
className
)}
data-slot='dialog-footer'
className={cn('flex flex-row justify-end gap-2', className)}
{...props}
/>
)
);
}
function DialogTitle({
@@ -116,11 +109,14 @@ function DialogTitle({
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base text-left leading-none font-semibold", className)}
data-slot='dialog-title'
className={cn(
'text-base text-left leading-none font-semibold',
className,
)}
{...props}
/>
)
);
}
function DialogDescription({
@@ -129,11 +125,11 @@ function DialogDescription({
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-left text-xs", className)}
data-slot='dialog-description'
className={cn('text-muted-foreground text-left text-xs', className)}
{...props}
/>
)
);
}
export {
@@ -147,4 +143,4 @@ export {
DialogPortal,
DialogTitle,
DialogTrigger,
}
};
+36 -27
View File
@@ -1,39 +1,42 @@
import * as React from "react"
import { cn } from "@/lib/utils"
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">
<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)}
className={cn('min-w-full text-sm text-left !my-0', className)}
{...props}
/>
</div>
))
DocsTable.displayName = "DocsTable"
));
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"
<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"
<tbody ref={ref} className={cn('bg-background', className)} {...props} />
));
DocsTableBody.displayName = 'DocsTableBody';
const DocsTableRow = React.forwardRef<
HTMLTableRowElement,
@@ -41,11 +44,14 @@ const DocsTableRow = React.forwardRef<
>(({ 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)}
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"
));
DocsTableRow.displayName = 'DocsTableRow';
const DocsTableHead = React.forwardRef<
HTMLTableCellElement,
@@ -54,13 +60,13 @@ const DocsTableHead = React.forwardRef<
<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
'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"
));
DocsTableHead.displayName = 'DocsTableHead';
const DocsTableCell = React.forwardRef<
HTMLTableCellElement,
@@ -68,11 +74,14 @@ const DocsTableCell = React.forwardRef<
>(({ 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)}
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"
));
DocsTableCell.displayName = 'DocsTableCell';
export {
DocsTable,
@@ -81,4 +90,4 @@ export {
DocsTableHead,
DocsTableRow,
DocsTableCell,
}
};
+38 -38
View File
@@ -1,32 +1,32 @@
"use client"
'use client';
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import * as React from 'react';
import { Drawer as DrawerPrimitive } from 'vaul';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
return <DrawerPrimitive.Root data-slot='drawer' {...props} />;
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
return <DrawerPrimitive.Trigger data-slot='drawer-trigger' {...props} />;
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
return <DrawerPrimitive.Portal data-slot='drawer-portal' {...props} />;
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
return <DrawerPrimitive.Close data-slot='drawer-close' {...props} />;
}
function DrawerOverlay({
@@ -35,14 +35,14 @@ function DrawerOverlay({
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-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
'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({
@@ -51,48 +51,48 @@ function DrawerContent({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerPortal data-slot='drawer-portal'>
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-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
'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" />
<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">) {
function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="drawer-header"
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
'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">) {
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)}
data-slot='drawer-footer'
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
)
);
}
function DrawerTitle({
@@ -101,11 +101,11 @@ function DrawerTitle({
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
data-slot='drawer-title'
className={cn('text-foreground font-semibold', className)}
{...props}
/>
)
);
}
function DrawerDescription({
@@ -114,11 +114,11 @@ function DrawerDescription({
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
data-slot='drawer-description'
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
);
}
export {
@@ -132,4 +132,4 @@ export {
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
};
+58 -58
View File
@@ -1,23 +1,23 @@
"use client"
'use client';
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
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"
import { cn } from '@/lib/utils';
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
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} />
)
<DropdownMenuPrimitive.Portal data-slot='dropdown-menu-portal' {...props} />
);
}
function DropdownMenuTrigger({
@@ -25,10 +25,10 @@ function DropdownMenuTrigger({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
data-slot='dropdown-menu-trigger'
{...props}
/>
)
);
}
function DropdownMenuContent({
@@ -39,47 +39,47 @@ function DropdownMenuContent({
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-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
'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} />
)
<DropdownMenuPrimitive.Group data-slot='dropdown-menu-group' {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-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
className,
)}
{...props}
/>
)
);
}
function DropdownMenuCheckboxItem({
@@ -90,22 +90,22 @@ function DropdownMenuCheckboxItem({
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
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
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
<CheckIcon className='size-4' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
);
}
function DropdownMenuRadioGroup({
@@ -113,10 +113,10 @@ function DropdownMenuRadioGroup({
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
data-slot='dropdown-menu-radio-group'
{...props}
/>
)
);
}
function DropdownMenuRadioItem({
@@ -126,21 +126,21 @@ function DropdownMenuRadioItem({
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
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
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<span className='pointer-events-none absolute left-2 flex size-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
<CircleIcon className='size-2 fill-current' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
);
}
function DropdownMenuLabel({
@@ -148,19 +148,19 @@ function DropdownMenuLabel({
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-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
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className,
)}
{...props}
/>
)
);
}
function DropdownMenuSeparator({
@@ -169,33 +169,33 @@ function DropdownMenuSeparator({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
data-slot='dropdown-menu-separator'
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
)
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="dropdown-menu-shortcut"
data-slot='dropdown-menu-shortcut'
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
'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} />
return <DropdownMenuPrimitive.Sub data-slot='dropdown-menu-sub' {...props} />;
}
function DropdownMenuSubTrigger({
@@ -204,22 +204,22 @@ function DropdownMenuSubTrigger({
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
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
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
<ChevronRightIcon className='ml-auto size-4' />
</DropdownMenuPrimitive.SubTrigger>
)
);
}
function DropdownMenuSubContent({
@@ -228,14 +228,14 @@ function DropdownMenuSubContent({
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
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
'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 {
@@ -254,4 +254,4 @@ export {
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
};
+89 -89
View File
@@ -1,110 +1,110 @@
"use client"
'use client';
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
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"
import { cn } from '@/lib/utils';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) {
return (
<fieldset
data-slot="field-set"
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
'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",
variant = 'legend',
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) {
return (
<legend
data-slot="field-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
'mb-3 font-medium',
'data-[variant=legend]:text-base',
'data-[variant=label]:text-sm',
className,
)}
{...props}
/>
)
);
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-group"
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
'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",
'group/field flex w-full gap-3 data-[invalid=true]:text-destructive',
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
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",
'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",
'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",
orientation: 'vertical',
},
}
)
},
);
function Field({
className,
orientation = "vertical",
orientation = 'vertical',
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
role='group'
data-slot='field'
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
);
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
function FieldContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-content"
data-slot='field-content'
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
'group/field-content flex flex-1 flex-col gap-1.5 leading-snug',
className,
)}
{...props}
/>
)
);
}
function FieldLabel({
@@ -113,74 +113,74 @@ function FieldLabel({
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-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
'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">) {
function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-label"
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
'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">) {
function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="field-description"
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
'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
}: React.ComponentProps<'div'> & {
children?: React.ReactNode;
}) {
return (
<div
data-slot="field-separator"
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
'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" />
<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"
className='bg-background text-muted-foreground relative mx-auto block w-fit px-2'
data-slot='field-separator-content'
>
{children}
</span>
)}
</div>
)
);
}
function FieldError({
@@ -188,50 +188,50 @@ function FieldError({
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}: React.ComponentProps<'div'> & {
errors?: Array<{ message?: string } | undefined>;
}) {
const content = useMemo(() => {
if (children) {
return children
return children;
}
if (!errors?.length) {
return null
return null;
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
];
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
return uniqueErrors[0]?.message;
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
<ul className='ml-4 flex list-disc flex-col gap-1'>
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
error?.message && <li key={index}>{error.message}</li>,
)}
</ul>
)
}, [children, errors])
);
}, [children, errors]);
if (!content) {
return null
return null;
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-destructive text-sm font-normal", className)}
role='alert'
data-slot='field-error'
className={cn('text-destructive text-sm font-normal', className)}
{...props}
>
{content}
</div>
)
);
}
export {
@@ -245,4 +245,4 @@ export {
FieldSet,
FieldContent,
FieldTitle,
}
};
+100 -89
View File
@@ -1,58 +1,69 @@
"use client"
'use client';
import { useEffect, useRef } from "react"
import { useEffect, useRef } from 'react';
interface Particle {
x: number
y: number
vx: number
vy: number
alpha: number
color: string
decay: number
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
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)
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d")
if (!ctx) return
const ctx = canvas.getContext('2d');
if (!ctx) return;
let animationFrameId: number
let fireworks: Firework[] = []
let animationFrameId: number;
let fireworks: Firework[] = [];
const colors = [
"#ff0000", "#ff7f00", "#ffff00", "#00ff00", "#0000ff", "#4b0082", "#8f00ff",
"#ff1493", "#00bfff", "#ffd700", "#ff4500", "#32cd32"
]
'#ff0000',
'#ff7f00',
'#ffff00',
'#00ff00',
'#0000ff',
'#4b0082',
'#8f00ff',
'#ff1493',
'#00bfff',
'#ffd700',
'#ff4500',
'#32cd32',
];
const resizeCanvas = () => {
canvas.width = window.innerWidth
canvas.height = window.innerHeight
}
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
const createFirework = () => {
const x = Math.random() * canvas.width
const y = canvas.height
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
const targetY =
canvas.height * 0.2 + Math.random() * (canvas.height * 0.5);
const speed = 8 + Math.random() * 5;
fireworks.push({
x,
@@ -63,17 +74,17 @@ export function FireworksEffect() {
color: colors[Math.floor(Math.random() * colors.length)],
particles: [],
exploded: false,
dead: false
})
}
dead: false,
});
};
const createParticles = (x: number, y: number, color: string) => {
const particleCount = 50 + Math.random() * 50
const particles: Particle[] = []
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
const angle = Math.random() * Math.PI * 2;
const speed = Math.random() * 5 + 1;
particles.push({
x,
y,
@@ -81,90 +92,90 @@ export function FireworksEffect() {
vy: Math.sin(angle) * speed,
alpha: 1,
color: color,
decay: Math.random() * 0.01 + 0.005
})
decay: Math.random() * 0.01 + 0.005,
});
}
return particles
}
return particles;
};
const updateAndDraw = () => {
if (!ctx || !canvas) return
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 = 'destination-out';
ctx.fillStyle = 'rgba(0, 0, 0, 0.1)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.globalCompositeOperation = "lighter"
ctx.globalCompositeOperation = 'lighter';
if (Math.random() < 0.04) {
createFirework()
createFirework();
}
fireworks.forEach((fw) => {
if (fw.dead) return
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()
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
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)
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]
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
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
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)
fw.particles.splice(i, 1);
}
}
if (fw.particles.length === 0) {
fw.dead = true
fw.dead = true;
}
}
})
});
fireworks = fireworks.filter(fw => !fw.dead)
fireworks = fireworks.filter((fw) => !fw.dead);
animationFrameId = requestAnimationFrame(updateAndDraw)
}
animationFrameId = requestAnimationFrame(updateAndDraw);
};
resizeCanvas()
updateAndDraw()
resizeCanvas();
updateAndDraw();
window.addEventListener("resize", resizeCanvas)
window.addEventListener('resize', resizeCanvas);
return () => {
cancelAnimationFrame(animationFrameId)
window.removeEventListener("resize", resizeCanvas)
}
}, [])
cancelAnimationFrame(animationFrameId);
window.removeEventListener('resize', resizeCanvas);
};
}, []);
return (
<canvas
ref={canvasRef}
className="fixed inset-0 pointer-events-none z-50"
style={{ background: "transparent" }}
className='fixed inset-0 pointer-events-none z-50'
style={{ background: 'transparent' }}
/>
)
);
}
+56 -55
View File
@@ -1,33 +1,33 @@
"use client"
'use client';
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
FormProvider,
useFormContext,
useFormState,
} from 'react-hook-form';
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { cn } from '@/lib/utils';
import { Label } from '@/components/ui/label';
const Form = FormProvider
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
{} as FormFieldContextValue,
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
@@ -39,21 +39,21 @@ const FormField = <
<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)
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>")
throw new Error('useFormField should be used within <FormField>');
}
const { id } = itemContext
const { id } = itemContext;
return {
id,
@@ -62,54 +62,55 @@ const useFormField = () => {
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
};
};
type FormItemContextValue = {
id: string
}
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
{} as FormItemContextValue,
);
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
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)}
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()
const { error, formItemId } = useFormField();
return (
<Label
data-slot="form-label"
data-slot='form-label'
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
className={cn('data-[error=true]:text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
)
);
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
data-slot="form-control"
data-slot='form-control'
id={formItemId}
aria-describedby={
!error
@@ -119,40 +120,40 @@ function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
aria-invalid={!!error}
{...props}
/>
)
);
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
const { formDescriptionId } = useFormField();
return (
<p
data-slot="form-description"
data-slot='form-description'
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
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
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? '') : props.children;
if (!body) {
return null
return null;
}
return (
<p
data-slot="form-message"
data-slot='form-message'
id={formMessageId}
className={cn("text-destructive text-sm", className)}
className={cn('text-destructive text-sm', className)}
{...props}
>
{body}
</p>
)
);
}
export {
@@ -164,4 +165,4 @@ export {
FormDescription,
FormMessage,
FormField,
}
};
+171 -129
View File
@@ -1,7 +1,7 @@
"use client"
'use client';
import {CropIcon, RotateCcwIcon} from "lucide-react"
import {Slot} from "@radix-ui/react-slot"
import { CropIcon, RotateCcwIcon } from 'lucide-react';
import { Slot } from '@radix-ui/react-slot';
import {
type ComponentProps,
createContext,
@@ -15,18 +15,18 @@ import {
useEffect,
useRef,
useState,
} from "react"
} 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"
} from 'react-image-crop';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import "react-image-crop/dist/ReactCrop.css"
import 'react-image-crop/dist/ReactCrop.css';
const centerAspectCrop = (
mediaWidth: number,
@@ -37,17 +37,17 @@ const centerAspectCrop = (
aspect
? makeAspectCrop(
{
unit: "%",
unit: '%',
width: 90,
},
aspect,
mediaWidth,
mediaHeight,
)
: { x: 0, y: 0, width: 90, height: 90, unit: "%" },
: { x: 0, y: 0, width: 90, height: 90, unit: '%' },
mediaWidth,
mediaHeight,
)
);
const getCroppedPngImage = async (
imageSrc: HTMLImageElement,
@@ -55,19 +55,19 @@ const getCroppedPngImage = async (
pixelCrop: PixelCrop,
maxImageSize: number,
): Promise<string> => {
const canvas = document.createElement("canvas")
const ctx = canvas.getContext("2d")
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error("Context is null, this should never happen.")
throw new Error('Context is null, this should never happen.');
}
const scaleX = imageSrc.naturalWidth / imageSrc.width
const scaleY = imageSrc.naturalHeight / imageSrc.height
const scaleX = imageSrc.naturalWidth / imageSrc.width;
const scaleY = imageSrc.naturalHeight / imageSrc.height;
ctx.imageSmoothingEnabled = false
canvas.width = pixelCrop.width
canvas.height = pixelCrop.height
ctx.imageSmoothingEnabled = false;
canvas.width = pixelCrop.width;
canvas.height = pixelCrop.height;
ctx.drawImage(
imageSrc,
@@ -79,53 +79,61 @@ const getCroppedPngImage = async (
0,
canvas.width,
canvas.height,
)
);
const croppedImageUrl = canvas.toDataURL("image/png")
const response = await fetch(croppedImageUrl)
const blob = await response.blob()
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 await getCroppedPngImage(
imageSrc,
scaleFactor * 0.9,
pixelCrop,
maxImageSize,
);
}
return croppedImageUrl
}
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
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 ImageCropContext = createContext<ImageCropContextType | null>(null);
const useImageCrop = () => {
const context = useContext(ImageCropContext)
const context = useContext(ImageCropContext);
if (!context) {
throw new Error("ImageCrop components must be used within ImageCrop")
throw new Error('ImageCrop components must be used within ImageCrop');
}
return context
}
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">
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,
@@ -136,54 +144,64 @@ export const ImageCrop = ({
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>()
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 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)
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)
}
setCrop(percentCrop);
onChange?.(pixelCrop, percentCrop);
};
const handleComplete = async (pixelCrop: PixelCrop, percentCrop: PercentCrop) => {
setCompletedCrop(pixelCrop)
onComplete?.(pixelCrop, percentCrop)
}
const handleComplete = async (
pixelCrop: PixelCrop,
percentCrop: PercentCrop,
) => {
setCompletedCrop(pixelCrop);
onComplete?.(pixelCrop, percentCrop);
};
const applyCrop = async () => {
if (!(imgRef.current && completedCrop)) {
return
return;
}
const croppedImage = await getCroppedPngImage(imgRef.current, 1, completedCrop, maxImageSize)
const croppedImage = await getCroppedPngImage(
imgRef.current,
1,
completedCrop,
maxImageSize,
);
onCrop?.(croppedImage)
}
onCrop?.(croppedImage);
};
const resetCrop = () => {
if (initialCrop) {
setCrop(initialCrop)
setCompletedCrop(null)
setCrop(initialCrop);
setCompletedCrop(null);
}
}
};
const contextValue: ImageCropContextType = {
file,
@@ -199,28 +217,42 @@ export const ImageCrop = ({
onImageLoad,
applyCrop,
resetCrop,
}
};
return <ImageCropContext.Provider value={contextValue}>{children}</ImageCropContext.Provider>
}
return (
<ImageCropContext.Provider value={contextValue}>
{children}
</ImageCropContext.Provider>
);
};
export interface ImageCropContentProps {
style?: CSSProperties
className?: string
style?: CSSProperties;
className?: string;
}
export const ImageCropContent = ({ style, className }: ImageCropContentProps) => {
const { imgSrc, crop, handleChange, handleComplete, onImageLoad, imgRef, reactCropProps } =
useImageCrop()
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
'--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)}
className={cn('max-h-[277px] max-w-full', className)}
crop={crop}
onChange={handleChange}
onComplete={handleComplete}
@@ -230,8 +262,8 @@ export const ImageCropContent = ({ style, className }: ImageCropContentProps) =>
{imgSrc && (
// eslint-disable-next-line @next/next/no-img-element
<img
alt="crop"
className="size-full"
alt='crop'
className='size-full'
height={400}
onLoad={onImageLoad}
ref={imgRef}
@@ -240,12 +272,12 @@ export const ImageCropContent = ({ style, className }: ImageCropContentProps) =>
/>
)}
</ReactCrop>
)
}
);
};
export type ImageCropApplyProps = ComponentProps<"button"> & {
asChild?: boolean
}
export type ImageCropApplyProps = ComponentProps<'button'> & {
asChild?: boolean;
};
export const ImageCropApply = ({
asChild = false,
@@ -253,31 +285,36 @@ export const ImageCropApply = ({
onClick,
...props
}: ImageCropApplyProps) => {
const { applyCrop } = useImageCrop()
const { applyCrop } = useImageCrop();
const handleClick = async (e: MouseEvent<HTMLButtonElement>) => {
await applyCrop()
onClick?.(e)
}
await applyCrop();
onClick?.(e);
};
if (asChild) {
return (
<Slot onClick={handleClick} {...(props as ComponentProps<"button">)}>
<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
onClick={handleClick}
size='icon'
variant='ghost'
{...(props as ComponentProps<'button'>)}
>
{children ?? <CropIcon className='size-4' />}
</Button>
)
}
);
};
export type ImageCropResetProps = ComponentProps<"button"> & {
asChild?: boolean
}
export type ImageCropResetProps = ComponentProps<'button'> & {
asChild?: boolean;
};
export const ImageCropReset = ({
asChild = false,
@@ -285,35 +322,40 @@ export const ImageCropReset = ({
onClick,
...props
}: ImageCropResetProps) => {
const { resetCrop } = useImageCrop()
const { resetCrop } = useImageCrop();
const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
resetCrop()
onClick?.(e)
}
resetCrop();
onClick?.(e);
};
if (asChild) {
return (
<Slot onClick={handleClick} {...(props as ComponentProps<"button">)}>
<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
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 type CropperProps = Omit<ReactCropProps, 'onChange'> & {
file: File;
maxImageSize?: number;
onCrop?: (croppedImage: string) => void;
onChange?: ReactCropProps['onChange'];
};
export const Cropper = ({
onChange,
@@ -331,8 +373,8 @@ export const Cropper = ({
onChange={onChange}
onComplete={onComplete}
onCrop={onCrop}
{...(props as Omit<ReactCropProps, "onChange" | "onComplete" | "children">)}
{...(props as Omit<ReactCropProps, 'onChange' | 'onComplete' | 'children'>)}
>
<ImageCropContent className={className} style={style} />
</ImageCrop>
)
);
+43 -41
View File
@@ -1,12 +1,12 @@
"use client"
'use client';
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { MinusIcon } from "lucide-react"
import * as React from 'react';
import { OTPInput, OTPInputContext } from 'input-otp';
import { MinusIcon } from 'lucide-react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const ErrorContext = React.createContext<boolean>(false)
const ErrorContext = React.createContext<boolean>(false);
function InputOTP({
className,
@@ -14,82 +14,84 @@ function InputOTP({
error,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
error?: boolean
containerClassName?: string;
error?: boolean;
}) {
return (
<ErrorContext.Provider value={error || false}>
<OTPInput
data-slot="input-otp"
data-slot='input-otp'
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName
'flex items-center gap-2 has-disabled:opacity-50',
containerClassName,
)}
className={cn("disabled:cursor-not-allowed", className)}
className={cn('disabled:cursor-not-allowed', className)}
{...props}
/>
</ErrorContext.Provider>
)
);
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center", className)}
data-slot='input-otp-group'
className={cn('flex items-center', className)}
{...props}
/>
)
);
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number
mask?: boolean
}: 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 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)
const slots = inputOTPContext?.slots ?? [];
const isAllFilled = slots.length >= 6 && slots.every((slot) => slot.char);
return (
<div
data-slot="input-otp-slot"
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",
'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
? '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}
{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 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">) {
function InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<div data-slot='input-otp-separator' role='separator' {...props}>
<MinusIcon />
</div>
)
);
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
+10 -10
View File
@@ -1,21 +1,21 @@
import * as React from "react"
import * as React from 'react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
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
'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 }
export { Input };
+12 -12
View File
@@ -1,28 +1,28 @@
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
return (
<kbd
data-slot="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",
'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
'[[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">) {
function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
data-slot='kbd-group'
className={cn('inline-flex items-center gap-1', className)}
{...props}
/>
)
);
}
export { Kbd, KbdGroup }
export { Kbd, KbdGroup };
+9 -9
View File
@@ -1,9 +1,9 @@
"use client"
'use client';
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Label({
className,
@@ -11,14 +11,14 @@ function Label({
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
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
'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 }
export { Label };
+42 -42
View File
@@ -1,68 +1,68 @@
import * as React from "react"
import * as React from 'react';
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
} from 'lucide-react';
import { cn } from "@/lib/utils"
import { buttonVariants, type Button } from "@/components/ui/button"
import { cn } from '@/lib/utils';
import { type Button, buttonVariants } from '@/components/ui/button';
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
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)}
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">) {
}: React.ComponentProps<'ul'>) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
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} />
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">
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, 'size'> &
React.ComponentProps<'a'>;
function PaginationLink({
className,
isActive,
size = "icon",
size = 'icon',
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
aria-current={isActive ? 'page' : undefined}
data-slot='pagination-link'
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
variant: isActive ? 'outline' : 'ghost',
size,
}),
className
className,
)}
{...props}
/>
)
);
}
function PaginationPrevious({
@@ -71,15 +71,15 @@ function PaginationPrevious({
}: 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)}
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>
<span className='hidden sm:block'>Previous</span>
</PaginationLink>
)
);
}
function PaginationNext({
@@ -88,32 +88,32 @@ function PaginationNext({
}: 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)}
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>
<span className='hidden sm:block'>Next</span>
<ChevronRightIcon />
</PaginationLink>
)
);
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
}: React.ComponentProps<'span'>) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
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>
<MoreHorizontalIcon className='size-4' />
<span className='sr-only'>More pages</span>
</span>
)
);
}
export {
@@ -124,4 +124,4 @@ export {
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}
};
+11 -11
View File
@@ -1,16 +1,16 @@
"use client"
'use client';
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
function PopoverContent({
className,
align = "center",
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
@@ -20,13 +20,13 @@ function PopoverContent({
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
'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 }
export { Popover, PopoverTrigger, PopoverContent };
+11 -11
View File
@@ -1,9 +1,9 @@
"use client"
'use client';
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import * as React from 'react';
import * as ProgressPrimitive from '@radix-ui/react-progress';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Progress({
className,
@@ -12,20 +12,20 @@ function Progress({
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
data-slot='progress'
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
'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"
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 }
export { Progress };
+23 -23
View File
@@ -1,13 +1,13 @@
"use client"
'use client';
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
type ScrollAreaProps = React.ComponentProps<typeof ScrollAreaPrimitive.Root> & {
viewportRef?: React.Ref<HTMLDivElement>
}
viewportRef?: React.Ref<HTMLDivElement>;
};
function ScrollArea({
className,
@@ -17,48 +17,48 @@ function ScrollArea({
}: ScrollAreaProps) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
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"
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",
orientation = 'vertical',
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
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
'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"
data-slot='scroll-area-thumb'
className='bg-border relative flex-1 rounded-full'
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
);
}
export { ScrollArea, ScrollBar }
export { ScrollArea, ScrollBar };
+47 -47
View File
@@ -1,71 +1,71 @@
"use client"
'use client';
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
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"
import { cn } from '@/lib/utils';
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
return <SelectPrimitive.Root data-slot='select' {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
return <SelectPrimitive.Group data-slot='select-group' {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
return <SelectPrimitive.Value data-slot='select-value' {...props} />;
}
function SelectTrigger({
className,
size = "default",
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
size?: 'sm' | 'default';
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-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
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
<ChevronDownIcon className='size-4 opacity-50' />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
);
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
position = 'popper',
align = 'center',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-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
'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}
@@ -74,9 +74,9 @@ function SelectContent({
<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"
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
)}
>
{children}
@@ -84,7 +84,7 @@ function SelectContent({
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
);
}
function SelectLabel({
@@ -93,11 +93,11 @@ function SelectLabel({
}: 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)}
data-slot='select-label'
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props}
/>
)
);
}
function SelectItem({
@@ -107,21 +107,21 @@ function SelectItem({
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-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
className,
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<span className='absolute right-2 flex size-3.5 items-center justify-center'>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
<CheckIcon className='size-4' />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
);
}
function SelectSeparator({
@@ -130,11 +130,11 @@ function SelectSeparator({
}: 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)}
data-slot='select-separator'
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...props}
/>
)
);
}
function SelectScrollUpButton({
@@ -143,16 +143,16 @@ function SelectScrollUpButton({
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
data-slot='select-scroll-up-button'
className={cn(
"flex cursor-pointer items-center justify-center py-1",
className
'flex cursor-pointer items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
<ChevronUpIcon className='size-4' />
</SelectPrimitive.ScrollUpButton>
)
);
}
function SelectScrollDownButton({
@@ -161,16 +161,16 @@ function SelectScrollDownButton({
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
data-slot='select-scroll-down-button'
className={cn(
"flex cursor-pointer items-center justify-center py-1",
className
'flex cursor-pointer items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
<ChevronDownIcon className='size-4' />
</SelectPrimitive.ScrollDownButton>
)
);
}
export {
@@ -184,4 +184,4 @@ export {
SelectSeparator,
SelectTrigger,
SelectValue,
}
};
+10 -10
View File
@@ -1,28 +1,28 @@
"use client"
'use client';
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Separator({
className,
orientation = "horizontal",
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
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
'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 }
export { Separator };
+45 -45
View File
@@ -1,31 +1,31 @@
"use client"
'use client';
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import * as React from 'react';
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { XIcon } from 'lucide-react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
return <SheetPrimitive.Root data-slot='sheet' {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
return <SheetPrimitive.Trigger data-slot='sheet-trigger' {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
return <SheetPrimitive.Close data-slot='sheet-close' {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
return <SheetPrimitive.Portal data-slot='sheet-portal' {...props} />;
}
function SheetOverlay({
@@ -34,71 +34,71 @@ function SheetOverlay({
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-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
'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",
side = 'right',
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
side?: 'top' | 'right' | 'bottom' | 'left';
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-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
'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 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">) {
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
data-slot='sheet-header'
className={cn('flex flex-col gap-1.5 p-4', className)}
{...props}
/>
)
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
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)}
data-slot='sheet-footer'
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
)
);
}
function SheetTitle({
@@ -107,11 +107,11 @@ function SheetTitle({
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
data-slot='sheet-title'
className={cn('text-foreground font-semibold', className)}
{...props}
/>
)
);
}
function SheetDescription({
@@ -120,11 +120,11 @@ function SheetDescription({
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
data-slot='sheet-description'
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
);
}
export {
@@ -136,4 +136,4 @@ export {
SheetFooter,
SheetTitle,
SheetDescription,
}
};
+307 -296
View File
@@ -1,46 +1,57 @@
"use client"
'use client';
import * as React from "react"
import {Slot} from "@radix-ui/react-slot"
import {cva, type VariantProps} from "class-variance-authority"
import {PanelLeftIcon} from "lucide-react"
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { PanelLeftIcon } from 'lucide-react';
import {useIsMobile} from "@/hooks/use-mobile"
import {cn} from "@/lib/utils"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Separator} from "@/components/ui/separator"
import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle,} from "@/components/ui/sheet"
import {Skeleton} from "@/components/ui/skeleton"
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,} from "@/components/ui/tooltip"
import {ScrollArea} from "@/components/ui/scroll-area"
import { useIsMobile } from '@/hooks/use-mobile';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Skeleton } from '@/components/ui/skeleton';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { ScrollArea } from '@/components/ui/scroll-area';
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "15rem"
const SIDEBAR_WIDTH_MOBILE = "16rem"
const SIDEBAR_WIDTH_ICON = "4rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
const SIDEBAR_COOKIE_NAME = 'sidebar_state';
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = '15rem';
const SIDEBAR_WIDTH_MOBILE = '16rem';
const SIDEBAR_WIDTH_ICON = '4rem';
const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
state: 'expanded' | 'collapsed';
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext)
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
throw new Error('useSidebar must be used within a SidebarProvider.');
}
return context
return context;
}
function SidebarProvider({
@@ -51,37 +62,37 @@ function SidebarProvider({
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}: React.ComponentProps<'div'> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
const openState = typeof value === 'function' ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState)
setOpenProp(openState);
} else {
_setOpen(openState)
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open]
)
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
@@ -90,18 +101,18 @@ function SidebarProvider({
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
event.preventDefault();
toggleSidebar();
}
}
};
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const state = open ? 'expanded' : 'collapsed';
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
@@ -113,24 +124,24 @@ function SidebarProvider({
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
data-slot='sidebar-wrapper'
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
className,
)}
{...props}
>
@@ -138,111 +149,111 @@ function SidebarProvider({
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
side = 'left',
variant = 'sidebar',
collapsible = 'offcanvas',
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}: React.ComponentProps<'div'> & {
side?: 'left' | 'right';
variant?: 'sidebar' | 'floating' | 'inset';
collapsible?: 'offcanvas' | 'icon' | 'none';
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
if (collapsible === 'none') {
return (
<div
data-slot="sidebar"
data-slot='sidebar'
className={cn(
"text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
'text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col',
className,
)}
{...props}
>
{children}
</div>
)
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
data-sidebar='sidebar'
data-slot='sidebar'
data-mobile='true'
className='text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden'
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetHeader className='sr-only'>
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
<div className='flex h-full w-full flex-col'>{children}</div>
</SheetContent>
</Sheet>
)
);
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
className='group peer text-sidebar-foreground hidden md:block'
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-collapsible={state === 'collapsed' ? collapsible : ''}
data-variant={variant}
data-side={side}
data-slot="sidebar"
data-slot='sidebar'
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
data-slot='sidebar-gap'
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
"will-change-[width]"
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
'will-change-[width]',
)}
/>
<div
data-slot="sidebar-container"
data-slot='sidebar-container'
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
"will-change-[left,right,width]", // Optimize performance
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
'will-change-[left,right,width]', // Optimize performance
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
data-sidebar='sidebar'
data-slot='sidebar-inner'
className='group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm'
>
{children}
</div>
</div>
</div>
)
);
}
function SidebarTrigger({
@@ -250,64 +261,64 @@ function SidebarTrigger({
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
data-sidebar='trigger'
data-slot='sidebar-trigger'
variant='ghost'
size='icon'
className={cn('size-7', className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
<span className='sr-only'>Toggle Sidebar</span>
</Button>
)
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
data-sidebar='rail'
data-slot='sidebar-rail'
aria-label='Toggle Sidebar'
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
title='Toggle Sidebar'
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
className,
)}
{...props}
/>
)
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
return (
<main
data-slot="sidebar-inset"
data-slot='sidebar-inset'
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
'bg-background relative flex w-full flex-1 flex-col',
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
className,
)}
{...props}
/>
)
);
}
function SidebarInput({
@@ -316,34 +327,34 @@ function SidebarInput({
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
data-slot='sidebar-input'
data-sidebar='input'
className={cn('bg-background h-8 w-full shadow-none', className)}
{...props}
/>
)
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
data-slot='sidebar-header'
data-sidebar='header'
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
)
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
data-slot='sidebar-footer'
data-sidebar='footer'
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
)
);
}
function SidebarSeparator({
@@ -352,196 +363,196 @@ function SidebarSeparator({
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
data-slot='sidebar-separator'
data-sidebar='separator'
className={cn('bg-sidebar-border mx-2 w-auto', className)}
{...props}
/>
)
);
}
function SidebarContent({
className,
children,
...props
}: React.ComponentProps<"div">) {
}: React.ComponentProps<'div'>) {
return (
<ScrollArea
data-slot="sidebar-content"
data-sidebar="content"
data-slot='sidebar-content'
data-sidebar='content'
className={cn(
"flex min-h-0 flex-1 flex-col [&>[data-slot=scroll-area-scrollbar]]:group-data-[collapsible=icon]:hidden",
className
'flex min-h-0 flex-1 flex-col [&>[data-slot=scroll-area-scrollbar]]:group-data-[collapsible=icon]:hidden',
className,
)}
>
<div className="flex flex-col gap-2" {...props}>
<div className='flex flex-col gap-2' {...props}>
{children}
</div>
</ScrollArea>
)
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
data-slot='sidebar-group'
data-sidebar='group'
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
{...props}
/>
)
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
}: React.ComponentProps<'div'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'div';
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
data-slot='sidebar-group-label'
data-sidebar='group-label'
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
className,
)}
{...props}
/>
)
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
}: React.ComponentProps<'button'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
data-slot='sidebar-group-action'
data-sidebar='group-action'
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
'after:absolute after:-inset-2 md:after:hidden',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
)
);
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
data-slot='sidebar-group-content'
data-sidebar='group-content'
className={cn('w-full text-sm', className)}
{...props}
/>
)
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
data-slot='sidebar-menu'
data-sidebar='menu'
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
{...props}
/>
)
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
data-slot='sidebar-menu-item'
data-sidebar='menu-item'
className={cn('group/menu-item relative', className)}
{...props}
/>
)
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-bold data-[active=true]:text-sidebar-primary data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-bold data-[active=true]:text-sidebar-primary data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',
},
},
defaultVariants: {
variant: "default",
size: "default",
variant: 'default',
size: 'default',
},
}
)
},
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
variant = 'default',
size = 'default',
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
}: React.ComponentProps<'button'> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const Comp = asChild ? Slot : 'button';
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-slot='sidebar-menu-button'
data-sidebar='menu-button'
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
);
if (!tooltip) {
return button
return button;
}
if (typeof tooltip === "string") {
if (typeof tooltip === 'string') {
tooltip = {
children: tooltip,
}
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
side='right'
align='center'
hidden={state !== 'collapsed' || isMobile}
{...tooltip}
/>
</Tooltip>
)
);
}
function SidebarMenuAction({
@@ -549,152 +560,152 @@ function SidebarMenuAction({
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}: React.ComponentProps<'button'> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
data-slot='sidebar-menu-action'
data-sidebar='menu-action'
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
'after:absolute after:-inset-2 md:after:hidden',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0',
className,
)}
{...props}
/>
)
);
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
data-slot='sidebar-menu-badge'
data-sidebar='menu-badge'
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none',
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
)
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}: React.ComponentProps<'div'> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
data-slot='sidebar-menu-skeleton'
data-sidebar='menu-skeleton'
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
className='size-4 rounded-md'
data-sidebar='menu-skeleton-icon'
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
className='h-4 max-w-(--skeleton-width) flex-1'
data-sidebar='menu-skeleton-text'
style={
{
"--skeleton-width": width,
'--skeleton-width': width,
} as React.CSSProperties
}
/>
</div>
)
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
data-slot='sidebar-menu-sub'
data-sidebar='menu-sub'
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
)
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
}: React.ComponentProps<'li'>) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
data-slot='sidebar-menu-sub-item'
data-sidebar='menu-sub-item'
className={cn('group/menu-sub-item relative', className)}
{...props}
/>
)
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
size = 'md',
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}: React.ComponentProps<'a'> & {
asChild?: boolean;
size?: 'sm' | 'md';
isActive?: boolean;
}) {
const Comp = asChild ? Slot : "a"
const Comp = asChild ? Slot : 'a';
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-slot='sidebar-menu-sub-button'
data-sidebar='menu-sub-button'
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:font-bold data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
'data-[active=true]:font-bold data-[active=true]:text-sidebar-accent-foreground',
size === 'sm' && 'text-xs',
size === 'md' && 'text-sm',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
)
);
}
export {
@@ -722,4 +733,4 @@ export {
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
};
+6 -6
View File
@@ -1,13 +1,13 @@
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
data-slot='skeleton'
className={cn('bg-accent animate-pulse rounded-md', className)}
{...props}
/>
)
);
}
export { Skeleton }
export { Skeleton };
+12 -12
View File
@@ -1,9 +1,9 @@
"use client"
'use client';
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import * as React from 'react';
import * as SliderPrimitive from '@radix-ui/react-slider';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
@@ -12,17 +12,17 @@ const Slider = React.forwardRef<
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
'relative flex w-full touch-none select-none items-center',
className,
)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
<SliderPrimitive.Track className='relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20'>
<SliderPrimitive.Range className='absolute h-full bg-primary' />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
<SliderPrimitive.Thumb className='block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50' />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider }
export { Slider };
+52 -52
View File
@@ -1,37 +1,37 @@
"use client"
'use client';
import { useEffect, useRef } from "react"
import { useEffect, useRef } from 'react';
interface Snowflake {
x: number
y: number
radius: number
speed: number
opacity: number
drift: number
x: number;
y: number;
radius: number;
speed: number;
opacity: number;
drift: number;
}
export function SnowEffect() {
const canvasRef = useRef<HTMLCanvasElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d")
if (!ctx) return
const ctx = canvas.getContext('2d');
if (!ctx) return;
let animationFrameId: number
let snowflakes: Snowflake[] = []
let animationFrameId: number;
let snowflakes: Snowflake[] = [];
const resizeCanvas = () => {
canvas.width = window.innerWidth
canvas.height = window.innerHeight
}
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
const createSnowflakes = () => {
const count = Math.floor(window.innerWidth / 4)
snowflakes = []
const count = Math.floor(window.innerWidth / 4);
snowflakes = [];
for (let i = 0; i < count; i++) {
snowflakes.push({
x: Math.random() * canvas.width,
@@ -39,58 +39,58 @@ export function SnowEffect() {
radius: Math.random() * 3 + 1,
speed: Math.random() * 2 + 0.5,
opacity: Math.random() * 0.5 + 0.3,
drift: Math.random() * 1 - 0.5
})
drift: Math.random() * 1 - 0.5,
});
}
}
};
const drawSnowflakes = () => {
if (!ctx || !canvas) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
if (!ctx || !canvas) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
snowflakes.forEach((flake) => {
ctx.beginPath()
ctx.arc(flake.x, flake.y, flake.radius, 0, Math.PI * 2)
ctx.fillStyle = `rgba(255, 255, 255, ${ flake.opacity })`
ctx.fill()
ctx.beginPath();
ctx.arc(flake.x, flake.y, flake.radius, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255, 255, 255, ${flake.opacity})`;
ctx.fill();
flake.y += flake.speed
flake.x += flake.drift
flake.y += flake.speed;
flake.x += flake.drift;
if (flake.y > canvas.height) {
flake.y = -flake.radius
flake.x = Math.random() * canvas.width
flake.y = -flake.radius;
flake.x = Math.random() * canvas.width;
}
if (flake.x > canvas.width) {
flake.x = 0
flake.x = 0;
} else if (flake.x < 0) {
flake.x = canvas.width
flake.x = canvas.width;
}
})
});
animationFrameId = requestAnimationFrame(drawSnowflakes)
}
animationFrameId = requestAnimationFrame(drawSnowflakes);
};
resizeCanvas()
createSnowflakes()
drawSnowflakes()
resizeCanvas();
createSnowflakes();
drawSnowflakes();
window.addEventListener("resize", () => {
resizeCanvas()
createSnowflakes()
})
window.addEventListener('resize', () => {
resizeCanvas();
createSnowflakes();
});
return () => {
cancelAnimationFrame(animationFrameId)
window.removeEventListener("resize", resizeCanvas)
}
}, [])
cancelAnimationFrame(animationFrameId);
window.removeEventListener('resize', resizeCanvas);
};
}, []);
return (
<canvas
ref={canvasRef}
className="fixed inset-0 pointer-events-none z-50"
style={{ background: "transparent" }}
className='fixed inset-0 pointer-events-none z-50'
style={{ background: 'transparent' }}
/>
)
);
}
+28 -29
View File
@@ -1,66 +1,65 @@
"use client"
'use client';
import { useEffect, useRef } from "react"
import { useEffect, useRef } from 'react';
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, useSonner, type ToasterProps } from "sonner"
import { useBellRing } from "@/contexts/bell-ring-context"
} from 'lucide-react';
import { useTheme } from 'next-themes';
import { Toaster as Sonner, type ToasterProps, useSonner } from 'sonner';
import { useBellRing } from '@/contexts/bell-ring-context';
/**
* Toast 观察器组件
* 监听 toast 事件并触发铃铛响铃动画
*/
function ToastObserver() {
const { toasts } = useSonner()
const { triggerRing } = useBellRing()
const prevToastCountRef = useRef(0)
const { toasts } = useSonner();
const { triggerRing } = useBellRing();
const prevToastCountRef = useRef(0);
useEffect(() => {
// 当 toast 数量增加时触发响铃
if (toasts.length > prevToastCountRef.current) {
triggerRing()
triggerRing();
}
prevToastCountRef.current = toasts.length
}, [toasts.length, triggerRing])
prevToastCountRef.current = toasts.length;
}, [toasts.length, triggerRing]);
return null
return null;
}
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
const { theme = 'system' } = useTheme();
return (
<>
<ToastObserver />
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
theme={theme as ToasterProps['theme']}
className='toaster group'
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
success: <CircleCheckIcon className='size-4' />,
info: <InfoIcon className='size-4' />,
warning: <TriangleAlertIcon className='size-4' />,
error: <OctagonXIcon className='size-4' />,
loading: <Loader2Icon className='size-4 animate-spin' />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
'--border-radius': 'var(--radius)',
} as React.CSSProperties
}
{...props}
/>
</>
)
}
export { Toaster }
);
};
export { Toaster };
+8 -8
View File
@@ -1,16 +1,16 @@
import { Loader2Icon } from "lucide-react"
import { Loader2Icon } from 'lucide-react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
function Spinner({ className, ...props }: React.ComponentProps<'svg'>) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
role='status'
aria-label='Loading'
className={cn('size-4 animate-spin', className)}
{...props}
/>
)
);
}
export { Spinner }
export { Spinner };
+11 -11
View File
@@ -1,9 +1,9 @@
"use client"
'use client';
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import * as React from 'react';
import * as SwitchPrimitive from '@radix-ui/react-switch';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
function Switch({
className,
@@ -11,21 +11,21 @@ function Switch({
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-slot='switch'
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
data-slot='switch-thumb'
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitive.Root>
)
);
}
export { Switch }
export { Switch };

Some files were not shown because too many files have changed in this diff Show More