压缩历史至 95081aff

This commit is contained in:
ryan
2026-06-08 20:34:27 +08:00
commit 8a782525de
435 changed files with 71146 additions and 0 deletions
@@ -0,0 +1,57 @@
'use client';
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import {
Button as ButtonPrimitive,
type ButtonProps as ButtonPrimitiveProps,
} from '@/components/animate-ui/primitives/buttons/button';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[box-shadow,_color,_background-color,_border-color,_outline-color,_text-decoration-color,_fill,_stroke] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
accent: 'bg-accent text-accent-foreground shadow-xs hover:bg-accent/90',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8 rounded-md',
'icon-lg': 'size-10 rounded-md',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
type ButtonProps = ButtonPrimitiveProps & VariantProps<typeof buttonVariants>;
function Button({ className, variant, size, ...props }: ButtonProps) {
return (
<ButtonPrimitive
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants, type ButtonProps };
@@ -0,0 +1,56 @@
'use client';
import * as React from 'react';
import { type VariantProps } from 'class-variance-authority';
import { buttonVariants } from '@/components/animate-ui/components/buttons/button';
import {
RippleButton as RippleButtonPrimitive,
RippleButtonRipples as RippleButtonRipplesPrimitive,
type RippleButtonProps as RippleButtonPrimitiveProps,
type RippleButtonRipplesProps as RippleButtonRipplesPrimitiveProps,
} from '@/components/animate-ui/primitives/buttons/ripple';
import { cn } from '@/lib/utils';
const rippleButtonVariants = {
default: '[--ripple-button-ripple-color:var(--primary-foreground)]',
accent: '[--ripple-button-ripple-color:var(--accent-foreground)]',
destructive: '[--ripple-button-ripple-color:var(--destructive-foreground)]',
outline: '[--ripple-button-ripple-color:var(--foreground)]',
secondary: '[--ripple-button-ripple-color:var(--secondary-foreground)]',
ghost: '[--ripple-button-ripple-color:var(--foreground)]',
link: '[--ripple-button-ripple-color:var(--primary-foreground)]',
};
type RippleButtonProps = RippleButtonPrimitiveProps &
VariantProps<typeof buttonVariants>;
function RippleButton({
className,
variant,
size,
...props
}: RippleButtonProps) {
return (
<RippleButtonPrimitive
className={cn(
buttonVariants({ variant, size, className }),
rippleButtonVariants[variant as keyof typeof rippleButtonVariants],
)}
{...props}
/>
);
}
type RippleButtonRipplesProps = RippleButtonRipplesPrimitiveProps;
function RippleButtonRipples(props: RippleButtonRipplesProps) {
return <RippleButtonRipplesPrimitive {...props} />;
}
export {
RippleButton,
RippleButtonRipples,
type RippleButtonProps,
type RippleButtonRipplesProps,
};
@@ -0,0 +1,75 @@
'use client';
import * as React from 'react';
import { motion, type Variants } from 'motion/react';
import {
getVariants,
useAnimateIconContext,
IconWrapper,
type IconProps,
} from '@/components/animate-ui/icons/icon';
type CheckProps = IconProps<keyof typeof animations>;
const animations = {
default: {
path: {
initial: {
pathLength: 1,
opacity: 1,
scale: 1,
},
animate: {
pathLength: [0, 1],
opacity: [0, 1],
scale: [1, 1.1, 1],
transition: {
duration: 0.6,
ease: 'easeInOut',
},
},
},
} satisfies Record<string, Variants>,
} as const;
function IconComponent({ size, ...props }: CheckProps) {
const { controls } = useAnimateIconContext();
const variants = getVariants(animations);
return (
<motion.svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
initial="initial"
animate={controls}
{...props}
>
<motion.path
d="m4 12 5 5L20 6"
variants={variants.path}
initial="initial"
animate={controls}
/>
</motion.svg>
);
}
function Check(props: CheckProps) {
return <IconWrapper icon={IconComponent} {...props} />;
}
export {
animations,
Check,
Check as CheckIcon,
type CheckProps,
type CheckProps as CheckIconProps,
};
@@ -0,0 +1,78 @@
'use client';
import * as React from 'react';
import { motion, type Variants } from 'motion/react';
import {
getVariants,
useAnimateIconContext,
IconWrapper,
type IconProps,
} from '@/components/animate-ui/icons/icon';
type ChevronDownProps = IconProps<keyof typeof animations>;
const animations = {
default: {
path: {
initial: {
y: 0,
transition: { duration: 0.3, ease: 'easeInOut' },
},
animate: {
y: 4,
transition: { duration: 0.3, ease: 'easeInOut' },
},
},
} satisfies Record<string, Variants>,
'default-loop': {
path: {
initial: {
y: 0,
},
animate: {
y: [0, 4, 0],
transition: { duration: 0.6, ease: 'easeInOut' },
},
},
} satisfies Record<string, Variants>,
} as const;
function IconComponent({ size, ...props }: ChevronDownProps) {
const { controls } = useAnimateIconContext();
const variants = getVariants(animations);
return (
<motion.svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<motion.path
d="m6 9 6 6 6-6"
variants={variants.path}
initial="initial"
animate={controls}
/>
</motion.svg>
);
}
function ChevronDown(props: ChevronDownProps) {
return <IconWrapper icon={IconComponent} {...props} />;
}
export {
animations,
ChevronDown,
ChevronDown as ChevronDownIcon,
type ChevronDownProps,
type ChevronDownProps as ChevronDownIconProps,
};
@@ -0,0 +1,78 @@
'use client';
import * as React from 'react';
import { motion, type Variants } from 'motion/react';
import {
getVariants,
useAnimateIconContext,
IconWrapper,
type IconProps,
} from '@/components/animate-ui/icons/icon';
type ChevronLeftProps = IconProps<keyof typeof animations>;
const animations = {
default: {
path: {
initial: {
x: 0,
transition: { duration: 0.3, ease: 'easeInOut' },
},
animate: {
x: -4,
transition: { duration: 0.3, ease: 'easeInOut' },
},
},
} satisfies Record<string, Variants>,
'default-loop': {
path: {
initial: {
x: 0,
},
animate: {
x: [0, -4, 0],
transition: { duration: 0.6, ease: 'easeInOut' },
},
},
} satisfies Record<string, Variants>,
} as const;
function IconComponent({ size, ...props }: ChevronLeftProps) {
const { controls } = useAnimateIconContext();
const variants = getVariants(animations);
return (
<motion.svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<motion.path
d="m15 18-6-6 6-6"
variants={variants.path}
initial="initial"
animate={controls}
/>
</motion.svg>
);
}
function ChevronLeft(props: ChevronLeftProps) {
return <IconWrapper icon={IconComponent} {...props} />;
}
export {
animations,
ChevronLeft,
ChevronLeft as ChevronLeftIcon,
type ChevronLeftProps,
type ChevronLeftProps as ChevronLeftIconProps,
};
@@ -0,0 +1,78 @@
'use client';
import * as React from 'react';
import { motion, type Variants } from 'motion/react';
import {
getVariants,
useAnimateIconContext,
IconWrapper,
type IconProps,
} from '@/components/animate-ui/icons/icon';
type ChevronRightProps = IconProps<keyof typeof animations>;
const animations = {
default: {
path: {
initial: {
x: 0,
transition: { duration: 0.3, ease: 'easeInOut' },
},
animate: {
x: 4,
transition: { duration: 0.3, ease: 'easeInOut' },
},
},
} satisfies Record<string, Variants>,
'default-loop': {
path: {
initial: {
x: 0,
},
animate: {
x: [0, 4, 0],
transition: { duration: 0.6, ease: 'easeInOut' },
},
},
} satisfies Record<string, Variants>,
} as const;
function IconComponent({ size, ...props }: ChevronRightProps) {
const { controls } = useAnimateIconContext();
const variants = getVariants(animations);
return (
<motion.svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<motion.path
d="m9 18 6-6-6-6"
variants={variants.path}
initial="initial"
animate={controls}
/>
</motion.svg>
);
}
function ChevronRight(props: ChevronRightProps) {
return <IconWrapper icon={IconComponent} {...props} />;
}
export {
animations,
ChevronRight,
ChevronRight as ChevronRightIcon,
type ChevronRightProps,
type ChevronRightProps as ChevronRightIconProps,
};
@@ -0,0 +1,82 @@
'use client';
import * as React from 'react';
import { motion, type Variants } from 'motion/react';
import {
getVariants,
useAnimateIconContext,
IconWrapper,
type IconProps,
} from '@/components/animate-ui/icons/icon';
type CircleCheckProps = IconProps<keyof typeof animations>;
const animations = {
default: {
circle: {},
path: {
initial: {
pathLength: 1,
opacity: 1,
scale: 1,
},
animate: {
pathLength: [0, 1],
opacity: [0, 1],
scale: [1, 1.1, 1],
transition: {
duration: 0.6,
ease: 'easeInOut',
},
},
},
} satisfies Record<string, Variants>,
} as const;
function IconComponent({ size, ...props }: CircleCheckProps) {
const { controls } = useAnimateIconContext();
const variants = getVariants(animations);
return (
<motion.svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<motion.circle
cx="12"
cy="12"
r="10"
variants={variants.circle}
initial="initial"
animate={controls}
/>
<motion.path
d="m9 12 2 2 4-4"
variants={variants.path}
initial="initial"
animate={controls}
/>
</motion.svg>
);
}
function CircleCheck(props: CircleCheckProps) {
return <IconWrapper icon={IconComponent} {...props} />;
}
export {
animations,
CircleCheck,
CircleCheck as CircleCheckIcon,
type CircleCheckProps,
type CircleCheckProps as CircleCheckIconProps,
};
@@ -0,0 +1,655 @@
'use client';
import * as React from 'react';
import {
motion,
useAnimation,
type SVGMotionProps,
type UseInViewOptions,
type LegacyAnimationControls,
type Variants,
type HTMLMotionProps,
} from 'motion/react';
import { cn } from '@/lib/utils';
import { useIsInView } from '@/hooks/use-is-in-view';
import { Slot, type WithAsChild } from '@/components/animate-ui/primitives/animate/slot';
const staticAnimations = {
path: {
initial: { pathLength: 1 },
animate: {
pathLength: [0.05, 1],
transition: {
duration: 0.8,
ease: 'easeInOut',
},
},
} as Variants,
'path-loop': {
initial: { pathLength: 1 },
animate: {
pathLength: [1, 0.05, 1],
transition: {
duration: 1.6,
ease: 'easeInOut',
},
},
} as Variants,
} as const;
type StaticAnimations = keyof typeof staticAnimations;
type TriggerProp<T = string> = boolean | StaticAnimations | T;
type Trigger = TriggerProp<string>;
type AnimateIconContextValue = {
controls: LegacyAnimationControls | undefined;
animation: StaticAnimations | string;
loop: boolean;
loopDelay: number;
active: boolean;
animate?: Trigger;
initialOnAnimateEnd?: boolean;
completeOnStop?: boolean;
persistOnAnimateEnd?: boolean;
delay?: number;
};
type DefaultIconProps<T = string> = {
animate?: TriggerProp<T>;
animateOnHover?: TriggerProp<T>;
animateOnTap?: TriggerProp<T>;
animateOnView?: TriggerProp<T>;
animateOnViewMargin?: UseInViewOptions['margin'];
animateOnViewOnce?: boolean;
animation?: T | StaticAnimations;
loop?: boolean;
loopDelay?: number;
initialOnAnimateEnd?: boolean;
completeOnStop?: boolean;
persistOnAnimateEnd?: boolean;
delay?: number;
};
type AnimateIconProps<T = string> = WithAsChild<
HTMLMotionProps<'span'> &
DefaultIconProps<T> & {
children: React.ReactNode;
asChild?: boolean;
}
>;
type IconProps<T> = DefaultIconProps<T> &
Omit<SVGMotionProps<SVGSVGElement>, 'animate'> & {
size?: number;
};
type IconWrapperProps<T> = IconProps<T> & {
icon: React.ComponentType<IconProps<T>>;
};
const AnimateIconContext = React.createContext<AnimateIconContextValue | null>(
null,
);
function useAnimateIconContext() {
const context = React.useContext(AnimateIconContext);
if (!context)
return {
controls: undefined,
animation: 'default',
loop: undefined,
loopDelay: undefined,
active: undefined,
animate: undefined,
initialOnAnimateEnd: undefined,
completeOnStop: undefined,
persistOnAnimateEnd: undefined,
delay: undefined,
};
return context;
}
function composeEventHandlers<E extends React.SyntheticEvent<unknown>>(
theirs?: (event: E) => void,
ours?: (event: E) => void,
) {
return (event: E) => {
theirs?.(event);
ours?.(event);
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyProps = Record<string, any>;
function AnimateIcon({
asChild = false,
animate = false,
animateOnHover = false,
animateOnTap = false,
animateOnView = false,
animateOnViewMargin = '0px',
animateOnViewOnce = true,
animation = 'default',
loop = false,
loopDelay = 0,
initialOnAnimateEnd = false,
completeOnStop = false,
persistOnAnimateEnd = false,
delay = 0,
children,
...props
}: AnimateIconProps) {
const controls = useAnimation();
const [localAnimate, setLocalAnimate] = React.useState<boolean>(() => {
if (animate === undefined || animate === false) return false;
return delay <= 0;
});
const [currentAnimation, setCurrentAnimation] = React.useState<
string | StaticAnimations
>(typeof animate === 'string' ? animate : animation);
const [status, setStatus] = React.useState<'initial' | 'animate'>('initial');
const delayRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const loopDelayRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const isAnimateInProgressRef = React.useRef<boolean>(false);
const animateEndPromiseRef = React.useRef<Promise<void> | null>(null);
const resolveAnimateEndRef = React.useRef<(() => void) | null>(null);
const activeRef = React.useRef<boolean>(localAnimate);
const runGenRef = React.useRef(0);
const cancelledRef = React.useRef(false);
const bumpGeneration = React.useCallback(() => {
runGenRef.current++;
}, []);
const startAnimation = React.useCallback(
(trigger: TriggerProp) => {
const next = typeof trigger === 'string' ? trigger : animation;
bumpGeneration();
if (delayRef.current) {
clearTimeout(delayRef.current);
delayRef.current = null;
}
setCurrentAnimation(next);
if (delay > 0) {
setLocalAnimate(false);
delayRef.current = setTimeout(() => {
setLocalAnimate(true);
}, delay);
} else {
setLocalAnimate(true);
}
},
[animation, delay, bumpGeneration],
);
const stopAnimation = React.useCallback(() => {
bumpGeneration();
if (delayRef.current) {
clearTimeout(delayRef.current);
delayRef.current = null;
}
if (loopDelayRef.current) {
clearTimeout(loopDelayRef.current);
loopDelayRef.current = null;
}
setLocalAnimate(false);
}, [bumpGeneration]);
React.useEffect(() => {
activeRef.current = localAnimate;
}, [localAnimate]);
React.useEffect(() => {
if (animate === undefined) return;
setCurrentAnimation(typeof animate === 'string' ? animate : animation);
if (animate) startAnimation(animate as TriggerProp);
else stopAnimation();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [animate]);
React.useEffect(() => {
return () => {
if (delayRef.current) clearTimeout(delayRef.current);
if (loopDelayRef.current) clearTimeout(loopDelayRef.current);
};
}, []);
const viewOuterRef = React.useRef<HTMLElement>(null);
const { ref: inViewRef, isInView } = useIsInView(viewOuterRef, {
inView: !!animateOnView,
inViewOnce: animateOnViewOnce,
inViewMargin: animateOnViewMargin,
});
const startAnim = React.useCallback(
async (anim: 'initial' | 'animate', method: 'start' | 'set' = 'start') => {
try {
await controls[method](anim);
setStatus(anim);
} catch {
return;
}
},
[controls],
);
React.useEffect(() => {
if (!animateOnView) return;
if (isInView) startAnimation(animateOnView);
else stopAnimation();
}, [isInView, animateOnView, startAnimation, stopAnimation]);
React.useEffect(() => {
const gen = ++runGenRef.current;
cancelledRef.current = false;
async function run() {
if (cancelledRef.current || gen !== runGenRef.current) {
await startAnim('initial');
return;
}
if (!localAnimate) {
if (
completeOnStop &&
isAnimateInProgressRef.current &&
animateEndPromiseRef.current
) {
try {
await animateEndPromiseRef.current;
} catch {
// noop
}
}
if (!persistOnAnimateEnd) {
if (cancelledRef.current || gen !== runGenRef.current) {
await startAnim('initial');
return;
}
await startAnim('initial');
}
return;
}
if (loop) {
if (cancelledRef.current || gen !== runGenRef.current) {
await startAnim('initial');
return;
}
await startAnim('initial', 'set');
}
isAnimateInProgressRef.current = true;
animateEndPromiseRef.current = new Promise<void>((resolve) => {
resolveAnimateEndRef.current = resolve;
});
if (cancelledRef.current || gen !== runGenRef.current) {
isAnimateInProgressRef.current = false;
resolveAnimateEndRef.current?.();
resolveAnimateEndRef.current = null;
animateEndPromiseRef.current = null;
await startAnim('initial');
return;
}
await startAnim('animate');
if (cancelledRef.current || gen !== runGenRef.current) {
isAnimateInProgressRef.current = false;
resolveAnimateEndRef.current?.();
resolveAnimateEndRef.current = null;
animateEndPromiseRef.current = null;
await startAnim('initial');
return;
}
isAnimateInProgressRef.current = false;
resolveAnimateEndRef.current?.();
resolveAnimateEndRef.current = null;
animateEndPromiseRef.current = null;
if (initialOnAnimateEnd) {
if (cancelledRef.current || gen !== runGenRef.current) {
await startAnim('initial');
return;
}
await startAnim('initial', 'set');
}
if (loop) {
if (loopDelay > 0) {
await new Promise<void>((resolve) => {
loopDelayRef.current = setTimeout(() => {
loopDelayRef.current = null;
resolve();
}, loopDelay);
});
if (cancelledRef.current || gen !== runGenRef.current) {
await startAnim('initial');
return;
}
if (!activeRef.current) {
if (status !== 'initial' && !persistOnAnimateEnd)
await startAnim('initial');
return;
}
} else {
if (!activeRef.current) {
if (status !== 'initial' && !persistOnAnimateEnd)
await startAnim('initial');
return;
}
}
if (cancelledRef.current || gen !== runGenRef.current) {
await startAnim('initial');
return;
}
await run();
}
}
void run();
return () => {
cancelledRef.current = true;
if (delayRef.current) {
clearTimeout(delayRef.current);
delayRef.current = null;
}
if (loopDelayRef.current) {
clearTimeout(loopDelayRef.current);
loopDelayRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localAnimate, controls]);
const childProps = (
React.isValidElement(children) ? (children as React.ReactElement).props : {}
) as AnyProps;
const handleMouseEnter = composeEventHandlers<React.MouseEvent<HTMLElement>>(
childProps.onMouseEnter,
() => {
if (animateOnHover) startAnimation(animateOnHover);
},
);
const handleMouseLeave = composeEventHandlers<React.MouseEvent<HTMLElement>>(
childProps.onMouseLeave,
() => {
if (animateOnHover || animateOnTap) stopAnimation();
},
);
const handlePointerDown = composeEventHandlers<
React.PointerEvent<HTMLElement>
>(childProps.onPointerDown, () => {
if (animateOnTap) startAnimation(animateOnTap);
});
const handlePointerUp = composeEventHandlers<React.PointerEvent<HTMLElement>>(
childProps.onPointerUp,
() => {
if (animateOnTap) stopAnimation();
},
);
const content = asChild ? (
<Slot
ref={inViewRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
{...props}
>
{children}
</Slot>
) : (
<motion.span
ref={inViewRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
{...props}
>
{children}
</motion.span>
);
return (
<AnimateIconContext.Provider
value={{
controls,
animation: currentAnimation,
loop,
loopDelay,
active: localAnimate,
animate,
initialOnAnimateEnd,
completeOnStop,
delay,
}}
>
{content}
</AnimateIconContext.Provider>
);
}
const pathClassName =
"[&_[stroke-dasharray='1px_1px']]:![stroke-dasharray:1px_0px]";
function IconWrapper<T extends string>({
size = 28,
animation: animationProp,
animate,
animateOnHover,
animateOnTap,
animateOnView,
animateOnViewMargin,
animateOnViewOnce,
icon: IconComponent,
loop,
loopDelay,
persistOnAnimateEnd,
initialOnAnimateEnd,
delay,
completeOnStop,
className,
...props
}: IconWrapperProps<T>) {
const context = React.useContext(AnimateIconContext);
if (context) {
const {
controls,
animation: parentAnimation,
loop: parentLoop,
loopDelay: parentLoopDelay,
active: parentActive,
animate: parentAnimate,
persistOnAnimateEnd: parentPersistOnAnimateEnd,
initialOnAnimateEnd: parentInitialOnAnimateEnd,
delay: parentDelay,
completeOnStop: parentCompleteOnStop,
} = context;
const hasOverrides =
animate !== undefined ||
animateOnHover !== undefined ||
animateOnTap !== undefined ||
animateOnView !== undefined ||
loop !== undefined ||
loopDelay !== undefined ||
initialOnAnimateEnd !== undefined ||
persistOnAnimateEnd !== undefined ||
delay !== undefined ||
completeOnStop !== undefined;
if (hasOverrides) {
const inheritedAnimate: Trigger = parentActive
? (animationProp ?? parentAnimation ?? 'default')
: false;
const finalAnimate: Trigger = (animate ??
parentAnimate ??
inheritedAnimate) as Trigger;
return (
<AnimateIcon
animate={finalAnimate}
animateOnHover={animateOnHover}
animateOnTap={animateOnTap}
animateOnView={animateOnView}
animateOnViewMargin={animateOnViewMargin}
animateOnViewOnce={animateOnViewOnce}
animation={animationProp ?? parentAnimation}
loop={loop ?? parentLoop}
loopDelay={loopDelay ?? parentLoopDelay}
persistOnAnimateEnd={persistOnAnimateEnd ?? parentPersistOnAnimateEnd}
initialOnAnimateEnd={initialOnAnimateEnd ?? parentInitialOnAnimateEnd}
delay={delay ?? parentDelay}
completeOnStop={completeOnStop ?? parentCompleteOnStop}
asChild
>
<IconComponent
size={size}
className={cn(
className,
((animationProp ?? parentAnimation) === 'path' ||
(animationProp ?? parentAnimation) === 'path-loop') &&
pathClassName,
)}
{...props}
/>
</AnimateIcon>
);
}
const animationToUse = animationProp ?? parentAnimation;
const loopToUse = parentLoop;
const loopDelayToUse = parentLoopDelay;
return (
<AnimateIconContext.Provider
value={{
controls,
animation: animationToUse,
loop: loopToUse,
loopDelay: loopDelayToUse,
active: parentActive,
animate: parentAnimate,
initialOnAnimateEnd: parentInitialOnAnimateEnd,
delay: parentDelay,
completeOnStop: parentCompleteOnStop,
}}
>
<IconComponent
size={size}
className={cn(
className,
(animationToUse === 'path' || animationToUse === 'path-loop') &&
pathClassName,
)}
{...props}
/>
</AnimateIconContext.Provider>
);
}
if (
animate !== undefined ||
animateOnHover !== undefined ||
animateOnTap !== undefined ||
animateOnView !== undefined ||
animationProp !== undefined
) {
return (
<AnimateIcon
animate={animate}
animateOnHover={animateOnHover}
animateOnTap={animateOnTap}
animateOnView={animateOnView}
animateOnViewMargin={animateOnViewMargin}
animateOnViewOnce={animateOnViewOnce}
animation={animationProp}
loop={loop}
loopDelay={loopDelay}
delay={delay}
completeOnStop={completeOnStop}
asChild
>
<IconComponent
size={size}
className={cn(
className,
(animationProp === 'path' || animationProp === 'path-loop') &&
pathClassName,
)}
{...props}
/>
</AnimateIcon>
);
}
return (
<IconComponent
size={size}
className={cn(
className,
(animationProp === 'path' || animationProp === 'path-loop') &&
pathClassName,
)}
{...props}
/>
);
}
function getVariants<
V extends { default: T; [key: string]: T },
T extends Record<string, Variants>,
>(animations: V): T {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { animation: animationType } = useAnimateIconContext();
let result: T;
if (animationType in staticAnimations) {
const variant = staticAnimations[animationType as StaticAnimations];
result = {} as T;
for (const key in animations.default) {
if (
(animationType === 'path' || animationType === 'path-loop') &&
key.includes('group')
)
continue;
result[key] = variant as T[Extract<keyof T, string>];
}
} else {
result = (animations[animationType as keyof V] as T) ?? animations.default;
}
return result;
}
export {
pathClassName,
staticAnimations,
AnimateIcon,
IconWrapper,
useAnimateIconContext,
getVariants,
type IconProps,
type IconWrapperProps,
type AnimateIconProps,
type AnimateIconContextValue,
};
@@ -0,0 +1,92 @@
'use client';
import * as React from 'react';
import { motion, type Variants } from 'motion/react';
import {
getVariants,
useAnimateIconContext,
IconWrapper,
type IconProps,
} from '@/components/animate-ui/icons/icon';
type SendProps = IconProps<keyof typeof animations>;
const animations = {
default: {
group: {
initial: {
scale: 1,
x: 0,
y: 0,
},
animate: {
scale: [1, 0.8, 1, 1, 1],
x: [0, '-10%', '100%', '-125%', 0],
y: [0, '10%', '-100%', '125%', 0],
transition: {
default: { ease: 'easeInOut', duration: 1.2 },
x: {
ease: 'easeInOut',
duration: 1.2,
times: [0, 0.25, 0.5, 0.5, 1],
},
y: {
ease: 'easeInOut',
duration: 1.2,
times: [0, 0.25, 0.5, 0.5, 1],
},
},
},
},
path1: {},
path2: {},
} satisfies Record<string, Variants>,
} as const;
function IconComponent({ size, ...props }: SendProps) {
const { controls } = useAnimateIconContext();
const variants = getVariants(animations);
return (
<motion.svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<motion.g variants={variants.group} initial="initial" animate={controls}>
<motion.path
d="M14.5,21.7c.1.3.4.4.7.3.1,0,.2-.2.3-.3L22,2.7c0-.3,0-.5-.3-.6-.1,0-.2,0-.3,0L2.3,8.5c-.3,0-.4.4-.3.6,0,.1.2.2.3.3l7.9,3.2c.5.2.9.6,1.1,1.1l3.2,7.9Z"
variants={variants.path1}
initial="initial"
animate={controls}
/>
<motion.path
d="M21.9,2.1l-10.9,10.9"
variants={variants.path2}
initial="initial"
animate={controls}
/>
</motion.g>
</motion.svg>
);
}
function Send(props: SendProps) {
return <IconWrapper icon={IconComponent} {...props} />;
}
export {
animations,
Send,
Send as SendIcon,
type SendProps,
type SendProps as SendIconProps,
};
@@ -0,0 +1,96 @@
'use client';
import * as React from 'react';
import { motion, isMotionComponent, type HTMLMotionProps } from 'motion/react';
import { cn } from '@/lib/utils';
type AnyProps = Record<string, unknown>;
type DOMMotionProps<T extends HTMLElement = HTMLElement> = Omit<
HTMLMotionProps<keyof HTMLElementTagNameMap>,
'ref'
> & { ref?: React.Ref<T> };
type WithAsChild<Base extends object> =
| (Base & { asChild: true; children: React.ReactElement })
| (Base & { asChild?: false | undefined });
type SlotProps<T extends HTMLElement = HTMLElement> = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
children?: any;
} & DOMMotionProps<T>;
function mergeRefs<T>(
...refs: (React.Ref<T> | undefined)[]
): React.RefCallback<T> {
return (node) => {
refs.forEach((ref) => {
if (!ref) return;
if (typeof ref === 'function') {
ref(node);
} else {
(ref as React.RefObject<T | null>).current = node;
}
});
};
}
function mergeProps<T extends HTMLElement>(
childProps: AnyProps,
slotProps: DOMMotionProps<T>,
): AnyProps {
const merged: AnyProps = { ...childProps, ...slotProps };
if (childProps.className || slotProps.className) {
merged.className = cn(
childProps.className as string,
slotProps.className as string,
);
}
if (childProps.style || slotProps.style) {
merged.style = {
...(childProps.style as React.CSSProperties),
...(slotProps.style as React.CSSProperties),
};
}
return merged;
}
function Slot<T extends HTMLElement = HTMLElement>({
children,
ref,
...props
}: SlotProps<T>) {
const isAlreadyMotion =
typeof children.type === 'object' &&
children.type !== null &&
isMotionComponent(children.type);
const Base = React.useMemo(
() =>
isAlreadyMotion
? (children.type as React.ElementType)
: motion.create(children.type as React.ElementType),
[isAlreadyMotion, children.type],
);
if (!React.isValidElement(children)) return null;
const { ref: childRef, ...childProps } = children.props as AnyProps;
const mergedProps = mergeProps(childProps, props);
return (
<Base {...mergedProps} ref={mergeRefs(childRef as React.Ref<T>, ref)} />
);
}
export {
Slot,
type SlotProps,
type WithAsChild,
type DOMMotionProps,
type AnyProps,
};
@@ -0,0 +1,32 @@
'use client';
import * as React from 'react';
import { motion, type HTMLMotionProps } from 'motion/react';
import { Slot, type WithAsChild } from '@/components/animate-ui/primitives/animate/slot';
type ButtonProps = WithAsChild<
HTMLMotionProps<'button'> & {
hoverScale?: number;
tapScale?: number;
}
>;
function Button({
hoverScale = 1.05,
tapScale = 0.95,
asChild = false,
...props
}: ButtonProps) {
const Component = asChild ? Slot : motion.button;
return (
<Component
whileTap={{ scale: tapScale }}
whileHover={{ scale: hoverScale }}
{...props}
/>
);
}
export { Button, type ButtonProps };
@@ -0,0 +1,144 @@
'use client';
import * as React from 'react';
import { motion, type HTMLMotionProps } from 'motion/react';
import { getStrictContext } from '@/lib/utils/get-strict-context';
import { Slot, type WithAsChild } from '@/components/animate-ui/primitives/animate/slot';
type Ripple = {
id: number;
x: number;
y: number;
};
type RippleButtonContextType = {
ripples: Ripple[];
setRipples: (ripples: Ripple[]) => void;
};
const [RippleButtonProvider, useRippleButton] =
getStrictContext<RippleButtonContextType>('RippleButtonContext');
type RippleButtonProps = WithAsChild<
HTMLMotionProps<'button'> & {
hoverScale?: number;
tapScale?: number;
}
>;
function RippleButton({
ref,
onClick,
hoverScale = 1,
tapScale = 0.95,
asChild = false,
style,
...props
}: RippleButtonProps) {
const [ripples, setRipples] = React.useState<Ripple[]>([]);
const buttonRef = React.useRef<HTMLButtonElement>(null);
React.useImperativeHandle(ref, () => buttonRef.current as HTMLButtonElement);
const createRipple = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
const button = buttonRef.current;
if (!button) return;
const rect = button.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const newRipple: Ripple = {
id: Date.now(),
x,
y,
};
setRipples((prev) => [...prev, newRipple]);
setTimeout(() => {
setRipples((prev) => prev.filter((r) => r.id !== newRipple.id));
}, 600);
},
[],
);
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
createRipple(event);
if (onClick) {
onClick(event);
}
},
[createRipple, onClick],
);
const Component = asChild ? Slot : motion.button;
return (
<RippleButtonProvider value={{ ripples, setRipples }}>
<Component
ref={buttonRef}
data-slot="ripple-button"
onClick={handleClick}
whileTap={{ scale: tapScale }}
whileHover={{ scale: hoverScale }}
style={{
position: 'relative',
overflow: 'hidden',
...style,
}}
{...props}
/>
</RippleButtonProvider>
);
}
type RippleButtonRipplesProps = WithAsChild<
HTMLMotionProps<'span'> & {
color?: string;
scale?: number;
}
>;
function RippleButtonRipples({
color = 'var(--ripple-button-ripple-color)',
scale = 10,
transition = { duration: 0.6, ease: 'easeOut' },
asChild = false,
style,
...props
}: RippleButtonRipplesProps) {
const { ripples } = useRippleButton();
const Component = asChild ? Slot : motion.span;
return ripples.map((ripple) => (
<Component
key={ripple.id}
initial={{ scale: 0, opacity: 0.5 }}
animate={{ scale, opacity: 0 }}
transition={transition}
style={{
position: 'absolute',
borderRadius: '50%',
pointerEvents: 'none',
width: '20px',
height: '20px',
backgroundColor: color,
top: ripple.y - 10,
left: ripple.x - 10,
...style,
}}
{...props}
/>
));
}
export {
RippleButton,
RippleButtonRipples,
type RippleButtonProps,
type RippleButtonRipplesProps,
};
@@ -0,0 +1,110 @@
'use client';
import * as React from 'react';
import { Collapsible as CollapsiblePrimitive } from 'radix-ui';
import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
import { getStrictContext } from '@/lib/utils/get-strict-context';
import { useControlledState } from '@/hooks/use-controlled-state';
type CollapsibleContextType = {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
};
const [CollapsibleProvider, useCollapsible] =
getStrictContext<CollapsibleContextType>('CollapsibleContext');
type CollapsibleProps = React.ComponentProps<typeof CollapsiblePrimitive.Root>;
function Collapsible(props: CollapsibleProps) {
const [isOpen, setIsOpen] = useControlledState({
value: props?.open,
defaultValue: props?.defaultOpen,
onChange: props?.onOpenChange,
});
return (
<CollapsibleProvider value={{ isOpen, setIsOpen }}>
<CollapsiblePrimitive.Root
data-slot="collapsible"
{...props}
onOpenChange={setIsOpen}
/>
</CollapsibleProvider>
);
}
type CollapsibleTriggerProps = React.ComponentProps<
typeof CollapsiblePrimitive.Trigger
>;
function CollapsibleTrigger(props: CollapsibleTriggerProps) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
);
}
type CollapsibleContentProps = Omit<
React.ComponentProps<typeof CollapsiblePrimitive.Content>,
'asChild' | 'forceMount'
> &
HTMLMotionProps<'div'> & {
keepRendered?: boolean;
};
function CollapsibleContent({
keepRendered = false,
transition = { duration: 0.35, ease: 'easeInOut' },
...props
}: CollapsibleContentProps) {
const { isOpen } = useCollapsible();
return (
<AnimatePresence>
{keepRendered ? (
<CollapsiblePrimitive.Content asChild forceMount>
<motion.div
key="collapsible-content"
data-slot="collapsible-content"
layout
initial={{ opacity: 0, height: 0, overflow: 'hidden', y: 20 }}
animate={
isOpen
? { opacity: 1, height: 'auto', overflow: 'hidden', y: 0 }
: { opacity: 0, height: 0, overflow: 'hidden', y: 20 }
}
transition={transition}
{...props}
/>
</CollapsiblePrimitive.Content>
) : (
isOpen && (
<CollapsiblePrimitive.Content asChild forceMount>
<motion.div
key="collapsible-content"
data-slot="collapsible-content"
layout
initial={{ opacity: 0, height: 0, overflow: 'hidden', y: 20 }}
animate={{ opacity: 1, height: 'auto', overflow: 'hidden', y: 0 }}
exit={{ opacity: 0, height: 0, overflow: 'hidden', y: 20 }}
transition={transition}
{...props}
/>
</CollapsiblePrimitive.Content>
)
)}
</AnimatePresence>
);
}
export {
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
useCollapsible,
type CollapsibleProps,
type CollapsibleTriggerProps,
type CollapsibleContentProps,
type CollapsibleContextType,
};
@@ -0,0 +1,136 @@
'use client';
import * as React from 'react';
import { useMotionValue, useSpring, type SpringOptions } from 'motion/react';
import {
useIsInView,
type UseIsInViewOptions,
} from '@/hooks/use-is-in-view';
type CountingNumberProps = Omit<React.ComponentProps<'span'>, 'children'> & {
number: number;
fromNumber?: number;
padStart?: boolean;
decimalSeparator?: string;
decimalPlaces?: number;
transition?: SpringOptions;
delay?: number;
initiallyStable?: boolean;
thousandSeparator?: string;
} & UseIsInViewOptions;
function CountingNumber({
ref,
number,
fromNumber = 0,
padStart = false,
inView = false,
inViewMargin = '0px',
inViewOnce = true,
decimalSeparator = '.',
transition = { stiffness: 200, damping: 30 },
decimalPlaces = 0,
delay = 0,
initiallyStable = false,
thousandSeparator = '',
...props
}: CountingNumberProps) {
const { ref: localRef, isInView } = useIsInView(
ref as React.Ref<HTMLElement>,
{
inView,
inViewOnce,
inViewMargin,
},
);
const numberStr = number.toString();
const decimals =
typeof decimalPlaces === 'number'
? decimalPlaces
: numberStr.includes('.')
? (numberStr.split('.')[1]?.length ?? 0)
: 0;
const motionVal = useMotionValue(initiallyStable ? number : fromNumber);
const springVal = useSpring(motionVal, transition);
React.useEffect(() => {
const timeoutId = setTimeout(() => {
if (isInView) motionVal.set(number);
}, delay);
return () => clearTimeout(timeoutId);
}, [isInView, number, motionVal, delay]);
React.useEffect(() => {
const unsubscribe = springVal.on('change', (latest) => {
if (localRef.current) {
let formatted =
decimals > 0
? latest.toFixed(decimals)
: Math.round(latest).toString();
if (decimals > 0) {
formatted = formatted.replace('.', decimalSeparator);
}
if (padStart) {
const finalIntLength = Math.floor(Math.abs(number)).toString().length;
const [intPart, fracPart] = formatted.split(decimalSeparator);
const paddedInt = intPart?.padStart(finalIntLength, '0') ?? '';
formatted = fracPart
? `${ paddedInt }${ decimalSeparator }${ fracPart }`
: paddedInt;
}
if (thousandSeparator) {
const [intPart, fracPart] = formatted.split(decimalSeparator);
const regex = /\B(?=(\d{3})+(?!\d))/g;
const separatedInt = intPart.replace(regex, thousandSeparator);
formatted = fracPart
? `${ separatedInt }${ decimalSeparator }${ fracPart }`
: separatedInt;
}
localRef.current.textContent = formatted;
}
});
return () => unsubscribe();
}, [springVal, decimals, padStart, number, decimalSeparator, localRef, thousandSeparator]);
const finalIntLength = Math.floor(Math.abs(number)).toString().length;
const formatValue = (val: number) => {
let out = decimals > 0 ? val.toFixed(decimals) : Math.round(val).toString();
if (decimals > 0) out = out.replace('.', decimalSeparator);
if (padStart) {
const [intPart, fracPart] = out.split(decimalSeparator);
const paddedInt = (intPart ?? '').padStart(finalIntLength, '0');
out = fracPart ? `${ paddedInt }${ decimalSeparator }${ fracPart }` : paddedInt;
}
if (thousandSeparator) {
const [intPart, fracPart] = out.split(decimalSeparator);
const regex = /\B(?=(\d{3})+(?!\d))/g;
const separatedInt = intPart.replace(regex, thousandSeparator);
out = fracPart ? `${ separatedInt }${ decimalSeparator }${ fracPart }` : separatedInt;
}
return out;
};
const zeroText = padStart
? '0'.padStart(finalIntLength, '0') +
(decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : '')
: '0' + (decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : '');
const initialText = initiallyStable ? formatValue(number) : zeroText;
return (
<span ref={localRef} data-slot="counting-number" {...props}>
{initialText}
</span>
);
}
export { CountingNumber, type CountingNumberProps };
+182
View File
@@ -0,0 +1,182 @@
"use client"
import { useState, useEffect } from "react"
import { toast } from "sonner"
import { Spinner } from "@/components/ui/spinner"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { motion, useAnimation } from "motion/react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion"
import { SquareArrowUpRight } from 'lucide-react';
import { cn } from "@/lib/utils"
import services from "@/lib/services"
import { termsSections } from "@/components/common/docs/terms"
import { privacySections } from "@/components/common/docs/privacy"
/**
* 登录表单组件
* 显示登录表单和登录按钮
*
* @example
* ```tsx
* <LoginForm />
* ```
* @param {React.ComponentProps<"div">} props - 组件属性
* @param {string} className - 组件类名
* @returns {React.ReactNode} 登录表单组件
*/
export function LoginForm({
className,
...props
}: React.ComponentProps<"div">) {
const [isLoading, setIsLoading] = useState(false)
const [hasAgreed, setHasAgreed] = useState(false)
const controls = useAnimation()
useEffect(() => {
const agreed = localStorage.getItem("loginPromptAgreed") === "true"
if (agreed) {
setHasAgreed(true)
}
}, [])
const handleAgreementChange = (checked: boolean | string) => {
const isChecked = checked === true
setHasAgreed(isChecked)
if (isChecked) {
localStorage.setItem("loginPromptAgreed", "true")
} else {
localStorage.removeItem("loginPromptAgreed")
}
}
/* 处理登录 */
const handleLogin = async () => {
if (!hasAgreed) {
toast.error("请先阅读并勾选服务条款和隐私政策")
controls.start({
x: [0, -4, 4, -4, 4, 0],
color: ["#ef4444", "inherit"],
transition: { duration: 0.5 }
})
return
}
setIsLoading(true)
try {
await services.auth.initiateLogin()
} catch (error) {
setIsLoading(false)
console.error('Login error:', error)
const message = error instanceof Error ? error.message : "登录失败,请重试"
toast.error(message, {
duration: 5000,
description: error instanceof Error && error.name === 'NetworkError'
? '请确认后端服务已启动'
: undefined
})
}
}
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<div className="grid gap-4 mx-4">
<Button
variant="default"
type="button"
className="w-full h-9 rounded-full tracking-wide bg-primary hover:bg-primary/90 text-primary-foreground text-sm font-bold shadow-lg shadow-primary/20 hover:shadow-primary/30 transition-all active:scale-95"
onClick={handleLogin}
disabled={isLoading}
>
{isLoading ? <Spinner className="mr-2" /> : <SquareArrowUpRight className="mr-2 h-4 w-4" />}
{isLoading ? "正在跳转..." : "使用 LINUX DO 登录"}
</Button>
</div>
<motion.div
animate={controls}
className="flex items-center justify-center space-x-2 px-4"
>
<Checkbox
id="terms"
checked={hasAgreed}
onCheckedChange={handleAgreementChange}
/>
<label
htmlFor="terms"
className="text-muted-foreground text-xs text-balance opacity-75 hover:opacity-100 transition-opacity cursor-pointer leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
我已阅读并同意
{" "}
<Dialog>
<DialogTrigger asChild>
<button
type="button"
className="underline underline-offset-4 hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
服务条款
</button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>服务条款</DialogTitle>
<DialogDescription>请仔细阅读以下条款,使用本服务即表示您接受。</DialogDescription>
</DialogHeader>
<Accordion type="single" collapsible className="w-full">
{termsSections.map((section) => (
<AccordionItem key={section.value} value={section.value}>
<AccordionTrigger>{section.title}</AccordionTrigger>
<AccordionContent>{section.content}</AccordionContent>
</AccordionItem>
))}
</Accordion>
</DialogContent>
</Dialog>
{" "}及{" "}
<Dialog>
<DialogTrigger asChild>
<button
type="button"
className="underline underline-offset-4 hover:text-foreground"
onClick={(e) => e.stopPropagation()}
>
隐私政策
</button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>隐私政策</DialogTitle>
<DialogDescription>我们重视您的隐私,以下说明信息如何收集与使用。</DialogDescription>
</DialogHeader>
<Accordion type="single" collapsible className="w-full">
{privacySections.map((section) => (
<AccordionItem key={section.value} value={section.value}>
<AccordionTrigger>{section.title}</AccordionTrigger>
<AccordionContent>{section.content}</AccordionContent>
</AccordionItem>
))}
</Accordion>
</DialogContent>
</Dialog>
</label>
</motion.div>
</div>
)
}
+390
View File
@@ -0,0 +1,390 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { motion, AnimatePresence } from "motion/react"
import { useRouter, useSearchParams } from "next/navigation"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { LoginForm } from "@/components/auth/login-form"
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"
import { Check } from "lucide-react"
import { AuroraBackground } from "@/components/ui/aurora-background"
import services from "@/lib/services"
import type { ApiResponse } from "@/lib/services/core/types"
/**
* 登录页面组件
* 显示登录表单和登录按钮
*
* @example
* ```tsx
* <LoginPage />
* ```
* @returns {React.ReactNode} 登录页面组件
*/
export function LoginPage() {
const router = useRouter()
const searchParams = useSearchParams()
/* 处理OAuth回调 */
const [isProcessingCallback, setIsProcessingCallback] = useState(() => {
const state = searchParams.get('state')
const code = searchParams.get('code')
return !!(state && code)
})
const [isCheckingSession, setIsCheckingSession] = useState(() => !searchParams.get('state') || !searchParams.get('code'))
const [loginSuccess, setLoginSuccess] = useState(false)
const [needsPayKeySetup, setNeedsPayKeySetup] = useState(false)
const [payKey, setPayKey] = useState("")
const [confirmPayKey, setConfirmPayKey] = useState("")
const [isSubmittingPayKey, setIsSubmittingPayKey] = useState(false)
const [setupStep, setSetupStep] = useState<'password' | 'confirm'>('password')
const isPayKeyValid = payKey.length === 6 && /^\d{6}$/.test(payKey)
const isConfirmValid = confirmPayKey.length === 6 && /^\d{6}$/.test(confirmPayKey)
const passwordsMatch = payKey === confirmPayKey
const resolveRedirectTarget = useCallback(() => {
const callbackUrl = searchParams.get('callbackUrl')
const storedRedirect = sessionStorage.getItem('redirect_after_login')
const target = callbackUrl || storedRedirect || '/home'
if (storedRedirect) {
sessionStorage.removeItem('redirect_after_login')
}
return target
}, [searchParams])
/* 安全密码输入 */
const handlePayKeyChange = (value: string) => {
const numericValue = value.replace(/\D/g, '')
setPayKey(numericValue)
}
/* 确认安全密码 */
const handleConfirmPayKeyChange = (value: string) => {
const numericValue = value.replace(/\D/g, '')
setConfirmPayKey(numericValue)
}
/* 登录页兜底:已登录用户直接跳转 */
useEffect(() => {
const state = searchParams.get('state')
const code = searchParams.get('code')
if (state && code) {
setIsCheckingSession(false)
return
}
let cancelled = false
const checkExistingSession = async () => {
setIsCheckingSession(true)
try {
const response = await fetch('/api/v1/oauth/user-info', {
credentials: 'include',
cache: 'no-store',
})
if (cancelled) return
if (response.ok) {
await response.json() as ApiResponse
router.replace(resolveRedirectTarget())
return
}
if (response.status !== 401) {
console.error('Session probe failed:', response.status)
}
} catch (error) {
if (!cancelled) {
console.error('Session probe error:', error)
}
} finally {
if (!cancelled) {
setIsCheckingSession(false)
}
}
}
checkExistingSession()
return () => {
cancelled = true
}
}, [router, searchParams, resolveRedirectTarget])
/* 回调逻辑 */
useEffect(() => {
const handleOAuthCallback = async () => {
const state = searchParams.get('state')
const code = searchParams.get('code')
if (state && code) {
setIsProcessingCallback(true)
try {
await services.auth.handleCallback({ state, code })
const user = await services.auth.getUserInfo()
if (!user.is_pay_key) {
setNeedsPayKeySetup(true)
} else {
setLoginSuccess(true)
toast.success("登录成功")
setTimeout(() => {
router.replace(resolveRedirectTarget())
}, 1500)
}
} catch (error) {
console.error('OAuth callback error:', error)
toast.error(error instanceof Error ? error.message : "登录失败,请重试")
setIsProcessingCallback(false)
router.replace('/login')
}
}
}
handleOAuthCallback()
}, [searchParams, router, resolveRedirectTarget])
/* 安全密码设置 */
const handlePayKeySubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (setupStep === 'password') {
if (!isPayKeyValid) {
toast.error("安全密码必须为6位数字")
return
}
setSetupStep('confirm')
} else {
if (!isConfirmValid) {
toast.error("确认密码必须为6位数字")
return
}
if (!passwordsMatch) {
toast.error("两次输入的安全密码不一致")
setSetupStep('password')
setConfirmPayKey("")
return
}
setIsSubmittingPayKey(true)
try {
await services.user.updatePayKey(payKey)
toast.success("安全密码设置成功")
setNeedsPayKeySetup(false)
setLoginSuccess(true)
setPayKey("")
setConfirmPayKey("")
setSetupStep('password')
setTimeout(() => {
router.replace(resolveRedirectTarget())
}, 1500)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "设置安全密码失败"
toast.error(errorMessage)
setSetupStep('password')
setConfirmPayKey("")
} finally {
setIsSubmittingPayKey(false)
}
}
}
/* 渲染 PayKey 设置界面 */
const renderPayKeySetup = (key: string) => (
<motion.div
key={key}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="w-full space-y-8"
>
<div className="flex flex-col items-center gap-2 mb-4">
<h3 className="text-base font-bold tracking-tight text-center">
{setupStep === 'password' ? '设置安全密码' : '确认安全密码'}
</h3>
<p className="text-xs text-muted-foreground text-center max-w-[320px] mx-auto">
{setupStep === 'password'
? '请设置6位数字安全密码,用于安全操作'
: '请再次输入密码进行确认'}
</p>
</div>
<form onSubmit={handlePayKeySubmit} className="space-y-6">
<div className="flex justify-center">
{setupStep === 'password' ? (
<InputOTP
maxLength={6}
value={payKey}
onChange={handlePayKeyChange}
disabled={isSubmittingPayKey}
autoFocus
>
<InputOTPGroup className="gap-2">
{[0, 1, 2, 3, 4, 5].map((i) => (
<InputOTPSlot key={i} index={i} className="w-9 h-9 border-input" />
))}
</InputOTPGroup>
</InputOTP>
) : (
<div className="space-y-4">
<InputOTP
maxLength={6}
value={confirmPayKey}
onChange={handleConfirmPayKeyChange}
disabled={isSubmittingPayKey}
autoFocus
>
<InputOTPGroup className="gap-2">
{[0, 1, 2, 3, 4, 5].map((i) => (
<InputOTPSlot key={i} index={i} className="w-9 h-9 text-lg border-input" />
))}
</InputOTPGroup>
</InputOTP>
{!passwordsMatch && confirmPayKey.length === 6 && (
<motion.p
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="text-xs text-red-500 text-center font-medium"
>
两次输入的密码不一致
</motion.p>
)}
</div>
)}
</div>
<div className="flex gap-4 mx-12">
{setupStep === 'confirm' && (
<Button
type="button"
variant="secondary"
onClick={() => {
setSetupStep('password')
setConfirmPayKey('')
}}
className="flex-1 h-9 rounded-full tracking-wide text-sm font-bold transition-all active:scale-95"
>
返回
</Button>
)}
<Button
type="submit"
className="flex-1 h-9 rounded-full tracking-wide bg-primary hover:bg-primary/90 text-primary-foreground text-sm font-bold shadow-lg shadow-primary/20 hover:shadow-primary/30 transition-all active:scale-95"
disabled={
setupStep === 'password'
? !isPayKeyValid
: isSubmittingPayKey || !isConfirmValid
}
>
{isSubmittingPayKey && <Spinner className="mr-1" />}
{setupStep === 'password' ? '继续' : '完成'}
</Button>
</div>
</form>
</motion.div>
)
return (
<AuroraBackground>
<motion.div
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{
delay: 0.3,
duration: 0.8,
ease: "easeInOut",
}}
className="relative z-10 w-full max-w-sm px-4"
>
<div className="text-center mb-8 space-y-2">
<h1 className="text-3xl font-bold tracking-tight text-foreground">
LINUX DO <span className="font-serif italic text-primary">Credit</span>
</h1>
<p className="text-sm text-muted-foreground font-light">
简单、安全,专为社区设计
</p>
</div>
<AnimatePresence mode="wait">
{isProcessingCallback || isCheckingSession ? (
<motion.div
key={isProcessingCallback ? "processing" : "session-check"}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="w-full"
>
{isCheckingSession ? (
<div className="flex flex-col items-center justify-center space-y-4 py-2">
<div className="relative">
<Spinner className="w-8 h-8 text-blue-600" />
</div>
<div className="text-center space-y-2">
<h3 className="font-semibold tracking-tight text-foreground">正在检查登录状态</h3>
<p className="text-xs text-muted-foreground">请稍候,我们正在确认当前会话...</p>
</div>
</div>
) : needsPayKeySetup ? (
renderPayKeySetup("oauth-pay-key-setup")
) : loginSuccess ? (
<div className="flex flex-col items-center justify-center space-y-4 py-2">
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
className="w-8 h-8 rounded-full bg-green-500/10 flex items-center justify-center text-green-500 ring-1 ring-green-500/20"
>
<Check className="w-6 h-6" strokeWidth={3} />
</motion.div>
<div className="text-center space-y-2">
<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 space-y-4 py-2">
<div className="relative">
<Spinner className="w-8 h-8 text-blue-600" />
</div>
<div className="text-center space-y-2">
<h3 className="font-semibold tracking-tight text-foreground">正在验证凭据</h3>
<p className="text-xs text-muted-foreground">请稍候,我们正在为您建立安全会话...</p>
</div>
</div>
)}
</motion.div>
) : (
<motion.div
key="login-form-wrapper"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
className="w-full"
>
<LoginForm />
</motion.div>
)}
</AnimatePresence>
<div className="mt-8 text-center text-xs text-muted-foreground">
&copy; {new Date().getFullYear()} LINUX DO Credit. 版权所有
</div>
</motion.div>
</AuroraBackground>
)
}
+378
View File
@@ -0,0 +1,378 @@
"use client"
import * as React from "react"
import { ManagePage, ManageDetailPanel } from "@/components/common/general/manage-pannel"
import { Input } from "@/components/ui/input"
import { formatDateTime } from "@/lib/utils"
import type { UserPayConfig } from "@/lib/services"
import { useAdmin } from "@/contexts/admin-context"
/**
* 积分配置详情面板组件
* 显示积分配置的详细信息和编辑面板
*
* @example
* ```tsx
* <PayConfigDetailPanel
* config={config}
* editData={editData}
* onEditDataChange={onEditDataChange}
* onSave={onSave}
* saving={saving}
* />
* ```
* @param {UserPayConfig} config - 积分配置
* @param {Partial<UserPayConfig>} editData - 编辑数据
* @param {function} onEditDataChange - 编辑数据改变回调
* @param {function} onSave - 保存回调
* @param {boolean} saving - 是否正在保存
* @returns {React.ReactNode} 积分配置详情面板组件
*/
function PayConfigDetailPanel({
config,
editData,
onEditDataChange,
onSave,
saving
}: {
config: UserPayConfig | null
editData: Partial<UserPayConfig>
onEditDataChange: (field: keyof UserPayConfig, value: UserPayConfig[keyof UserPayConfig]) => void
onSave: () => void
saving: boolean
}) {
return (
<ManageDetailPanel
isEmpty={!config}
onSave={onSave}
saving={saving}
>
<div className="grid grid-cols-2 gap-0">
<div className="border border-dashed rounded-l-lg">
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">配置等级</label>
<p className="text-xs text-muted-foreground">Level {config?.level}</p>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">配置ID</label>
<p className="text-xs text-muted-foreground">{config?.id}</p>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">创建时间</label>
<p className="text-xs text-muted-foreground">{config ? formatDateTime(config.created_at) : ''}</p>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed">
<label className="text-xs font-medium text-muted-foreground">更新时间</label>
<p className="text-xs text-muted-foreground">{config ? formatDateTime(config.updated_at) : ''}</p>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0" aria-hidden="true">
<span className="text-xs text-muted-foreground">&nbsp;</span>
<span className="text-xs text-muted-foreground">&nbsp;</span>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0" aria-hidden="true">
<span className="text-xs text-muted-foreground">&nbsp;</span>
<span className="text-xs text-muted-foreground">&nbsp;</span>
</div>
</div>
<div className="border border-dashed rounded-r-lg border-l-0">
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">最低分数</label>
<div className="flex items-center gap-1">
<Input
type="number"
step="1"
min="0"
value={editData.min_score !== undefined ? editData.min_score.toString() : (config?.min_score?.toString() || '')}
placeholder={editData.min_score === undefined && !config?.min_score ? '必需' : ''}
onChange={(e) => {
const value = e.target.value
if (value === '') {
onEditDataChange('min_score', 0)
return
}
const numValue = parseInt(value)
if (isNaN(numValue)) {
return
}
if (numValue >= 0) {
onEditDataChange('min_score', numValue)
}
}}
className="text-xs text-right h-4 w-16 px-0 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]"
/>
<p className="text-xs text-muted-foreground">LDC</p>
</div>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">最高分数</label>
<div className="flex items-center gap-1">
<Input
type="number"
step="1"
min="0"
value={editData.max_score !== undefined ? (editData.max_score?.toString() || '') : (config?.max_score?.toString() || '')}
placeholder={(editData.max_score === null || editData.max_score === undefined) && (config?.max_score === null || config?.max_score === undefined) ? '无限制' : ''}
onChange={(e) => {
const value = e.target.value
if (value === '') {
onEditDataChange('max_score', null)
return
}
const numValue = parseInt(value)
if (isNaN(numValue)) {
return
}
if (numValue >= 0) {
onEditDataChange('max_score', numValue)
}
}}
className="text-xs text-right h-4 w-16 px-0 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]"
/>
<p className="text-xs text-muted-foreground">LDC</p>
</div>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">手续费率</label>
<div className="flex items-right gap-1">
<Input
type="number"
step="1"
min="0"
max="100"
value={
editData.fee_rate !== undefined
? (parseFloat(editData.fee_rate.toString()) * 100).toString()
: (config?.fee_rate ? (parseFloat(config.fee_rate.toString()) * 100).toString() : '')
}
placeholder={editData.fee_rate === undefined && !config?.fee_rate ? '必需' : ''}
onChange={(e) => {
const value = e.target.value
if (value === '') {
onEditDataChange('fee_rate', '0')
return
}
const numValue = parseInt(value)
if (isNaN(numValue)) {
return
}
if (numValue >= 0 && numValue <= 100) {
onEditDataChange('fee_rate', (numValue / 100).toString())
}
}}
className="text-xs text-right h-4 w-16 px-0 mr-3 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]"
/>
<p className="text-xs text-muted-foreground">%</p>
</div>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">分数转化率</label>
<div className="flex items-right gap-1">
<Input
type="number"
step="1"
min="0"
max="100"
value={
editData.score_rate !== undefined
? (parseFloat(editData.score_rate.toString()) * 100).toString()
: (config?.score_rate ? (parseFloat(config.score_rate.toString()) * 100).toString() : '')
}
placeholder={editData.score_rate === undefined && !config?.score_rate ? '必需' : ''}
onChange={(e) => {
const value = e.target.value
if (value === '') {
onEditDataChange('score_rate', '0')
return
}
const numValue = parseInt(value)
if (isNaN(numValue)) {
return
}
if (numValue >= 0 && numValue <= 100) {
onEditDataChange('score_rate', (numValue / 100).toString())
}
}}
className="text-xs text-right h-4 w-16 px-0 mr-3 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]"
/>
<p className="text-xs text-muted-foreground">%</p>
</div>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">分发费率</label>
<div className="flex items-right gap-1">
<Input
type="number"
step="1"
min="0"
max="100"
value={
editData.distribute_rate !== undefined
? (parseFloat(editData.distribute_rate.toString()) * 100).toString()
: (config?.distribute_rate ? (parseFloat(config.distribute_rate.toString()) * 100).toString() : '')
}
placeholder={editData.distribute_rate === undefined && !config?.distribute_rate ? '必需' : ''}
onChange={(e) => {
const value = e.target.value
if (value === '') {
onEditDataChange('distribute_rate', '0')
return
}
const numValue = parseInt(value)
if (isNaN(numValue)) {
return
}
if (numValue >= 0 && numValue <= 100) {
onEditDataChange('distribute_rate', (numValue / 100).toString())
}
}}
className="text-xs text-right h-4 w-16 px-0 mr-3 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]"
/>
<p className="text-xs text-muted-foreground">%</p>
</div>
</div>
<div className="px-3 py-2 flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">每日支付上限</label>
<div className="flex items-center gap-1">
<Input
type="number"
step="1"
min="0"
value={editData.daily_limit !== undefined ? (editData.daily_limit?.toString() || '') : (config?.daily_limit?.toString() || '')}
placeholder={(editData.daily_limit === null || editData.daily_limit === undefined) && (config?.daily_limit === null || config?.daily_limit === undefined) ? '无限制' : ''}
onChange={(e) => {
const value = e.target.value
if (value === '') {
onEditDataChange('daily_limit', null)
return
}
const numValue = parseInt(value)
if (isNaN(numValue)) {
return
}
if (numValue >= 0) {
onEditDataChange('daily_limit', numValue)
}
}}
className="text-xs text-right h-4 w-16 px-0 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]"
/>
<p className="text-xs text-muted-foreground">LDC</p>
</div>
</div>
</div>
</div>
</ManageDetailPanel>
)
}
/**
* 用户积分配置管理组件
*
* @example
* ```tsx
* <UserPayConfigs />
* ```
* @returns {React.ReactNode} 用户积分配置管理组件
*/
export function UserPayConfigs() {
const {
userPayConfigs: configs,
userPayConfigsLoading: loading,
userPayConfigsError: error,
refetchUserPayConfigs,
updateUserPayConfig,
deleteUserPayConfig
} = useAdmin()
const getInitialEditData = (config: UserPayConfig) => ({
min_score: config.min_score,
max_score: config.max_score,
daily_limit: config.daily_limit,
fee_rate: config.fee_rate.toString(),
score_rate: config.score_rate.toString(),
distribute_rate: config.distribute_rate.toString(),
})
const handleSave = async (config: UserPayConfig, editData: Partial<UserPayConfig>) => {
if (!config) return
await updateUserPayConfig(config.id, {
min_score: editData.min_score ?? config.min_score,
max_score: editData.max_score,
daily_limit: editData.daily_limit,
fee_rate: editData.fee_rate?.toString() ?? config.fee_rate.toString(),
score_rate: editData.score_rate?.toString() ?? config.score_rate.toString(),
distribute_rate: editData.distribute_rate?.toString() ?? config.distribute_rate.toString(),
})
await refetchUserPayConfigs()
}
const handleDelete = async (config: UserPayConfig) => {
await deleteUserPayConfig(config.id)
}
return (
<ManagePage<UserPayConfig>
title="积分配置"
data={configs}
loading={loading}
error={error}
onReload={refetchUserPayConfigs}
getInitialEditData={getInitialEditData}
onSave={handleSave}
onDelete={handleDelete}
getId={(config) => config.id}
emptyDescription="未发现积分配置"
loadingDescription="配置加载中"
columns={[
{ header: "等级", cell: (item) => <span className="font-medium">Level {item.level}</span>, width: "min-w-[80px]", align: "left" },
{ header: "最低分", cell: (item) => item.min_score, width: "min-w-[200px]", align: "left" },
{ header: "最高分", cell: (item) => item.max_score || "无限制", width: "min-w-[200px]", align: "left" },
{ header: "每日限额", cell: (item) => item.daily_limit ? `LDC ${ item.daily_limit.toLocaleString() }` : "无限制", width: "min-w-[200px]", align: "left" },
{ header: "费率", cell: (item) => `${ (Number(item.fee_rate) * 100).toFixed(2) }%`, width: "min-w-[200px]", align: "left" },
{ header: "分数转化率", cell: (item) => `${ (Number(item.score_rate) * 100).toFixed(2) }%`, width: "min-w-[200px]", align: "left" },
{ header: "分发费率", cell: (item) => `${ (Number(item.distribute_rate) * 100).toFixed(2) }%`, width: "min-w-[200px]", align: "left" },
{ header: "更新时间", cell: (item) => <span className="text-muted-foreground">{formatDateTime(item.updated_at)}</span>, width: "min-w-[200px]", align: "left" },
]}
renderDetail={({ selected, hovered, editData, onEditDataChange, onSave, saving }) => (
<PayConfigDetailPanel
config={selected || hovered}
editData={editData}
onEditDataChange={onEditDataChange}
onSave={onSave}
saving={saving}
/>
)}
/>
)
}
+190
View File
@@ -0,0 +1,190 @@
"use client"
import * as React from "react"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
import { ManagePage, ManageDetailPanel } from "@/components/common/general/manage-pannel"
import { formatDateTime } from "@/lib/utils"
import type { SystemConfig } from "@/lib/services"
import { useAdmin } from "@/contexts/admin-context"
/**
* 系统配置
* 显示系统配置的详细信息和编辑面板
*
* @example
* ```tsx
* <SystemConfigDetailPanel
* config={config}
* editData={editData}
* onEditDataChange={onEditDataChange}
* onSave={onSave}
* saving={saving}
* />
* ```
* @param {SystemConfig} config - 系统配置
* @param {Partial<SystemConfig>} editData - 编辑数据
* @param {function} onEditDataChange - 编辑数据改变回调
* @param {function} onSave - 保存回调
* @param {boolean} saving - 是否正在保存
* @returns {React.ReactNode} 系统配置详情面板组件
*/
function SystemConfigDetailPanel({
config,
editData,
onEditDataChange,
onSave,
saving
}: {
config: SystemConfig | null
editData: Partial<SystemConfig>
onEditDataChange: (field: keyof SystemConfig, value: SystemConfig[keyof SystemConfig]) => void
onSave: () => void
saving: boolean
}) {
return (
<ManageDetailPanel
isEmpty={!config}
onSave={onSave}
saving={saving}
>
<div className="grid grid-cols-1 gap-0">
<div className="border border-dashed rounded-lg">
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">配置键</label>
<p className="text-xs text-muted-foreground font-mono">{config?.key}</p>
</div>
<div className="pl-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">配置值</label>
<div className="flex gap-1 w-[90%] justify-end items-center pr-3">
{config?.key.endsWith('_enabled') ? (
<Switch
checked={editData.value !== undefined ? editData.value === 'true' : (config?.value === 'true')}
onCheckedChange={(checked) => {
onEditDataChange('value', checked ? 'true' : 'false')
}}
/>
) : (
<Input
type="number"
step="1"
min="0"
value={editData.value !== undefined ? editData.value : (config?.value || '')}
placeholder={editData.value === undefined && !config?.value ? '必需' : ''}
onChange={(e) => {
const value = e.target.value
if (value === '') {
onEditDataChange('value', '')
return
}
onEditDataChange('value', value)
}}
className="!text-[12px] text-right h-4 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:!text-[12px] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-inner-spin-button]"
style={{
MozAppearance: 'textfield'
}}
/>
)}
</div>
</div>
<div className="pl-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">配置描述</label>
<div className="flex gap-1 w-[90%]">
<Input
type="text"
value={editData.description !== undefined ? editData.description : (config?.description || '')}
placeholder="可选描述"
onChange={(e) => {
const value = e.target.value
onEditDataChange('description', value)
}}
className="!text-[12px] text-right h-4 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:!text-[12px]"
/>
</div>
</div>
<div className="px-3 py-2 flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">创建时间</label>
<p className="text-xs text-muted-foreground">{config ? formatDateTime(config.created_at) : ''}</p>
</div>
</div>
</div>
</ManageDetailPanel>
)
}
/**
* 系统配置管理组件
*
* @example
* ```tsx
* <SystemConfigs />
* ```
* @returns {React.ReactNode} 系统配置管理组件
*/
export function SystemConfigs() {
const {
systemConfigs: configs,
systemConfigsLoading: loading,
systemConfigsError: error,
refetchSystemConfigs,
updateSystemConfig,
deleteSystemConfig
} = useAdmin()
const getInitialEditData = (config: SystemConfig) => ({
value: config.value,
description: config.description
})
const handleSave = async (config: SystemConfig, editData: Partial<SystemConfig>) => {
if (!config) return
await updateSystemConfig(config.key, {
value: editData.value ?? config.value,
description: editData.description ?? config.description
})
await refetchSystemConfigs()
}
const handleDelete = async (config: SystemConfig) => {
await deleteSystemConfig(config.key)
}
return (
<ManagePage<SystemConfig>
title="系统配置"
data={configs}
loading={loading}
error={error}
onReload={refetchSystemConfigs}
getInitialEditData={getInitialEditData}
onSave={handleSave}
onDelete={handleDelete}
getId={(config) => config.key}
emptyDescription="未发现系统配置"
loadingDescription="配置加载中"
columns={[
{ header: "配置键", cell: (item) => <span className="font-mono font-medium">{item.key}</span>, width: "200px" },
{ header: "配置值", cell: (item) => <span className="truncate max-w-[120px] inline-block" title={item.value}>{item.value}</span>, width: "120px" },
{ header: "描述", cell: (item) => <span className="truncate max-w-[200px] inline-block text-muted-foreground" title={item.description}>{item.description}</span>, width: "200px" },
]}
renderDetail={({ selected, hovered, editData, onEditDataChange, onSave, saving }) => (
<SystemConfigDetailPanel
config={selected || hovered}
editData={editData}
onEditDataChange={onEditDataChange}
onSave={onSave}
saving={saving}
/>
)}
/>
)
}
+370
View File
@@ -0,0 +1,370 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Spinner } from "@/components/ui/spinner"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Layers, Play, Clock, Info } from "lucide-react"
import { TaskMeta, AdminService, DispatchTaskRequest } from "@/lib/services"
import { ErrorInline } from "@/components/layout/error"
import { LoadingStateWithBorder } from "@/components/layout/loading"
import { EmptyStateWithBorder } from "@/components/layout/empty"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { format } from "date-fns"
import { zhCN } from "date-fns/locale"
import { Calendar as CalendarIcon } from "lucide-react"
import { Calendar } from "@/components/ui/calendar"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
const TASK_CONFIGS: Record<string, { icon: React.ComponentType<{ className?: string }>, color: string, gradient: string }> = {
'order_sync': {
icon: Layers,
color: "text-blue-600 dark:text-blue-400",
gradient: "from-blue-500/10 via-blue-500/5 to-transparent border-blue-200/50 dark:border-blue-800/50 hover:border-blue-400 dark:hover:border-blue-500",
},
'user_gamification': {
icon: Play,
color: "text-amber-600 dark:text-amber-400",
gradient: "from-amber-500/10 via-amber-500/5 to-transparent border-amber-200/50 dark:border-amber-800/50 hover:border-amber-400 dark:hover:border-amber-500",
},
'dispute_auto_refund': {
icon: Clock,
color: "text-rose-600 dark:text-rose-400",
gradient: "from-rose-500/10 via-rose-500/5 to-transparent border-rose-200/50 dark:border-rose-800/50 hover:border-rose-400 dark:hover:border-rose-500",
}
}
const DEFAULT_TASK_CONFIG = {
icon: Layers,
color: "text-zinc-600 dark:text-zinc-400",
gradient: "from-zinc-500/10 via-zinc-500/5 to-transparent border-zinc-200/50 dark:border-zinc-800/50 hover:border-zinc-400 dark:hover:border-zinc-500",
}
function DatePickerWithTime({ date, setDate }: { date: Date | undefined, setDate: (date: Date | undefined) => void }) {
const timeString = date ? format(date, "HH:mm:ss") : ""
const handleDateSelect = (newDate: Date | undefined) => {
if (!newDate) {
setDate(undefined)
return
}
const updated = new Date(newDate)
if (date) {
updated.setHours(date.getHours())
updated.setMinutes(date.getMinutes())
updated.setSeconds(date.getSeconds())
} else {
updated.setHours(0, 0, 0, 0)
}
setDate(updated)
}
const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTime = e.target.value
if (!newTime) return
if (date) {
const [hours, minutes, seconds] = newTime.split(':').map(Number)
const updated = new Date(date)
updated.setHours(hours || 0)
updated.setMinutes(minutes || 0)
updated.setSeconds(seconds || 0)
setDate(updated)
} else {
const today = new Date()
const [hours, minutes, seconds] = newTime.split(':').map(Number)
today.setHours(hours || 0)
today.setMinutes(minutes || 0)
today.setSeconds(seconds || 0)
setDate(today)
}
}
return (
<div className="flex items-center gap-2">
<div className="flex-1">
<Popover>
<PopoverTrigger asChild>
<Button
variant={"outline"}
className={cn(
"w-full justify-start text-left font-normal text-xs h-8",
!date && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-1 size-3" />
{date ? format(date, "yyyy-MM-dd") : <span>选择日期</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={date}
onSelect={handleDateSelect}
locale={zhCN}
/>
</PopoverContent>
</Popover>
</div>
<div className="w-[120px]">
<Input
type="time"
step="1"
value={timeString}
onChange={handleTimeChange}
className="text-xs font-mono"
/>
</div>
</div>
)
}
export function TaskManager() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const [taskTypes, setTaskTypes] = useState<TaskMeta[]>([])
const [dispatching, setDispatching] = useState(false)
const [selectedTaskType, setSelectedTaskType] = useState<string | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [startTime, setStartTime] = useState<Date | undefined>(undefined)
const [endTime, setEndTime] = useState<Date | undefined>(undefined)
const [userId, setUserId] = useState("")
const fetchTaskTypes = useCallback(async () => {
try {
setLoading(true)
setError(null)
const data = await AdminService.getTaskTypes()
setTaskTypes(data)
} catch (err) {
setError(err instanceof Error ? err : new Error('加载任务类型失败'))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchTaskTypes()
}, [fetchTaskTypes])
const handleDispatch = async () => {
if (!selectedTaskType) return
try {
setDispatching(true)
const targetTask = taskTypes.find(t => t.type === selectedTaskType)
const params: DispatchTaskRequest = {
task_type: selectedTaskType
}
if (targetTask?.type === 'order_sync') {
if (startTime) params.start_time = startTime.toISOString()
if (endTime) params.end_time = endTime.toISOString()
}
if (targetTask?.type === 'user_gamification') {
if (userId) params.user_id = userId
}
await AdminService.dispatchTask(params)
toast.success('任务下发成功', {
description: `已成功将任务 ${ targetTask?.name || selectedTaskType } 加入队列`
})
setDialogOpen(false)
setStartTime(undefined)
setEndTime(undefined)
setUserId("")
} catch (err) {
toast.error('任务下发失败', {
description: err instanceof Error ? err.message : '未知错误'
})
} finally {
setDispatching(false)
}
}
const openDispatchDialog = (type: string) => {
setSelectedTaskType(type)
setDialogOpen(true)
}
const getSelectedTaskMeta = () => {
return taskTypes.find(t => t.type === selectedTaskType)
}
return (
<div className="py-6 space-y-6">
<div className="flex items-center justify-between border-b border-border pb-2">
<div className="flex flex-col gap-1">
<div className="text-2xl font-semibold tracking-tight">任务管理</div>
</div>
</div>
<div className="space-y-6">
{error ? (
<div className="p-8 border border-dashed rounded-xl bg-card">
<ErrorInline error={error} onRetry={fetchTaskTypes} className="justify-center" />
</div>
) : loading && taskTypes.length === 0 ? (
<LoadingStateWithBorder icon={Layers} description="加载任务类型中..." />
) : taskTypes.length === 0 ? (
<EmptyStateWithBorder icon={Layers} description="暂无可用任务类型" />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{taskTypes.map((task, index) => {
const config = TASK_CONFIGS[task.type] || DEFAULT_TASK_CONFIG
return (
<div
key={`${ task.type }-${ index }`}
className={cn(
"relative group overflow-hidden rounded-xl border bg-gradient-to-br transition-all duration-500",
config.gradient
)}
>
<div className="relative h-full bg-card/40 backdrop-blur-sm p-4 flex flex-col justify-between hover:bg-card/0 transition-colors duration-500">
<div className="space-y-2">
<div className="flex items-start justify-between">
<div className="space-y-1">
<h3 className="font-semibold text-base tracking-tight">{task.name}</h3>
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2 min-h-[36px]">
{task.description}
</p>
</div>
<Badge variant="secondary" className="font-mono text-[10px] bg-background/50 backdrop-blur-md border px-1.5 h-5">
{task.queue}
</Badge>
</div>
<div className="flex flex-wrap gap-1.5">
<Badge variant="outline" className="text-[9px] h-4.5 bg-background/50 font-mono text-muted-foreground border-border/50 px-1">
类型:{task.type}
</Badge>
<Badge variant="outline" className="text-[9px] h-4.5 bg-background/50 font-mono text-muted-foreground border-border/50 px-1">
重试:{task.max_retry}
</Badge>
</div>
</div>
<div className="pt-4 mt-1">
<Button
className="w-full h-7 text-xs"
variant="secondary"
onClick={() => openDispatchDialog(task.type)}
>
<Play className="size-3 mr-1" />
立即执行
</Button>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>下发任务</DialogTitle>
<DialogDescription>
配置任务参数并启动,任务将在后台异步执行。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>执行任务</Label>
<div className="flex items-center gap-2 p-2 rounded-lg bg-muted/50">
{(() => {
const meta = getSelectedTaskMeta()
if (!meta) return null
const config = TASK_CONFIGS[meta.type] || DEFAULT_TASK_CONFIG
const Icon = config.icon
return (
<>
<div className={cn("p-2", config.color)}>
<Icon className="size-4" />
</div>
<div className="flex flex-col">
<span className="text-xs font-medium">{meta.name}</span>
<span className="text-[10px] text-muted-foreground font-mono">{meta.type}</span>
</div>
</>
)
})()}
</div>
</div>
{selectedTaskType === 'order_sync' && (
<>
<div className="grid gap-2">
<Label>开始时间</Label>
<DatePickerWithTime date={startTime} setDate={setStartTime} />
<p className="text-xs text-muted-foreground">选择同步的起始时间点</p>
</div>
<div className="grid gap-2">
<Label>结束时间</Label>
<DatePickerWithTime date={endTime} setDate={setEndTime} />
<p className="text-xs text-muted-foreground">选择同步的结束时间点</p>
</div>
</>
)}
{selectedTaskType === 'user_gamification' && (
<div className="grid gap-2">
<Label htmlFor="user-id">用户 ID</Label>
<Input
id="user-id"
type="number"
placeholder="e.g. 10086"
className="font-mono text-xs"
value={userId}
onChange={(e) => setUserId(e.target.value)}
/>
<p className="text-xs text-muted-foreground">指定需要更新积分数据的用户 ID</p>
</div>
)}
{!['order_sync', 'user_gamification'].includes(selectedTaskType || '') && (
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 p-3 rounded-md border border-dashed">
<Info className="h-4 w-4" />
<span>此任务无需额外配置参数,确认后将直接下发。</span>
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setDialogOpen(false)} disabled={dispatching} className="h-8 text-xs">
取消
</Button>
<Button onClick={handleDispatch} disabled={dispatching} className="h-8 text-xs">
{dispatching ? <Spinner className="size-3" /> : <Play className="size-3" />}
{dispatching ? '下发中' : '启动'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
+598
View File
@@ -0,0 +1,598 @@
"use client"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { Separator } from "@/components/ui/separator"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Sheet, SheetTitle, SheetContent } from "@/components/ui/sheet"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Layers, Search, UserX, UserCheck, Eye, Wallet, CreditCard, ShieldCheck, Filter, X, ChevronDown, ChevronLeft, ChevronRight, Users, Loader2 } from "lucide-react"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { AdminUser, AdminService, DispatchTaskRequest } from "@/lib/services"
import { toast } from "sonner"
import { formatDateTime } from "@/lib/utils"
import { EmptyStateWithBorder } from "@/components/layout/empty"
import { LoadingStateWithBorder } from "@/components/layout/loading"
import { ErrorInline } from "@/components/layout/error"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"
import { useAdminUsers } from "@/contexts/admin-users-context"
export function UsersManager() {
const {
users,
total,
loading,
error,
page,
pageSize,
searchUserId,
searchUsername,
statusFilter,
setPage,
setPageSize,
setSearchUserId,
setSearchUsername,
setStatusFilter,
fetchUsers,
updateUserStatus
} = useAdminUsers()
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [updatingUserId, setUpdatingUserId] = useState<string | null>(null)
useEffect(() => {
fetchUsers()
}, [fetchUsers])
const handleStatusToggle = async (user: AdminUser) => {
await updateUserStatus(user)
if (selectedUser?.id === user.id) {
setSelectedUser(prev => prev ? { ...prev, is_active: !prev.is_active } : null)
}
}
const handleShowDetail = (user: AdminUser) => {
setSelectedUser(user)
setDetailOpen(true)
}
const handleUpdateCredits = async (user: AdminUser) => {
try {
setUpdatingUserId(user.id)
const params: DispatchTaskRequest = {
task_type: 'user_gamification',
user_id: user.id
}
await AdminService.dispatchTask(params)
toast.success('积分更新任务已下发', {
description: `正在更新用户 ${ user.username } 的积分数据`
})
} catch (err) {
toast.error('任务下发失败', {
description: err instanceof Error ? err.message : '未知错误'
})
} finally {
setUpdatingUserId(null)
}
}
const totalPages = Math.ceil(total / pageSize)
const hasSearchFilter = Boolean(searchUserId || searchUsername)
const renderFilterBar = () => (
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
hasSearchFilter && "bg-primary/5 border-primary/20"
)}
>
<Search className="size-3 mr-1" />
搜索
{hasSearchFilter && (
<>
<Separator orientation="vertical" className="mx-1" />
<Badge
variant="secondary"
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
>
!
</Badge>
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56 p-3" align="start">
<div className="space-y-2.5">
<input
className="w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background"
placeholder="输入用户 ID..."
value={searchUserId}
onChange={(e) => setSearchUserId(e.target.value)}
/>
<input
className="w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background"
placeholder="输入 username..."
value={searchUsername}
onChange={(e) => setSearchUsername(e.target.value)}
/>
{hasSearchFilter && (
<Button
variant="ghost"
size="sm"
className="w-full h-6 text-xs"
onClick={() => {
setSearchUserId("")
setSearchUsername("")
}}
>
清除
</Button>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
statusFilter !== 'all' && "bg-primary/5 border-primary/20"
)}
>
<Filter className="size-3" />
状态
{statusFilter !== 'all' && (
<>
<Separator orientation="vertical" className="mx-1" />
<Badge
variant="secondary"
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
>
1
</Badge>
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[120px]" align="start">
<DropdownMenuItem
onSelect={(e) => { e.preventDefault(); setStatusFilter('all') }}
>
<div className={cn(
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
statusFilter === 'all'
? "bg-primary text-primary-foreground"
: "opacity-50"
)} />
<span className="text-xs">全部状态</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => { e.preventDefault(); setStatusFilter('active') }}
>
<div className={cn(
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
statusFilter === 'active'
? "bg-primary text-primary-foreground"
: "opacity-50"
)} />
<span className="text-xs">正常</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => { e.preventDefault(); setStatusFilter('inactive') }}
>
<div className={cn(
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
statusFilter === 'inactive'
? "bg-primary text-primary-foreground"
: "opacity-50"
)} />
<span className="text-xs">禁用</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{(hasSearchFilter || statusFilter !== 'all') && (
<>
<Separator orientation="vertical" className="h-6 hidden sm:block" />
<Button
variant="ghost"
size="sm"
onClick={() => {
setSearchUserId("")
setSearchUsername("")
setStatusFilter('all')
}}
className="h-5 px-2 lg:px-3 text-[11px] font-medium text-muted-foreground hover:text-foreground"
>
<X className="size-3" />
清空筛选
</Button>
</>
)}
</div>
<Separator className="lg:hidden" />
<div className="flex items-center gap-1.5 self-end lg:self-auto">
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
{total} 条记录
</span>
<div className="flex items-center border border-dashed rounded-md shadow-none">
<Button
variant="ghost"
size="icon"
className="h-5.5 w-6 rounded-none rounded-l-md disabled:opacity-30"
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page <= 1 || loading}
>
<ChevronLeft className="size-3" />
</Button>
<span className="text-[10px] font-mono text-muted-foreground px-2 border-x border-dashed">
{page}/{totalPages}
</span>
<Button
variant="ghost"
size="icon"
className="h-5.5 w-6 rounded-none rounded-r-md disabled:opacity-30"
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page >= totalPages || loading}
>
<ChevronRight className="size-3" />
</Button>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-6 border-dashed text-[10px] px-2 font-mono shadow-none" disabled={loading}>
{pageSize}条/页
<ChevronDown className="size-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{[20, 50, 100].map(size => (
<DropdownMenuItem
key={size}
onClick={() => setPageSize(size)}
className={cn("font-mono text-xs", pageSize === size && "bg-accent")}
>
{size}条/页
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="icon"
className="h-6 w-6 border-dashed shadow-none"
onClick={() => fetchUsers(true)}
disabled={loading}
title="刷新数据"
>
<Loader2 className={cn("size-3", loading && "animate-spin")} />
</Button>
</div>
</div>
)
return (
<div className="py-6 space-y-4">
<div className="flex items-center justify-between border-b border-border pb-2">
<div className="flex flex-col gap-1">
<div className="text-2xl font-semibold">用户管理</div>
</div>
</div>
{renderFilterBar()}
{error ? (
<div className="p-8 border border-dashed rounded-lg">
<ErrorInline error={error} onRetry={() => fetchUsers(true)} className="justify-center" />
</div>
) : loading && users.length === 0 ? (
<LoadingStateWithBorder icon={Layers} description="加载用户列表中..." />
) : users.length === 0 ? (
<EmptyStateWithBorder icon={UserX} description="暂无用户数据" />
) : (
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
<Table className="w-full caption-bottom text-sm min-w-full">
<TableHeader className="sticky top-0 z-20 bg-background">
<TableRow className="border-b border-dashed hover:bg-transparent">
<TableHead className="w-[90px] whitespace-nowrap py-2 h-8">ID</TableHead>
<TableHead className="w-[120px] whitespace-nowrap py-2 h-8">用户</TableHead>
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono">余额</TableHead>
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono">基准分</TableHead>
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono">支付分</TableHead>
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono">总收益</TableHead>
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono">总消耗</TableHead>
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono is-last-money">总划转</TableHead>
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8 pl-4">上次登陆</TableHead>
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">注册时间</TableHead>
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">上次更新</TableHead>
<TableHead className="sticky right-0 text-center bg-background z-10 w-[80px] py-2 h-8">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow
key={user.id}
className="border-dashed hover:bg-muted/30 cursor-pointer group"
onClick={() => handleShowDetail(user)}
>
<TableCell className="font-mono text-[11px] text-muted-foreground py-1">{user.id}</TableCell>
<TableCell className="py-1">
<div className="flex items-center gap-2">
<Avatar className="h-7 w-7 rounded-sm border">
<AvatarImage src={user.avatar_url} />
<AvatarFallback className="rounded-sm text-[10px]">
{user.username.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-0">
<div className="flex items-center gap-1.5">
<span className="font-medium text-[11px] leading-tight max-w-[100px] truncate" title={user.nickname}>{user.nickname}</span>
{user.is_admin && (
<Badge variant="secondary" className="text-[9px] h-3.5 px-0.5 rounded-[2px] font-normal leading-none tracking-tighter">
ADM
</Badge>
)}
</div>
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground font-mono leading-tight">@{user.username}</span>
</div>
</div>
</div>
</TableCell>
<TableCell className="text-right font-mono text-[11px] text-green-600 dark:text-green-500 font-medium py-1">
{Number(user.available_balance).toFixed(2)}
</TableCell>
<TableCell className="text-right font-mono text-[11px] py-1">
{Number(user.community_balance).toFixed(2)}
</TableCell>
<TableCell className="text-right font-mono text-[11px] py-1">
{Number(user.pay_score).toFixed(2)}
</TableCell>
<TableCell className="text-right font-mono text-[11px] text-green-600 py-1">
{Number(user.total_receive).toFixed(2)}
</TableCell>
<TableCell className="text-right font-mono text-[11px] text-red-500 py-1">
{Number(user.total_payment).toFixed(2)}
</TableCell>
<TableCell className="text-right font-mono text-[11px] text-purple-500 py-1 is-last-money">
{Number(user.total_community).toFixed(2)}
</TableCell>
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1 pl-4">
{formatDateTime(user.last_login_at)}
</TableCell>
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1">
{formatDateTime(user.created_at)}
</TableCell>
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1">
{formatDateTime(user.updated_at)}
</TableCell>
<TableCell className="sticky right-0 text-center bg-background z-10 py-1" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-center gap-0.5">
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Switch
checked={user.is_active}
onCheckedChange={() => handleStatusToggle(user)}
disabled={user.is_admin}
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
/>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{user.is_admin ? '管理员账户' : user.is_active ? '禁用账户' : '启用账户'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground"
onClick={() => handleUpdateCredits(user)}
disabled={updatingUserId === user.id}
>
<Loader2 className={cn("size-3", updatingUserId === user.id && "animate-spin")} />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
更新积分
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-foreground" onClick={() => handleShowDetail(user)}>
<Eye className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
查看详情
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
<SheetContent className="sm:max-w-[400px] w-full p-0 flex flex-col gap-0">
<SheetTitle className="px-5 py-3">用户档案</SheetTitle>
{selectedUser && (
<>
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
<div className="flex flex-col pb-6">
<div className="px-5 py-6 border-b border-border/50">
<div className="flex flex-col items-center text-center gap-3">
<Avatar className="h-20 w-20 rounded-full border-4 border-background ring-1 ring-border/20">
<AvatarImage src={selectedUser.avatar_url} />
<AvatarFallback className="rounded-full text-xl font-medium bg-secondary text-secondary-foreground">
{selectedUser.username.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="space-y-1.5">
<h3 className="text-lg font-bold tracking-tight">{selectedUser.nickname}</h3>
<div className="flex items-center justify-center gap-2">
<code className="px-1.5 py-0.5 rounded-md bg-muted text-[10px] font-mono text-muted-foreground">@{selectedUser.username}</code>
<Badge variant="secondary" className="h-4.5 px-1.5 text-[9px] uppercase font-medium">
UID: {selectedUser.id}
</Badge>
<Badge variant="secondary" className="h-4.5 px-1.5 text-[9px] uppercase font-medium">
Lv.{selectedUser.trust_level}
</Badge>
{selectedUser.is_admin && (
<Badge className="h-4.5 px-1.5 text-[9px] uppercase font-medium bg-primary text-primary-foreground">
Admin
</Badge>
)}
</div>
</div>
<div className="gap-4 w-full max-w-[240px] mt-1 pt-4 border-t border-border/50">
<div className="flex flex-col gap-0.5">
<span className="text-[9px] uppercase tracking-widest text-muted-foreground font-medium">注册时间</span>
<span className="font-mono text-xs font-semibold">{formatDateTime(selectedUser.created_at).split(' ')[0]}</span>
</div>
</div>
</div>
</div>
<div className="p-6">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-4 px-1">核心资产</h4>
<div className="grid grid-cols-3 gap-2">
<div className="p-3 rounded-xl border bg-card/50 hover:bg-card/80 transition-colors space-y-2">
<div className="flex flex-col gap-1 text-muted-foreground">
<Wallet className="w-4 h-4" />
<span className="text-[10px] font-medium">可用余额</span>
</div>
<div className="text-lg font-mono font-bold tracking-tight text-foreground truncate">
{Number(selectedUser.available_balance).toFixed(2)}
</div>
</div>
<div className="p-3 rounded-xl border bg-card/50 hover:bg-card/80 transition-colors space-y-2">
<div className="flex flex-col gap-1 text-muted-foreground">
<CreditCard className="w-4 h-4" />
<span className="text-[10px] font-medium">支付积分</span>
</div>
<div className="text-lg font-mono font-bold tracking-tight text-foreground truncate">
{Number(selectedUser.pay_score).toFixed(2)}
</div>
</div>
<div className="p-3 rounded-xl border bg-card/50 hover:bg-card/80 transition-colors space-y-2">
<div className="flex flex-col gap-1 text-muted-foreground">
<Users className="w-4 h-4" />
<span className="text-[10px] font-medium">社区余额</span>
</div>
<div className="text-lg font-mono font-bold tracking-tight text-foreground truncate">
{Number(selectedUser.community_balance).toFixed(2)}
</div>
</div>
</div>
</div>
<Separator className="mx-6 w-auto opacity-50" />
<div className="p-6 space-y-6">
<div className="space-y-4">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1">财务概况</h4>
<div className="rounded-lg border divide-y bg-background/50">
<div className="flex items-center justify-between p-3.5">
<span className="text-[10px]">累计收益</span>
<span className="font-mono text-[10px] font-medium text-purple-600 dark:text-purple-400">+{Number(selectedUser.total_community).toFixed(2)}</span>
</div>
<div className="flex items-center justify-between p-3.5">
<span className="text-[10px]">累计收入</span>
<span className="font-mono text-[10px] font-medium text-green-600">+{Number(selectedUser.total_receive).toFixed(2)}</span>
</div>
<div className="flex items-center justify-between p-3.5">
<span className="text-[10px]">累计支出</span>
<span className="font-mono text-[10px] font-medium text-red-500">{Number(selectedUser.total_payment) === 0 ? '0.00' : `-${ Number(selectedUser.total_payment).toFixed(2) }`}</span>
</div>
</div>
</div>
<div className="space-y-4">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1">系统记录</h4>
<div className="rounded-lg border divide-y bg-background/50">
<div className="flex items-center justify-between p-3.5 text-sm">
<span className="text-[10px]">最后登录</span>
<span className="font-mono text-[10px]">{formatDateTime(selectedUser.last_login_at)}</span>
</div>
<div className="flex items-center justify-between p-3.5 text-sm">
<span className="text-[10px]">注册时间</span>
<span className="font-mono text-[10px]">{formatDateTime(selectedUser.created_at)}</span>
</div>
<div className="flex items-center justify-between p-3.5 text-sm">
<span className="text-[10px]">最后更新</span>
<span className="font-mono text-[10px]">{formatDateTime(selectedUser.updated_at)}</span>
</div>
</div>
</div>
</div>
</div>
</div>
{!selectedUser.is_admin && (
<div className="p-4 border-t bg-background/80 backdrop-blur-md shrink-0">
<Button
variant={selectedUser.is_active ? "destructive" : "default"}
className={cn(
"w-full h-9 text-xs font-medium transition-all active:scale-[0.98]",
selectedUser.is_active
? "bg-red-500 hover:bg-red-600 text-white"
: "bg-primary text-primary-foreground hover:bg-primary/90"
)}
onClick={() => handleStatusToggle(selectedUser)}
>
{selectedUser.is_active ? (
<>
<ShieldCheck className="size-3 mr-1" />
封禁账户
</>
) : (
<>
<UserCheck className="size-3 mr-1" />
解除封禁
</>
)}
</Button>
</div>
)}
</>
)}
</SheetContent>
</Sheet>
</div>
)
}
@@ -0,0 +1,54 @@
"use client"
import * as React from "react"
import { CountingNumber } from "@/components/animate-ui/primitives/texts/counting-number"
import { BalanceReport } from "@/components/common/balance/balance-report"
import { BalanceSummary } from "@/components/common/balance/balance-summary"
import { BalanceTable } from "@/components/common/balance/balance-table"
import { useUser } from "@/contexts/user-context"
/**
* 积分余额主页面组件
*
* 负责组装积分余额页面的各个子组件,包括积分余额总览、积分余额摘要、近期活动和报告侧边栏
*/
export function BalanceMain() {
const { user, loading } = useUser()
/** 计算总余额 */
const totalBalance = React.useMemo(() => {
return parseFloat(user?.available_balance || '0')
}, [user?.available_balance])
return (
<div className="py-6">
<div className="flex items-center gap-2 pb-2">
<h1 className="text-3xl">
<span className="font-semibold">积分</span>
<span className="pl-2">LDC</span>
<span className="pl-2">
{loading ? "-" : <CountingNumber number={totalBalance} decimalPlaces={2} />}
</span>
</h1>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12 pt-4">
<div className="lg:col-span-2 space-y-12">
<section aria-labelledby="balance-summary-title">
<h2 id="balance-summary-title" className="font-semibold mb-4">积分摘要</h2>
<BalanceSummary />
</section>
<section aria-labelledby="recent-activity-title">
<h2 id="recent-activity-title" className="font-semibold mb-2">近期活动</h2>
<BalanceTable />
</section>
</div>
<aside className="lg:col-span-1" aria-labelledby="reports-title">
<BalanceReport />
</aside>
</div>
</div>
)
}
@@ -0,0 +1,15 @@
import * as React from "react"
/**
* 余额报告组件
*
* 显示可下载或查看的报告列表
*/
export function BalanceReport() {
return (
<section className="space-y-4" aria-labelledby="reports-title">
<h2 id="reports-title" className="font-semibold">报告</h2>
<div className="text-sm text-muted-foreground">暂无报告</div>
</section>
)
}
@@ -0,0 +1,117 @@
import * as React from "react"
import { CountingNumber } from "@/components/animate-ui/primitives/texts/counting-number"
import { useUser } from "@/contexts/user-context"
/** 颜色配置 - 统一管理主题色 */
const COLORS = {
available: "bg-primary/80",
pending: "bg-zinc-400/80",
emptyState: "bg-muted/50",
} as const
/**
* 计算积分余额百分比
*/
function calculatePercentages(available: number, total: number) {
if (total <= 0) {
return { available: 0, pending: 0 }
}
const pending = total - available
return {
available: (available / total) * 100,
pending: (pending / total) * 100,
}
}
/**
* 安全转换数字,避免 NaN
*/
function safeNumber(value: number): number {
return typeof value === 'number' && !isNaN(value) ? value : 0
}
/**
* 积分余额摘要组件
*
* 显示积分余额的可视化摘要,包括可视化进度条和详细的积分余额分类列表
*/
export function BalanceSummary() {
const { user, loading } = useUser()
const available = parseFloat(user?.available_balance || '0')
const pending = parseFloat(user?.pending_balance || '0')
const total = available + pending
const percentages = React.useMemo(
() => calculatePercentages(available, total),
[available, total]
)
return (
<div className="space-y-4">
<div
className={`w-full h-4 ${ COLORS.emptyState } rounded-sm overflow-hidden flex`}
role="progressbar"
aria-label="积分余额分布"
aria-valuenow={percentages.available}
aria-valuemin={0}
aria-valuemax={100}
>
<div
className={`${ COLORS.available } transition-all duration-300`}
style={{ width: `${ percentages.available }%` }}
title={`可用: ${ percentages.available.toFixed(1) }%`}
/>
<div
className={`${ COLORS.pending } transition-all duration-300`}
style={{ width: `${ percentages.pending }%` }}
title={`未来积分: ${ percentages.pending.toFixed(1) }%`}
/>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center text-xs font-medium pb-2 border-b border-border/80">
<span>积分类型</span>
<span className="text-muted-foreground">值</span>
</div>
<div className="flex justify-between items-center font-bold text-sm pb-2 border-b border-border/80">
<div className="flex items-center gap-2">
<div className={`size-2.5 ${ COLORS.available } rounded-xs`} aria-hidden="true" />
<span>可用</span>
</div>
<span className="font-semibold">
{loading ? "-" : (
<CountingNumber
number={safeNumber(available)}
decimalPlaces={2}
initiallyStable={true}
inView={true}
inViewOnce={true}
/>
)} LDC
</span>
</div>
<div className="flex justify-between items-center font-bold text-sm pb-2 border-b border-border/80">
<div className="flex items-center gap-2">
<div className={`size-2.5 ${ COLORS.pending } rounded-xs`} aria-hidden="true" />
<span>未来积分</span>
</div>
<span className="font-semibold">
{loading ? "-" : (
<CountingNumber
number={safeNumber(pending)}
decimalPlaces={2}
initiallyStable={true}
inView={true}
inViewOnce={true}
/>
)} LDC
</span>
</div>
</div>
</div>
)
}
@@ -0,0 +1,150 @@
"use client"
import * as React from "react"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { TransactionTableList } from "@/components/common/general/table-data"
import { TransactionProvider, useTransaction } from "@/contexts/transaction-context"
import { DEFAULT_ORDER_TYPES, type OrderType } from "@/lib/services"
import { formatLocalDate } from "@/lib/utils"
/** 标签触发器样式 */
const TAB_TRIGGER_STYLES =
"data-[state=active]:bg-transparent " +
"data-[state=active]:shadow-none " +
"data-[state=active]:border-0 " +
"data-[state=active]:border-b-2 " +
"data-[state=active]:border-indigo-500 " +
"bg-transparent " +
"rounded-none " +
"border-0 " +
"border-b-2 " +
"border-transparent " +
"px-0 " +
"text-sm " +
"font-bold " +
"text-muted-foreground " +
"data-[state=active]:text-indigo-500 " +
"-mb-[2px] " +
"relative " +
"hover:text-foreground " +
"transition-colors " +
"flex-none"
/** 标签配置 - 数据驱动渲染 */
const TABS = [
{ value: "receive" as const, label: "积分收益" },
{ value: "payment" as const, label: "积分消耗" },
{ value: "community" as const, label: "社区划转" },
{ value: "online" as const, label: "在线流转" },
{ value: "all" as const, label: "所有活动" },
] as const
/**
* 计算最近30天的时间范围
* 每次调用时重新计算,避免缓存过期时间
*/
function getTimeRange() {
const now = new Date()
now.setHours(23, 59, 59, 999)
const start = new Date(now)
start.setDate(start.getDate() - 30)
start.setHours(0, 0, 0, 0)
return {
startTime: formatLocalDate(start),
endTime: formatLocalDate(now)
}
}
/**
* 积分余额活动表格组件
*
* 显示不同类型的积分余额活动记录,支持多标签切换和分页加载
*/
export function BalanceTable() {
const [activeTab, setActiveTab] = React.useState<OrderType | "all">("all")
const [timeRange, setTimeRange] = React.useState<{ startTime: string; endTime: string } | null>(null)
React.useEffect(() => {
setTimeRange(getTimeRange())
}, [])
if (!timeRange) {
return null
}
return (
<div>
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as OrderType | "all")}
className="w-full"
>
<TabsList className="flex p-0 gap-4 rounded-none w-full bg-transparent justify-start border-b border-border overflow-x-auto overflow-y-hidden">
{TABS.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className={TAB_TRIGGER_STYLES}
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
<div className="mt-2">
<TransactionProvider
defaultParams={{
page_size: 20,
startTime: timeRange.startTime,
endTime: timeRange.endTime,
}}
>
<TransactionList
type={activeTab === "all" ? undefined : activeTab}
/>
</TransactionProvider>
</div>
</Tabs>
</div>
)
}
/**
* 积分余额活动列表组件
*
* 负责获取和显示积分余额活动数据
*/
const TransactionList = React.memo(function TransactionList({ type }: { type?: OrderType }) {
const {
transactions,
loading,
error,
lastParams,
fetchTransactions,
} = useTransaction()
/** 当积分余额活动类型变化时重新加载数据 */
React.useEffect(() => {
fetchTransactions({
page: 1,
types: type ? [type] : DEFAULT_ORDER_TYPES,
startTime: lastParams.startTime,
endTime: lastParams.endTime,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [type])
return (
<TransactionTableList
loading={loading}
error={error}
transactions={transactions}
onRetry={() => fetchTransactions({
page: 1,
types: type ? [type] : DEFAULT_ORDER_TYPES,
startTime: lastParams.startTime,
endTime: lastParams.endTime,
})}
/>
)
})
+589
View File
@@ -0,0 +1,589 @@
import { type PolicySection } from "./types"
import { CodeBlock } from "@/components/ui/code-block"
import {
DocsTable,
DocsTableHeader,
DocsTableBody,
DocsTableHead,
DocsTableRow,
DocsTableCell,
} from "@/components/ui/docs-table"
export const DOCS_LAST_UPDATED = "2026-04-20"
/**
* ------------------------------------------------------------------
* API 文档
* ------------------------------------------------------------------
*/
export const apiSections: PolicySection[] = [
{
value: "official-service",
title: "1. 官方 LDC 接口",
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">官方原生接口,使用 Ed25519 签名算法,安全性更高</p>
</div>
<h3 id="1-1-overview" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">1.1 概览</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>协议:</strong>官方 LDC 支付协议</li>
<li><strong>服务类型:</strong>支持 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">type=ldcpay</code></li>
<li><strong>网关基址:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">https://credit.linux.do/epay</code></li>
<li><strong>签名方式:</strong>Ed25519 非对称加密</li>
</ul>
<h3 id="1-2-flow" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">1.2 对接流程</h3>
<ol className="list-decimal pl-4 md:pl-5 space-y-2">
<li>控制台创建应用,配置 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">client_id</code> 并在应用设置中上传商户 Ed25519 公钥</li>
<li>根据“签名算法”及商户私钥生成 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">sign</code></li>
<li>调用 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">/pay/submit</code> 发起积分流转请求</li>
<li>认证完成后,通过异步回调或轮询接口同步状态</li>
</ol>
<h3 id="1-3-auth-sign" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">1.3 鉴权与签名</h3>
<h4 className="font-medium text-foreground mt-3 md:mt-4 mb-2">1.3.1 签名算法</h4>
<div className="space-y-4">
<ol className="list-decimal pl-4 md:pl-5 space-y-2">
<li>取除 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">sign</code> 以外的所有非空请求参数</li>
<li>将参数按参数名 ASCII 码从到大排序(字典序)</li>
<li>使用 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">k1=v1&k2=v2...</code> 格式拼接成字符串</li>
<li>将 <strong>应用密钥 (Client Secret)</strong> 直接追加到字符串末尾</li>
<li>使用商户私钥对最终字符串进行 <strong>Ed25519</strong> 签名</li>
<li>将签名结果转换成 Base64 编码作为 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">sign</code> 参数</li>
</ol>
<CodeBlock
code={`// 示例:client_id=1&money=10.00&order_name=Test&out_trade_no=M1&type=ldcpay{SECRET}
data = "client_id=1&money=10.00&order_name=Test&out_trade_no=M1&type=ldcpay" + client_secret
signature = ed25519.Sign(privateKey, []byte(data))
signBase64 = base64.StdEncoding.EncodeToString(signature)`}
language="javascript"
/>
</div>
<h3 id="1-4-submit" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">1.4 积分流转服务</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-6">
<li><strong>方法:</strong>POST <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">/epay/pay/submit.php</code></li>
<li><strong>编码:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">application/json</code> 或 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">application/x-www-form-urlencoded</code></li>
</ul>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className="w-[100px] whitespace-nowrap">参数</DocsTableHead>
<DocsTableHead className="w-[80px] whitespace-nowrap">必填</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">client_id</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>应用客户端 ID</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">type</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>固定 <code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">ldcpay</code></DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">out_trade_no</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>业务单号</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">money</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>积分数量,必须保留两位小数(比如,10.00)</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">order_name</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>商品名称</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">notify_url</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>会参与签名;可选订单级异步通知地址。长度不超过 100,需为合法 URL。传入后支付成功优先回调该地址,未传则使用应用 notify_url</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">return_url</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>会参与签名;可选订单级回跳地址。长度不超过 100,需为合法 URL。传入后支付成功页面优先跳转该地址,未传则使用应用 redirect_uri</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">sign</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>按“签名算法”生成的 Base64 签名串</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<h3 id="1-5-others" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">1.5 其他接口</h3>
<p className="text-muted-foreground mb-4">其他接口定义请参考 <a href="#3-common-services" className="text-primary underline underline-offset-4">3. 其他接口</a>。</p>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>订单查询:</strong>详见 <a href="#3-1-order" className="text-primary underline underline-offset-4">3.1 订单查询</a></li>
<li><strong>订单退款:</strong>详见 <a href="#3-2-refund" className="text-primary underline underline-offset-4">3.2 订单退款</a></li>
<li><strong>异步通知:</strong>详见 <a href="#3-3-notify" className="text-primary underline underline-offset-4">3.3 异步通知</a></li>
</ul>
</div>
),
children: [
{ value: "1-1-overview", title: "1.1 概览" },
{ value: "1-2-flow", title: "1.2 对接流程" },
{ value: "1-3-auth-sign", title: "1.3 鉴权与签名" },
{ value: "1-4-submit", title: "1.4 积分流转服务" },
{ value: "1-5-others", title: "1.5 其他接口" },
]
},
{
value: "epay-compatibility",
title: "2. 易支付兼容接口",
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">兼容易支付、CodePay、VPay 等支付协议</p>
</div>
<h3 id="2-1-overview" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">2.1 概览</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>协议:</strong>EasyPay / CodePay / VPay 兼容协议</li>
<li><strong>服务类型:</strong>仅支持 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">type=epay</code></li>
<li><strong>网关基址:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">https://credit.linux.do/epay</code></li>
<li><strong>订单有效期:</strong>取系统配置 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">merchant_order_expire_minutes</code>(平台端设置)</li>
</ul>
<h3 id="2-2-common-errors" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">2.2 常见错误</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">不支持的请求类型</code>:<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">type</code> 仅允许 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">epay</code></li>
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">签名验证失败</code>:参与签名字段与请求体需一致,密钥直接拼接</li>
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">金额必须大于0</code> / <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">积分小数位数不能超过2位</code></li>
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">订单已过期</code>:超出系统配置有效期</li>
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">订单不存在或已完成</code>:订单号错误、已退回或已完成</li>
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">余额不足</code>:余额退回时用户积分不足</li>
</ul>
<h3 id="2-3-flow" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">2.3 对接流程</h3>
<ol className="list-decimal pl-4 md:pl-5 space-y-2">
<li>控制台创建 API Key,记录 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">pid</code>、<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">key</code>,配置回调地址</li>
<li>按“签名算法”生成 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">sign</code>,调用 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">/epay/pay/submit.php</code> 创建积分流转服务并跳转认证界面</li>
<li>可通过 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">/epay/api.php</code> 轮询结果,或等待异步回调</li>
<li>退回服务时,携带同一 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">trade_no</code> 和原积分数量,调用积分退回接口</li>
<li>回调验签通过后返回 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">success</code> 完成闭环</li>
</ol>
<h3 id="2-4-auth-sign" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">2.4 鉴权与签名</h3>
<h4 className="font-medium text-foreground mt-3 md:mt-4 mb-2">2.4.1 API Key</h4>
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-4">
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">pid</code>:Client ID</li>
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">key</code>:Client Secret(妥善保管)</li>
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">notify_url</code> / <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">return_url</code>:应用级默认回调地址(兜底);创建订单时可在请求中传同名字段作为订单级覆盖,未传时回退应用配置。</li>
</ul>
<h4 className="font-medium text-foreground mt-3 md:mt-4 mb-2">2.4.2 签名算法</h4>
<div className="space-y-4">
<ol className="list-decimal pl-4 md:pl-5 space-y-2">
<li>取所有非空字段(排除 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">sign</code>、<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">sign_type</code> 字段)</li>
<li>将上述字段按 ASCII 升序,依次拼成 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">k1=v1&k2=v2</code></li>
<li>在末尾追加应用密钥:<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">k1=v1&k2=v2{"{secret}"}</code></li>
<li>整体进行 MD5,取小写十六进制作为 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">sign</code></li>
</ol>
<CodeBlock
code={`payload="money=10&name=Test&out_trade_no=M20250101&pid=001&type=epay"
sign=$(echo -n "\${payload}\${SECRET}" | md5) # 输出小写`}
language="bash"
/>
</div>
<h3 id="2-5-submit" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">2.5 积分流转服务</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-6">
<li><strong>方法:</strong>POST <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">/epay/pay/submit.php</code></li>
<li><strong>编码:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">application/json</code> 或 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">application/x-www-form-urlencoded</code></li>
<li><strong>成功:</strong>验签通过后,平台自动创建积分流转服务,并跳转到认证界面(Location=<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">https://credit.linux.do/paying?order_no=...</code>)</li>
<li><strong>失败:</strong>返回 JSON <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">{`{"error_msg":"...", "data":null}`}</code></li>
</ul>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className="w-[100px] whitespace-nowrap">参数</DocsTableHead>
<DocsTableHead className="w-[80px] whitespace-nowrap">必填</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">pid</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>Client ID</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">type</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>固定 <code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">epay</code></DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">out_trade_no</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>业务单号,建议全局唯一</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">name</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>标题,最多 64 字符</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">money</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>积分数量,最多 2 位小数</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">notify_url</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>会参与签名;可选订单级异步通知地址。长度不超过 100,需为合法 URL。传入后支付成功优先回调该地址,未传则使用应用 notify_url</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">return_url</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>会参与签名;可选订单级回跳地址。长度不超过 100,需为合法 URL。传入后支付成功页面优先跳转该地址,未传则使用应用 redirect_uri</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">device</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>终端标识,可选</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">sign</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>按“签名算法”生成</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">sign_type</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>固定 <code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">MD5</code></DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<p className="text-muted-foreground mb-2">请求示例:</p>
<CodeBlock
code={`curl -X POST https://credit.linux.do/epay/pay/submit.php \\
-H "Content-Type: application/x-www-form-urlencoded" \\
-d "pid=001" \\
-d "type=epay" \\
-d "out_trade_no=M20250101" \\
-d "name=Test" \\
-d "money=10" \\
-d "sign=\${SIGN}" \\
-d "sign_type=MD5"`}
language="bash"
/>
<h3 id="2-6-others" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">2.6 其他接口</h3>
<p className="text-muted-foreground mb-4">其他接口定义请参考 <a href="#3-common-services" className="text-primary underline underline-offset-4">3. 其他接口</a>。</p>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>订单查询:</strong>详见 <a href="#3-1-order" className="text-primary underline underline-offset-4">3.1 订单查询</a></li>
<li><strong>订单退款:</strong>详见 <a href="#3-2-refund" className="text-primary underline underline-offset-4">3.2 订单退款</a></li>
<li><strong>异步通知:</strong>详见 <a href="#3-3-notify" className="text-primary underline underline-offset-4">3.3 异步通知</a></li>
</ul>
</div>
),
children: [
{ value: "2-1-overview", title: "2.1 概览" },
{ value: "2-2-common-errors", title: "2.2 常见错误" },
{ value: "2-3-flow", title: "2.3 对接流程" },
{ value: "2-4-auth-sign", title: "2.4 鉴权与签名" },
{ value: "2-5-submit", title: "2.5 积分流转服务" },
{ value: "2-6-others", title: "2.6 其他接口" },
]
},
{
value: "common-services",
title: "3. 其他接口",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<div className="bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6">
<p className="text-muted-foreground m-0">官方接口与易支付兼容接口公用接口。</p>
</div>
<h3 id="3-1-order" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">3.1 订单查询</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-6">
<li><strong>方法:</strong>GET <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">/epay/api.php</code></li>
<li><strong>认证:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">pid</code> + <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">key</code></li>
<li><strong>说明:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">out_trade_no</code> 必填;<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">act</code> 可传 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">order</code>,后端不强校验。</li>
</ul>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className="w-[100px] whitespace-nowrap">参数</DocsTableHead>
<DocsTableHead className="w-[80px] whitespace-nowrap">必填</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">act</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>可选字段,建议 <code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">order</code></DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">pid</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>Client ID</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">key</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>Client Secret</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">out_trade_no</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>业务单号</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<p className="text-muted-foreground mb-2">成功响应:</p>
<CodeBlock
code={`{
"code": 1,
"msg": "查询订单号成功!",
"trade_no": "M20250101",
"out_trade_no": "M20250101",
"type": "epay",
"pid": "001",
"addtime": "2025-01-01 12:00:00",
"endtime": "2025-01-01 12:01:30",
"name": "Test",
"money": "10",
"status": 1
}`}
language="json"
/>
<p className="text-muted-foreground text-xs"><strong className="text-foreground">补充:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">status</code> 1=成功,0=失败/处理中;不存在会返回 HTTP 404 且 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">{`{"code":-1,"msg":"服务不存在或已完成"}`}</code>。</p>
<h3 id="3-2-refund" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">3.2 订单退款</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-6">
<li><strong>方法:</strong>POST <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">/epay/api.php</code></li>
<li><strong>编码:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">application/json</code> 或 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">application/x-www-form-urlencoded</code></li>
<li><strong>限制:</strong>仅支持对已成功的积分流转服务进行积分的全额退回</li>
</ul>
<div>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className="w-[100px] whitespace-nowrap">参数</DocsTableHead>
<DocsTableHead className="w-[80px] whitespace-nowrap">必填</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">pid</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>Client ID</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">key</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>Client Secret</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">trade_no</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>编号</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">money</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>必须等于原积分流转服务的积分数量</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">out_trade_no</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>业务单号(兼容)</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
</div>
<p className="text-muted-foreground mb-2">响应:</p>
<CodeBlock code={`{ "code": 1, "msg": "退款成功" }`} language="json" />
<p className="text-muted-foreground text-xs"><strong className="text-foreground">常见失败:</strong>服务不存在/未认证、金额不合法(&lt;=0 或小数超过 2 位)。</p>
<h3 id="3-3-notify" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">3.3 异步通知</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-6">
<li><strong>触发:</strong>认证成功后;失败自动重试,最多 5 次(单次 30s 超时)</li>
<li><strong>目标:</strong>订单级 notify_url(如有)优先,否则回退到创建应用时设置的 notify_url</li>
<li><strong>方式:</strong>HTTP GET</li>
</ul>
<div>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className="w-[100px] whitespace-nowrap">参数</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">pid</DocsTableCell>
<DocsTableCell>Client ID</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">trade_no</DocsTableCell>
<DocsTableCell>编号</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">out_trade_no</DocsTableCell>
<DocsTableCell>业务单号</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">type</DocsTableCell>
<DocsTableCell>固定 <code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">epay</code></DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">name</DocsTableCell>
<DocsTableCell>标题</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">money</DocsTableCell>
<DocsTableCell>积分数量,最多 2 位小数</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">trade_status</DocsTableCell>
<DocsTableCell>固定 <code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">TRADE_SUCCESS</code></DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">sign</DocsTableCell>
<DocsTableCell>按“签名算法”生成</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
</div>
<p className="text-muted-foreground text-xs">应用需返回 HTTP 200 且响应体为 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">success</code>(大小写不敏感),否则视为失败并继续重试。</p>
<h3 id="3-4-distribute" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">3.4 商户分发接口</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-6">
<li><strong>方法:</strong>POST <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">/lpay/distribute</code></li>
<li><strong>编码:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">application/json</code></li>
<li><strong>认证:</strong>Basic Auth (使用 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">client_id:client_secret</code> 进行 Base64 编码)</li>
</ul>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className="w-[100px] whitespace-nowrap">参数</DocsTableHead>
<DocsTableHead className="w-[80px] whitespace-nowrap">必填</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">user_id</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>收款人用户 ID (数字)</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">username</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>收款人用户名 (用于二次校验)</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">amount</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>分发积分数量,最多 2 位小数</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">out_trade_no</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>商户自定义单号</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">remark</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>分发备注</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<p className="text-muted-foreground text-xs"><strong className="text-foreground">成功响应:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">{`{"code":1, "data":{"trade_no":"...", "out_trade_no":"..."}}`}</code></p>
<h3 id="3-5-user-balance" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">3.5 用户余额统计</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-6">
<li><strong>方法:</strong>GET <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">/api/v1/dashboard/stats/user-balance</code></li>
<li><strong>认证:</strong>无需鉴权(公开接口)</li>
<li><strong>说明:</strong>获取平台所有用户可用余额的统计数据,结果有缓存,TTL 由系统配置决定</li>
</ul>
<p className="text-muted-foreground mb-2">成功响应:</p>
<CodeBlock
code={`{
"error_msg": "",
"data": {
"total_count": 1234,
"total_amount": "98765.00",
"avg_amount": "80.03",
"median_amount": "50.00",
"min_amount": "0.00",
"max_amount": "9999.00",
"std_dev": "123.45"
}
}`}
language="json"
/>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className="w-[120px] whitespace-nowrap">字段</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">total_count</DocsTableCell>
<DocsTableCell>统计用户总数</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">total_amount</DocsTableCell>
<DocsTableCell>所有用户可用余额之和</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">avg_amount</DocsTableCell>
<DocsTableCell>平均余额</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">median_amount</DocsTableCell>
<DocsTableCell>余额中位数</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">min_amount</DocsTableCell>
<DocsTableCell>最小余额</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">max_amount</DocsTableCell>
<DocsTableCell>最大余额</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className="font-mono text-xs">std_dev</DocsTableCell>
<DocsTableCell>余额标准差</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
</div>
),
children: [
{ value: "3-1-order", title: "3.1 订单查询" },
{ value: "3-2-refund", title: "3.2 订单退款" },
{ value: "3-3-notify", title: "3.3 异步通知" },
{ value: "3-4-distribute", title: "3.4 商户分发接口" },
{ value: "3-5-user-balance", title: "3.5 用户余额统计" },
]
},
]
@@ -0,0 +1,329 @@
import { type PolicySection } from "./types"
import { CodeBlock } from "@/components/ui/code-block"
import {
DocsTable,
DocsTableHeader,
DocsTableBody,
DocsTableHead,
DocsTableRow,
DocsTableCell,
} from "@/components/ui/docs-table"
/**
* ------------------------------------------------------------------
* 使用指南 (How To Use)
* ------------------------------------------------------------------
*/
export const howToUseSections: PolicySection[] = [
{
value: "quick-start",
title: "1. 快速开始",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<div className="bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6">
<p className="text-muted-foreground m-0">为社区开发者与用户提供完整的平台使用说明</p>
</div>
<ul className="list-disc pl-5 space-y-2">
<li><strong>身份认证:</strong>基于 LINUX DO Connect (OAuth)</li>
<li><strong>认证方式:</strong>账户积分消耗认证</li>
<li><strong>手续费:</strong>动态费率,由服务方承担</li>
<li><strong>争议处理:</strong>支持服务方与消费方的双方争议处理</li>
</ul>
</div>
)
},
{
value: "roles",
title: "2. 角色说明",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<ul className="list-disc pl-5 space-y-2">
<li><strong>服务方:</strong>最终积分流转的转入方</li>
<li><strong>消费方:</strong>最终积分流转的转出方</li>
<li><strong>认证平台:</strong>LINUX DO Credit 系统本身</li>
</ul>
</div>
)
},
{
value: "integration",
title: "3. 接入积分服务",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<h3 id="3-1-api" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">3.1 使用 API 接口</h3>
<ol className="list-decimal pl-4 md:pl-5 space-y-2">
<li>
<strong>创建应用</strong>
<ul className="list-disc pl-4 md:pl-5 mt-2 space-y-1 text-muted-foreground">
<li>前往 <a href="/merchant" className="text-primary hover:underline">集市中心</a></li>
<li>点击顶部右侧 <strong>创建应用</strong> 按钮</li>
<li>填写必要信息:应用名称、应用主页、回调地址、通知地址</li>
</ul>
</li>
<li>
<strong>获取 API 凭证</strong>
<ul className="list-disc pl-4 md:pl-5 mt-2 space-y-1 text-muted-foreground">
<li>在集市中心顶部右侧选择器中选择您的应用</li>
<li>在 <strong>API 配置</strong> 面板中获取:
<ul className="list-[circle] pl-4 md:pl-5 mt-1">
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">Client ID</code>:客户端ID,用于标识您的身份</li>
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">Client Secret</code>:客户端密钥,用于签名验证(<strong>请妥善保管,切勿泄露</strong>)</li>
</ul>
</li>
</ul>
</li>
<li>
<strong>使用 API 接口</strong>
<ul className="list-disc pl-4 md:pl-5 mt-2 space-y-1 text-muted-foreground">
<li>使用 API 接口创建积分流转服务</li>
<li>参考文档:<a href="/docs/api" className="text-primary hover:underline">API 接口文档</a></li>
</ul>
</li>
</ol>
<h3 id="3-2-online" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">3.2 使用在线服务</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-4">
<li><strong>适用场景:</strong>无代码开发基础,或只用于简单的积分服务。</li>
<li><strong>操作步骤:</strong>
<ol className="list-decimal pl-4 md:pl-5 mt-2 space-y-1 text-muted-foreground">
<li>前往 <a href="/merchant" className="text-primary hover:underline">集市中心</a> 创建应用,获取 API 凭证</li>
<li>选择应用,点击 <strong>在线收款</strong> 功能</li>
<li>创建在线积分服务</li>
<li>获取唯一积分服务链接</li>
<li>发送给您所服务的客户使用</li>
</ol>
</li>
</ul>
<h3 id="3-3-new-api" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">3.3 快速集成 New API</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-4">
<li><strong>适用场景:</strong>New API 站点,LINUX DO Credit 兼容 EasyPay 协议,公益站站长可直接集成。</li>
<li><strong>操作步骤:</strong>
<ol className="list-decimal pl-4 md:pl-5 mt-2 space-y-2 text-muted-foreground">
<li>
前往 <a href="/merchant" className="text-primary hover:underline">集市中心</a>,点击 <strong>创建应用</strong>,填写 New API 站点信息:
<div className="my-4">
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead>字段</DocsTableHead>
<DocsTableHead>值</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell>应用名称</DocsTableCell>
<DocsTableCell><code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">您的应用名称</code></DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell>应用主页</DocsTableCell>
<DocsTableCell><code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">https://{"{您的 New API 域名}"}</code></DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell>回调地址</DocsTableCell>
<DocsTableCell><code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">https://{"{您的 New API 域名}"}/console/log</code></DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell>通知地址</DocsTableCell>
<DocsTableCell><code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">https://{"{您的 New API 域名}"}/api/user/epay/notify</code></DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
</div>
</li>
<li>前往 New API 站点的系统设置,找到 <strong>支付设置</strong>。</li>
<li>
配置 LINUX DO Credit 平台参数:
<div className="my-4">
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead>参数</DocsTableHead>
<DocsTableHead>值</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell>支付地址</DocsTableCell>
<DocsTableCell><code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">https://credit.linux.do/epay/pay</code></DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell>易支付商户ID</DocsTableCell>
<DocsTableCell>您的 <code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">Client ID</code></DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell>易支付商户密钥</DocsTableCell>
<DocsTableCell>您的 <code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">Client Secret</code></DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell>回调地址</DocsTableCell>
<DocsTableCell><code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">https://{"{您的 New API 域名}"}</code></DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
</div>
</li>
<li>
配置充值方式(JSON 格式):
<div className="my-4">
<CodeBlock
code={`[
{"color":"rgba(var(--semi-blue-5), 1)","name":"支付宝","type":"alipay"},
{"color":"rgba(var(--semi-green-5), 1)","name":"微信","type":"wxpay"},
{"color":"black","name":"Linux Do Credit","type":"epay"}
]`}
language="json"
/>
</div>
</li>
</ol>
</li>
</ul>
</div>
),
children: [
{ value: "3-1-api", title: "3.1 使用 API 接口" },
{ value: "3-2-online", title: "3.2 使用在线服务" },
{ value: "3-3-new-api", title: "3.3 快速集成 New API" },
]
},
{
value: "usage",
title: "4. 使用 LINUX DO Credit 积分",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>您可以在任意支持 LINUX DO Credit 的平台下使用积分。在其他平台点击使用 LINUX DO Credit 积分时,会自动跳转到 LINUX DO Credit 的积分流转服务页面,您只需要确认积分流转信息无误,并选择使用 LINUX DO Credit 进行账户认证,即可完成整个交易服务。</p>
</div>
)
},
{
value: "fees",
title: "5. 服务(手续)费",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<h3 id="5-1-rules" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">5.1 规则说明</h3>
<p>为了更好的维持 LINUX DO Credit 平台的积分服务机制,保证社区积分的生态可持续发展,我们会按照规范进行不同程度的服务(手续)费用收取。</p>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>承担方:</strong>服务(手续)费默认<strong>由服务方承担</strong></li>
<li><strong>消费方使用:</strong>不会产生额外费用</li>
<li><strong>服务方实收:</strong>订单金额 - 服务(手续)费</li>
</ul>
<div className="mt-4">
<p className="font-mono text-xs mb-2 text-muted-foreground">计算公式:</p>
<CodeBlock
code={`手续费 = 订单金额 × 当前费率
商家实际到账 = 订单金额 - 手续费`}
language="text"
/>
</div>
<h3 id="5-2-dynamic" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">5.2 动态费率</h3>
<p>费率并非固定不变,会根据以下因素动态调整:</p>
<ul className="list-disc pl-4 md:pl-5 space-y-1">
<li>服务方平台等级</li>
<li>服务方平台积分</li>
<li>LINUX DO Credit 平台活动</li>
</ul>
</div>
),
children: [
{ value: "5-1-rules", title: "5.1 规则说明" },
{ value: "5-2-dynamic", title: "5.2 动态费率" },
]
},
{
value: "dispute",
title: "6. 争议处理",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>为了保障服务方与消费方的合法权益,当积分服务出现纠纷时,可使用争议功能。</p>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li>作为服务方,您需要及时响应消费方的争议请求:
<ol className="list-decimal pl-4 md:pl-5 mt-1 text-muted-foreground">
<li>在集市中心或通知中查看到 <strong>待处理的争议</strong></li>
<li>查看消费方理由,选择操作:
<ul className="list-[circle] pl-5 mt-1">
<li><strong>同意:</strong>认可消费方诉求,积分原路退回给消费方</li>
<li><strong>拒绝:</strong>如果您认为已履约,请提交相关证据</li>
</ul>
</li>
</ol>
</li>
</ul>
<div className="bg-yellow-500/10 border border-yellow-500/20 text-yellow-600 dark:text-yellow-400 px-3 py-2 rounded-lg mt-4">
<p className="m-0 text-sm">
<strong>重要:</strong>建议服务方与消费方优先沟通解决。长时间未处理的争议会由 LINUX DO Credit 平台介入仲裁,这可能会影响您的服务方信誉。
</p>
</div>
</div>
)
},
{
value: "community-balance",
title: "7. 社区积分",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>您的 LINUX DO Credit 平台基础积分主要由 <strong>社区积分 (Community Balance)</strong> 划转而来。</p>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>基本获取方式:</strong>通过在 LINUX DO 社区的活跃行为获得:
<ul className="list-[circle] pl-4 md:pl-5 mt-1 text-muted-foreground">
<li>获赞、解决问题等社区贡献</li>
<li>详情可见 <a href="https://linux.do/t/topic/1409175" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">LINUX DO 社区</a></li>
</ul>
</li>
<li><strong>划转规则:</strong>
<ul className="list-[circle] pl-4 md:pl-5 mt-1 text-muted-foreground">
<li>划转时间:社区积分每日凌晨 <strong>00:00</strong> 自动划转至可用余额</li>
<li>限制说明:划转前不可用于任何积分服务</li>
<li>服务费用:目前不收取任何划转 <strong>服务费</strong></li>
</ul>
</li>
</ul>
</div>
)
},
{
value: "settings",
title: "8. 账户设置",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>您可以在 <strong>设置 (Settings)</strong> 页面管理您的账户信息。</p>
<h3 className="text-base md:text-lg font-semibold text-foreground mt-4 mb-2">功能列表</h3>
<ul className="list-disc pl-4 md:pl-5 space-y-1">
<li><strong>个人资料:</strong>查看当前的账户信息和会员等级</li>
<li><strong>安全设置:</strong>修改认证密码</li>
<li><strong>外观设置:</strong>切换页面主题、界面外观</li>
</ul>
</div>
)
},
{
value: "scripts",
title: "9. 辅助脚本",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>为了方便用户随时查看当前的实时积分收入,我们提供了开源的 Userscript 脚本。</p>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>功能:</strong>在 LINUX DO 显示实时积分收入,支持拖拽,不影响界面。</li>
<li><strong>获取:</strong>仅需安装 Tampermonkey 插件即可使用。</li>
<li><strong>安装:</strong>
<a
href="https://linux.do/t/topic/1365853"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
「LINUX DO Credit」实时积分收入脚本
<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-external-link"><path d="M15 3h6v6" /><path d="M10 14 21 3" /><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></svg>
</a>
</li>
</ul>
<div className="bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mt-2">
<p className="text-xs text-muted-foreground m-0">
注:脚本完全开源且安全,仅通过官方 API 获取公开数据,不涉及任何敏感权限。
</p>
</div>
</div>
)
}
]
@@ -0,0 +1,416 @@
"use client"
import * as React from "react"
import Link from "next/link"
import { ArrowLeft, Calendar, Check, Copy, FileText } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { motion, useScroll, useSpring } from "motion/react"
import type { PolicySection } from "./types"
interface LegalPageLayoutProps {
title: string
lastUpdated: string
sections: PolicySection[]
description?: React.ReactNode
}
export function LegalPageLayout({
title,
lastUpdated,
sections,
description,
}: LegalPageLayoutProps) {
const [activeSection, setActiveSection] = React.useState<string>(sections[0]?.value || "")
const [hasCopied, setHasCopied] = React.useState(false)
const { scrollYProgress } = useScroll()
const contentRef = React.useRef<HTMLDivElement | null>(null)
const scaleX = useSpring(scrollYProgress, {
stiffness: 100,
damping: 30,
restDelta: 0.001
})
const allSectionIds = React.useMemo(() => {
return sections.flatMap(section => [
section.value,
...(section.children?.map(child => child.value) || [])
])
}, [sections])
React.useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveSection(entry.target.id)
}
})
},
{ rootMargin: "-20% 0px -35% 0px" }
)
allSectionIds.forEach((id) => {
const element = document.getElementById(id)
if (element) observer.observe(element)
})
return () => observer.disconnect()
}, [sections, allSectionIds])
const convertInlineNodes = (nodes: ChildNode[]): string => {
return nodes.map((node) => {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent || ""
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return ""
}
const element = node as HTMLElement
const tagName = element.tagName.toLowerCase()
if (tagName === "code") {
return `\`${element.textContent?.trim() || ""}\``
}
if (tagName === "strong" || tagName === "b") {
return `**${convertInlineNodes(Array.from(element.childNodes))}**`
}
if (tagName === "em" || tagName === "i") {
return `*${convertInlineNodes(Array.from(element.childNodes))}*`
}
if (tagName === "a") {
const text = convertInlineNodes(Array.from(element.childNodes)) || element.textContent || ""
const href = element.getAttribute("href") || ""
return href ? `[${text}](${href})` : text
}
if (tagName === "br") {
return "\n"
}
return convertInlineNodes(Array.from(element.childNodes))
}).join("")
}
const convertList = (
list: HTMLOListElement | HTMLUListElement,
indentLevel: number
): string => {
const isOrdered = list.tagName.toLowerCase() === "ol"
const items = Array.from(list.children).filter((child) => child.tagName.toLowerCase() === "li")
return items.map((item, index) => {
const prefix = isOrdered ? `${index + 1}. ` : "- "
const indent = " ".repeat(indentLevel)
const parts: string[] = []
const itemElement = item as HTMLElement
const childNodes = Array.from(itemElement.childNodes)
const nestedLists: HTMLElement[] = []
childNodes.forEach((child) => {
if (child.nodeType === Node.ELEMENT_NODE) {
const childElement = child as HTMLElement
if (childElement.tagName.toLowerCase() === "ul" || childElement.tagName.toLowerCase() === "ol") {
nestedLists.push(childElement)
return
}
}
parts.push(convertInlineNodes([child]).trim())
})
const baseLine = `${indent}${prefix}${parts.join("").trim()}`
const nestedMarkdown = nestedLists.map((nested) => convertList(nested as HTMLOListElement | HTMLUListElement, indentLevel + 1))
return [baseLine, ...nestedMarkdown].filter(Boolean).join("\n")
}).join("\n")
}
const convertTable = (table: HTMLTableElement): string => {
const rows = Array.from(table.querySelectorAll("tr")).map((row) =>
Array.from(row.querySelectorAll("th,td")).map((cell) => (cell.textContent || "").trim())
)
if (!rows.length) return ""
const header = rows[0]
const body = rows.slice(1)
const headerLine = `| ${header.join(" | ")} |`
const separatorLine = `| ${header.map(() => "---").join(" | ")} |`
const bodyLines = body.map((row) => `| ${row.join(" | ")} |`)
return [headerLine, separatorLine, ...bodyLines].join("\n")
}
const convertBlockNode = (node: HTMLElement, indentLevel: number): string => {
const tagName = node.tagName.toLowerCase()
if (tagName === "h3") return `### ${node.textContent?.trim() || ""}`
if (tagName === "h4") return `#### ${node.textContent?.trim() || ""}`
if (tagName === "p") return convertInlineNodes(Array.from(node.childNodes)).trim()
if (tagName === "ul" || tagName === "ol") return convertList(node as HTMLOListElement | HTMLUListElement, indentLevel)
if (tagName === "pre") {
const code = node.textContent || ""
return `\`\`\`\n${code.trim()}\n\`\`\``
}
if (tagName === "table") return convertTable(node as HTMLTableElement)
if (tagName === "div") {
const hasTable = node.querySelector("table")
const hasList = node.querySelector("ul,ol")
const hasPre = node.querySelector("pre")
if (hasTable || hasList || hasPre) {
return Array.from(node.children)
.map((child) => convertBlockNode(child as HTMLElement, indentLevel))
.filter(Boolean)
.join("\n\n")
}
const text = convertInlineNodes(Array.from(node.childNodes)).trim()
return text
}
return convertInlineNodes(Array.from(node.childNodes)).trim()
}
const buildMarkdown = (): string => {
const contentRoot = contentRef.current
if (!contentRoot) return ""
const lines: string[] = []
lines.push(`# ${title}`)
lines.push("")
lines.push(`最后更新:${lastUpdated}`)
if (description) {
const descriptionElement = contentRoot.querySelector("[data-docs-description]")
if (descriptionElement) {
const descriptionText = convertInlineNodes(Array.from(descriptionElement.childNodes)).trim()
if (descriptionText) {
lines.push("")
lines.push(descriptionText)
}
}
}
lines.push("")
const sectionNodes = Array.from(contentRoot.querySelectorAll("[data-docs-section]")) as HTMLElement[]
sectionNodes.forEach((section) => {
const heading = section.querySelector("h2")
if (heading?.textContent) {
lines.push(`## ${heading.textContent.trim()}`)
lines.push("")
}
const prose = section.querySelector("[data-docs-content]")
if (prose) {
const blocks = Array.from(prose.children)
.map((child) => convertBlockNode(child as HTMLElement, 0))
.filter(Boolean)
if (blocks.length) {
lines.push(blocks.join("\n\n"))
lines.push("")
}
}
})
return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n"
}
const handleCopyMarkdown = async () => {
const markdown = buildMarkdown()
if (!markdown) return
try {
await navigator.clipboard.writeText(markdown)
setHasCopied(true)
setTimeout(() => setHasCopied(false), 2000)
} catch {
const textArea = document.createElement("textarea")
textArea.value = markdown
textArea.style.position = "fixed"
textArea.style.opacity = "0"
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
document.execCommand("copy")
document.body.removeChild(textArea)
setHasCopied(true)
setTimeout(() => setHasCopied(false), 2000)
}
}
const scrollToSection = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
e.preventDefault()
const element = document.getElementById(id)
if (element) {
const offset = 80
const bodyRect = document.body.getBoundingClientRect().top
const elementRect = element.getBoundingClientRect().top
const elementPosition = elementRect - bodyRect
const offsetPosition = elementPosition - offset
window.scrollTo({
top: offsetPosition,
behavior: "smooth"
})
}
}
return (
<div className="min-h-screen bg-background relative selection:bg-primary/20">
<motion.div
className="fixed top-0 left-0 right-0 h-1 bg-primary origin-left z-50"
style={{ scaleX }}
/>
<div className="fixed inset-0 pointer-events-none">
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-primary/5 rounded-full blur-[120px] -translate-y-1/2" />
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-blue-500/5 rounded-full blur-[120px] translate-y-1/2" />
</div>
<div ref={contentRef} className="container relative z-10 max-w-7xl mx-auto px-4 md:px-6 py-8 lg:py-16">
<header className="mb-8 md:mb-12">
<Link href="/" className="inline-block mb-8">
<Button
variant="ghost"
className="gap-2 pl-0 text-muted-foreground hover:text-foreground hover:bg-transparent -ml-2"
>
<ArrowLeft className="w-4 h-4" />
返回首页
</Button>
</Link>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<h1 className="text-2xl md:text-3xl lg:text-5xl font-bold tracking-tight text-foreground mb-6">
{title}
</h1>
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground mb-8">
<span className="flex items-center gap-1.5 bg-muted/50 px-3 py-1 rounded-full border border-border/50">
<FileText className="w-3.5 h-3.5" />
{sections.length} 个内容
</span>
<span className="flex items-center gap-1.5 bg-muted/50 px-3 py-1 rounded-full border border-border/50">
<Calendar className="w-3.5 h-3.5" />
最后更新:{lastUpdated}
</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCopyMarkdown}
className="h-8 gap-2 text-xs"
>
{hasCopied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
{hasCopied ? "已复制" : "复制 Markdown"}
</Button>
</div>
{description && (
<div className="w-full p-4 md:p-6 rounded-2xl bg-muted/30 border border-border/50 backdrop-blur-sm overflow-hidden">
<div data-docs-description className="text-sm md:text-base text-muted-foreground leading-relaxed m-0 break-all">
{description}
</div>
</div>
)}
</motion.div>
</header>
<div className="grid lg:grid-cols-12 gap-8 lg:gap-12 relative">
<aside className="hidden lg:block lg:col-span-3">
<div className="sticky top-24">
<div className="space-y-2 max-h-[calc(100vh-8rem)] overflow-y-auto pr-4 scrollbar-thin">
<div className="text-sm font-semibold text-foreground mb-4 pl-3">目录</div>
<nav className="space-y-1">
{sections.map((section) => (
<div key={section.value} className="space-y-1">
<a
href={`#${ section.value }`}
onClick={(e) => scrollToSection(e, section.value)}
className={cn(
"group flex items-center justify-between py-2 px-3 text-sm rounded-lg transition-all duration-200",
activeSection === section.value
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
)}
>
<span className="truncate">{section.title}</span>
{activeSection === section.value && (
<motion.div
layoutId="active-dot"
className="w-1.5 h-1.5 rounded-full bg-primary"
/>
)}
</a>
{section.children && (
<div className="pl-4 space-y-1">
{section.children.map((child) => (
<a
key={child.value}
href={`#${ child.value }`}
onClick={(e) => scrollToSection(e, child.value)}
className={cn(
"block py-1.5 px-3 text-xs rounded-lg transition-all duration-200 truncate",
activeSection === child.value
? "text-primary font-medium bg-primary/5"
: "text-muted-foreground/80 hover:text-foreground hover:bg-muted/30"
)}
>
{child.title}
</a>
))}
</div>
)}
</div>
))}
</nav>
</div>
</div>
</aside>
<main className="lg:col-span-9 lg:col-start-4 space-y-8 md:space-y-16 pb-20">
{sections.map((section, index) => (
<motion.section
key={section.value}
id={section.value}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "100px" }}
transition={{ duration: 0.3 }}
className="scroll-mt-24 group"
data-docs-section
>
<div className="sticky top-0 z-20 flex items-center gap-4 py-6 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 mb-2 -mx-4 px-4 md:-mx-0 md:px-0">
<span className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 text-primary text-sm font-bold opacity-50 group-hover:opacity-100 transition-opacity flex-shrink-0">
{index + 1}
</span>
<h2 className="text-xl md:text-2xl font-semibold tracking-tight text-foreground group-hover:text-primary transition-colors duration-300">
{section.title.replace(/^\d+\.\s*/, '')}
</h2>
</div>
<div
data-docs-content
className={cn(
"prose prose-sm md:prose-base prose-neutral dark:prose-invert max-w-none",
"prose-p:text-muted-foreground prose-p:leading-7",
"prose-li:text-muted-foreground",
"prose-strong:text-foreground",
"pl-4 md:pl-12 border-l border-border/30 group-hover:border-primary/20 transition-colors duration-500"
)}>
{section.content}
</div>
</motion.section>
))}
<div className="pt-16 border-t border-border mt-20">
<div className="bg-muted/30 rounded-2xl p-8 text-center">
<p className="text-muted-foreground text-sm mb-4">
对这些内容有疑问?
</p>
<div className="flex justify-center gap-4">
<Link href="https://github.com/linux-do/credit/issues" target="_blank">
<Button variant="outline" className="h-9 text-xs">
联系支持
</Button>
</Link>
<Link href="https://linux.do" target="_blank">
<Button variant="outline" className="h-9 text-xs">
访问社区
</Button>
</Link>
</div>
</div>
</div>
</main>
</div>
</div >
</div >
)
}
+110
View File
@@ -0,0 +1,110 @@
import { type PolicySection } from "./types"
/**
* ------------------------------------------------------------------
* 隐私政策 (PRIVACY POLICY)
* ------------------------------------------------------------------
*/
export const privacySections: PolicySection[] = [
{
value: "collection-details",
title: "1. 详细信息收集说明",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>我们仅遵循合法、正当、必要的原则,收集为您提供服务所必需的信息。我们的数据收集范围严格限制如下:</p>
<div className="space-y-3">
<div>
<span className="font-semibold text-foreground">1.1 身份鉴权信息:</span>
<p className="mt-1 text-muted-foreground">当您通过 LINUX DO Connect 登录时,我们会获取您的社区 OpenID(唯一标识符)、加密后的用户名及头像 URL。<strong>我们不收集您的手机号、真实姓名或身份证件信息。</strong></p>
</div>
<div>
<span className="font-semibold text-foreground">1.2 服务日志信息:</span>
<p className="mt-1 text-muted-foreground">为保障系统运行安全及满足法律合规要求,我们会自动收集您的操作日志,包括 IP 地址、访问日期和时间、API 调用记录、User-Agent(浏览器/设备类型)。</p>
</div>
<div>
<span className="font-semibold text-foreground">1.3 交易与资产信息:</span>
<p className="mt-1 text-muted-foreground">若您使用支付功能,我们将记录您的商户订单号、交易金额、交易时间、交易状态摘要。这些信息是账务核对的必要依据。</p>
</div>
</div>
</div>
),
},
{
value: "storage-security",
title: "2. 数据存储与安全保护",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>我们深知数据安全的重要性,并采取业界领先的技术措施保护您的数据:</p>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>存储地点:</strong>依照法规要求,我们收集和产生的用户个人信息,<strong>存储在独立</strong>的服务器上。我们不会将您的数据传输至境外管辖区。</li>
<li><strong>加密技术:</strong>敏感数据(如 支付密码)在数据库中均采用高强度加密算法存储。数据传输全链路采用 SSL/TLS 1.3 协议进行加密,防止网络嗅探。</li>
<li><strong>隔离机制:</strong>本平台数据与外部网络物理隔离,且独立于 LINUX DO 社区论坛主数据库,确保单一系统故障不会波及全局数据安全。</li>
<li><strong>访问控制:</strong>我们实行严格的最小权限原则(Least Privilege),仅有核心运维人员经授权后方可访问必要的维护数据,且所有操作均有审计日志留存。</li>
</ul>
</div>
),
},
{
value: "usage-rules",
title: "3. 信息使用规范",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>我们收集的信息将仅用于以下目的:</p>
<ul className="list-disc pl-4 md:pl-5 space-y-1">
<li><strong>身份识别:</strong>用于确认您的社区身份,展示您的个人中心数据。</li>
<li><strong>业务功能:</strong>处理您的支付指令、API 请求、回调通知及账单生成。</li>
<li><strong>安全风控:</strong>利用 IP 及行为日志进行反作弊、反欺诈分析,识别恶意攻击行为,保护平台及其他用户的安全。</li>
<li><strong>客户支持:</strong>在您发起工单或申诉时,查询相关日志以协助您解决问题。</li>
</ul>
<p><strong>禁止用途:</strong>我们承诺<strong>绝不</strong>利用您的数据进行用户画像分析、个性化广告推送或商业营销。</p>
</div>
),
},
{
value: "sharing-disclosure",
title: "4. 信息共享与对外披露",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p><strong>4.1 共享原则:</strong>我们坚持<strong>数据零共享</strong>策略。除以下极端情况外,我们不会向任何第三方(包括且不限于关联公司、支付宝、微信、银行、广告商)共享您的个人信息:</p>
<ul className="list-disc pl-4 md:pl-5 space-y-1 text-muted-foreground">
<li>事先获得您的明确授权或同意;</li>
<li>根据适用的法律法规、法律程序的要求、强制性的行政或司法要求所必须的情况下进行提供。</li>
</ul>
<p><strong>4.2 转让与公开披露:</strong>我们不会将您的个人信息转让给任何公司、组织和个人。我们仅在法律法规强制要求,或为了保护平台及用户与公众的人身财产安全免受侵害时,才会公开披露您的信息。</p>
</div>
),
},
{
value: "user-rights",
title: "5. 您的权利与数据管理",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>依照《中华人民共和国个人信息保护法》,您对您的个人信息享有完整的控制权:</p>
<div className="space-y-3">
<div>
<span className="font-semibold text-foreground">5.1 查阅与复制权:</span>
<p className="mt-1 text-muted-foreground">您可以随时登录开发者后台,查阅您的概览信息、API Key 状态及历史交易账单。您可以通过“导出账单”功能获取您的数据副本。</p>
</div>
<div>
<span className="font-semibold text-foreground">5.2 删除与遗忘权:</span>
<p className="mt-1 text-muted-foreground">若您决定停止使用本服务,在结清所有应付费用及余额后,您可以申请<strong>注销账户</strong>。注销后,我们将立即删除您的所有敏感信息或进行匿名化处理,法律法规规定需保留的日志除外。</p>
</div>
<div>
<span className="font-semibold text-foreground">5.3 纠正与更正权:</span>
<p className="mt-1 text-muted-foreground">若您发现您的信息有误,您有权要求我们更正或补充。您可以在设置页面直接修改您的信息,或通过客服提交工单。</p>
</div>
</div>
</div>
),
},
{
value: "policy-update",
title: "6. 政策更新与通知",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>随着业务的发展或法律法规的变动,我们可能会适时修订本《隐私政策》。</p>
<p>当条款发生重大变更时(例如收集范围扩大、使用目的改变),我们会通过站内信、公告或弹窗等显著方式通知您。若您在政策更新后继续使用本服务,即表示您同意接受更新后的隐私政策约束。</p>
</div>
),
},
]
+115
View File
@@ -0,0 +1,115 @@
import { type PolicySection } from "./types"
export const TERMS_LAST_UPDATED = "2025-12-22"
/**
* ------------------------------------------------------------------
* 服务条款 (TERMS OF SERVICE)
* ------------------------------------------------------------------
*/
export const termsSections: PolicySection[] = [
{
value: "contract-establishment",
title: "1. 缔约申明与服务综述",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p><strong>1.1 缔约主体:</strong>本《服务协议》(以下简称“本协议”)是您(以下亦称“社区用户”、“开发者”、“消费方”或“服务方”)与 LINUX DO Credit 平台运营团队(以下简称“平台”、“我们”)之间关于使用平台服务所订立的具有法律约束力的契约。</p>
<p><strong>1.2 审慎阅读:</strong>请您务必审慎阅读、充分理解各条款内容,特别是<strong>免除或者限制责任的条款、争议解决和法律适用条款</strong>。各免责或限责条款将以粗体标识,您应重点阅读。如您不同意本协议的任何内容,请立即停止注册或使用本服务。</p>
<p><strong>1.3 协议构成:</strong>本协议内容包括协议正文及所有我们已经发布或将来可能发布的各类规则、声明、说明。所有规则为本协议不可分割的组成部分,与协议正文具有同等法律效力。</p>
</div>
),
},
{
value: "service-definition",
title: "2. 服务定义与性质界定",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p><strong>2.1 社区技术服务:</strong>LINUX DO Credit 是基于 LINUX DO 社区生态构建的独立价值交换协议与技术系统。我们仅提供 API 接口调用、数据路由、账单管理等<strong>纯技术服务</strong>。</p>
<p><strong>2.2 非金融机构申明:</strong></p>
<ul className="list-disc pl-4 md:pl-5 space-y-2">
<li><strong>非银行机构:</strong>我们不是商业银行、持牌支付机构(如支付宝、微信支付、银联)或清算机构。</li>
<li><strong>不提供资金沉淀:</strong>平台不设立资金池,不提供真实法币存取款、转账汇款或支付结算服务。所有涉及资金流转的行为均发生于社区用户与社区支付渠道之间,不涉及真实货币。</li>
<li><strong>不提供金融服务:</strong>平台不提供任何金融服务,包括但不限于贷款、融资、投资、理财、保险等金融服务。</li>
<li><strong>不提供积分兑现:</strong>平台不提供任何积分兑换服务,包括但不限于积分兑换为真实货币、积分兑换为实物商品、积分兑换为服务等。</li>
<li><strong>不提供真实货币交易:</strong>平台不提供任何真实货币交易服务,包括但不限于真实货币交易为积分、真实货币交易为虚拟资产、真实货币交易为服务等。</li>
</ul>
<p><strong>2.3 服务限定:</strong>本平台建议用于仅支持虚拟商品、软件授权、技术咨询、会员订阅等<strong>无实物交付</strong>的场景。关于实物电商、物流发货或涉及线下履约的商业场景造成的任何后果我们概不负责,请妥善保管好自己的个人财产,谨防上当受骗。</p>
</div>
),
},
{
value: "account-specifications",
title: "3. 账号注册与使用规范",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p><strong>3.1 账号体系:</strong>本平台采用 LINUX DO Connect (OAuth) 授权登录体系。您必须拥有合法、有效的 LINUX DO 社区账号方可使用本服务。您的平台账号权益(包括但不限于信誉分、等级)与社区账号严格绑定。</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>操作真实性:</strong>您承诺注册和使用的账号是您本人操作。严禁恶意注册、挂机脚本、自动化程序注册等破坏平台公平性的行为。</li>
</ul>
<p><strong>3.3 账号安全责任:</strong>您应妥善保管您账号的支付密码、Client ID 和 Client Secret。<strong>因您保管不善可能导致账号被他人非法使用、资金损失或数据泄露的责任,由您自行承担。</strong>如发现账号异常,请立即通知我们进行冻结。</p>
</div>
),
},
{
value: "user-conduct",
title: "4. 用户行为准则(负面清单)",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p>您在使用本服务时,必须严格遵守《中华人民共和国网络安全法》、《计算机信息网络国际联网安全保护管理办法》等法律法规。<strong>严禁利用本平台从事以下活动(“红线条款”):</strong></p>
<div className="bg-red-500/5 border border-red-500/20 rounded-lg p-3 md:p-4 space-y-3">
<ul className="list-disc pl-4 md:pl-5 space-y-2 text-red-600 dark:text-red-400 font-medium">
<li><strong>危害国家安全:</strong>反对宪法所确定的基本原则、危害国家安全、泄露国家秘密、颠覆国家政权、破坏国家统一的;</li>
<li><strong>非法信息服务:</strong>黑客攻击工具、DDoS 攻击服务、服务器爆破等其他非法信息服务平台;</li>
<li><strong>黄赌毒关联:</strong>制作、复制、发布、传播淫秽、色情、赌博、暴力、凶杀、恐怖或者教唆犯罪的;</li>
<li><strong>侵犯知识产权:</strong>销售盗版软件、盗版影视资源、非法游戏外挂、私服、黑号、社工库数据等;</li>
<li><strong>欺诈与虚假:</strong>进行电信诈骗、金融诈骗、传销、虚假广告虚假交易等;</li>
<li><strong>其他违法信息:</strong>涉及散布谣言、宣扬邪教/封建迷信、侮辱/诽谤他人、侵害他人合法权益的。</li>
</ul>
</div>
<p><strong>违约处理:</strong>一旦发现您违反上述规定,平台有权不经通知<strong>立即永久封禁您的账号、拦截所有 API 请求、冻结账户内所有关联价值,并依法向公安机关、网安部门移交相关线索。</strong></p>
</div>
),
},
{
value: "virtual-assets",
title: "5. 虚拟资产与交易规则",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p><strong>5.1 资产性质:</strong>平台内流转的“余额”、“积分”等均为<strong>社区虚拟积分</strong>,仅代表您在社区生态内的活跃度或贡献值。它们<strong>不具有任何货币属性</strong>,严禁兑换为法定货币,也不可用于任何非平台许可的商业交易。</p>
<p><strong>5.2 交易不可逆:</strong>鉴于网络技术的实时性,<strong>一旦积分消耗或划转指令被执行,该操作即不可撤销。</strong>请您在确认相关积分活动前,务必仔细核对服务方信息。</p>
<p><strong>5.3 规则说明:</strong>为营造良好的社区积分环境,平台有权收取一定的服务费(以积分为结算单位)进行调控,具体规则以控制台公示为准。平台保留根据社区运营状况调整积分规则的权利。</p>
</div>
),
},
{
value: "liability-limitation",
title: "6. 免责声明与不可抗力",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p><strong>6.1 基础免责:</strong>本平台服务按“现状”(As-Is)及“现有”(As-Available)状态提供。我们不保证服务一定能满足您的要求,也不保证服务不会中断,对服务的及时性、安全性、准确性都不作担保。</p>
<p><strong>6.2 不可抗力:</strong>对于因以下原因导致的服务中断、数据丢失或账号损失,平台不承担赔偿责任:</p>
<ul className="list-disc pl-4 md:pl-5 space-y-1 text-muted-foreground">
<li>自然灾害(台风、地震、海啸、洪水等);</li>
<li>政府行为、法律法规或政策调整、行政命令;</li>
<li>电信部门技术调整、通讯线路中断、海底光缆故障;</li>
<li>黑客攻击、计算机病毒侵入或发作、技术性故障;</li>
<li>社区维护、系统升级(我们将尽可能提前公告)。</li>
</ul>
<p><strong>6.3 责任上限:</strong>在任何情况下,平台对您所承担的违约赔偿责任总额不超过您在违约行为发生前 1 个月内向平台支付的费用总额。</p>
</div>
),
},
{
value: "governing-law",
title: "7. 法律适用与争议解决",
content: (
<div className="space-y-4 text-sm leading-relaxed">
<p><strong>7.1 法律适用:</strong>本协议的订立、执行、解释及争议的解决均适用<strong>中华人民共和国法律</strong>(不包括港澳台地区法律及冲突法)。</p>
<p><strong>7.2 争议解决:</strong>若您和平台发生任何争议或纠纷,首先应友好协商解决;协商不成的,您同意将纠纷或争议提交至<strong>平台运营团队所在地有管辖权的人民法院</strong>管辖。</p>
<p><strong>7.3 协议变更:</strong>我们有权根据法律法规变化或业务发展需要修改本协议。变更后的协议将在平台公示,自公示之日起生效。若您继续使用服务,视为您已接受修订后的协议。</p>
</div>
),
},
]
+11
View File
@@ -0,0 +1,11 @@
import { type ReactNode } from "react"
export type PolicySection = {
value: string
title: string
content: ReactNode
children?: {
value: string
title: string
}[]
}
@@ -0,0 +1,760 @@
"use client"
import * as React from "react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { Spinner } from "@/components/ui/spinner"
import { AlertTriangle, X, Layers, Banknote, TicketSlash } from "lucide-react"
import { formatDateTime } from "@/lib/utils"
import type { Order, DisputeWithOrder, Dispute } from "@/lib/services"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { DisputeService } from "@/lib/services"
import { toast } from "sonner"
import { useIsMobile } from "@/hooks/use-mobile"
import { usePublicConfig } from "@/hooks/use-public-config"
import { useTransaction } from "@/contexts/transaction-context"
import { useUser } from "@/contexts/user-context"
import { Textarea } from "@/components/ui/textarea"
import { typeConfig } from "@/components/common/general/table-filter"
import { VisuallyHidden } from "@radix-ui/react-visually-hidden"
type DisputeViewer = 'payer' | 'payee'
/**
* 活动详情弹窗
*/
export function OrderDetailDialog({ order }: { order: Order }) {
const [open, setOpen] = useState(false)
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-1 text-xs rounded-full"
onClick={() => setOpen(true)}
>
<Banknote className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>查看详情</p>
</TooltipContent>
</Tooltip>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent showCloseButton={false} className="bg-transparent border-0 shadow-none p-0 w-100">
<VisuallyHidden>
<DialogHeader>
<DialogTitle>积分流转详情</DialogTitle>
<DialogDescription>查看积分活动的详细信息</DialogDescription>
</DialogHeader>
</VisuallyHidden>
<div className="p-5 space-y-5 bg-card border border-border/50 rounded-lg shadow-xl">
<div className="flex flex-col items-center space-y-2 pb-4 border-b-2 border-dashed border-border/50">
<h3 className="font-bold text-2xl tracking-wider uppercase">{order.app_name || 'RECEIPT'}</h3>
<p className="text-[10px] text-muted-foreground font-mono uppercase tracking-widest">
{formatDateTime(order.created_at)}
</p>
</div>
<div className="text-center py-2">
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">Total Credits</p>
<div className="text-4xl font-black tracking-tighter flex items-start justify-center gap-1">
{parseFloat(order.amount).toFixed(2)}
</div>
</div>
<div className="space-y-0 text-sm border-y-2 border-dashed border-border/50 py-4">
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
<span className="text-muted-foreground text-xs uppercase font-medium">编号</span>
<span className="font-mono text-xs font-medium">{order.order_no}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
<span className="text-muted-foreground text-xs uppercase font-medium">类型</span>
<span className="text-xs font-medium">{typeConfig[order.type]?.label || order.type}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
<span className="text-muted-foreground text-xs uppercase font-medium">消费方</span>
<span className="text-xs font-medium">{order.payer_username}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
<span className="text-muted-foreground text-xs uppercase font-medium">服务方</span>
<span className="text-xs font-medium">{order.payee_username}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
<span className="text-muted-foreground text-xs uppercase font-medium">备注</span>
<span className="text-xs font-medium">{order.remark}</span>
</div>
{(order.status === 'success' || order.status === 'refund') && (
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
<span className="text-muted-foreground text-xs uppercase font-medium">时间</span>
<span className="font-mono text-xs">{formatDateTime(order.trade_time)}</span>
</div>
)}
</div>
<div className="pt-2 flex flex-col items-center space-y-3">
<div className="h-8 w-full max-w-[200px] flex items-end justify-between opacity-40">
{[...Array(30)].map((_, i) => (
<div
key={i}
className="bg-foreground w-[2px]"
style={{ height: `${ Math.max(40, Math.random() * 100) }%` }}
/>
))}
</div>
<p className="text-[10px] text-muted-foreground font-mono tracking-wider text-center">
LINUX DO Credit
</p>
</div>
</div>
</DialogContent>
</Dialog>
</>
)
}
/**
* 发起争议弹窗
*/
export function CreateDisputeDialog({ order, onSuccess }: { order: Order; onSuccess?: () => void }) {
const isMobile = useIsMobile()
const { config: publicConfig, loading: configLoading, error: configError } = usePublicConfig()
const { updateOrderStatus } = useTransaction()
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [reason, setReason] = useState("")
const resetForm = () => {
setReason("")
}
const handleButtonClick = async () => {
if (configLoading) {
try {
await new Promise(resolve => setTimeout(resolve, 100))
if (configLoading) {
toast.error('配置加载中', { description: '请稍候再试' })
return
}
} catch {
toast.error('配置加载失败', { description: '请刷新页面重试' })
return
}
}
if (configError || !publicConfig) {
toast.error('无法获取配置', { description: configError?.message || '请刷新页面重试' })
return
}
const tradeTime = new Date(order.trade_time)
const expiryTime = new Date(tradeTime.getTime() + publicConfig.dispute_time_window_hours * 60 * 60 * 1000)
const now = new Date()
if (now > expiryTime) {
toast.error('无法发起争议', { description: '已超过最晚发起争议时间' })
return
}
setOpen(true)
}
const handleOpenChange = (newOpen: boolean) => {
if (newOpen && !loading) {
resetForm()
}
setOpen(newOpen)
}
const handleCreateDispute = async () => {
if (!reason.trim()) {
toast.error('表单验证失败', { description: '请填写争议原因' })
return
}
if (reason.length > 100) {
toast.error('表单验证失败', { description: '争议原因不能超过 100 个字符' })
return
}
try {
setLoading(true)
const dispute = await DisputeService.createDispute({ order_id: order.id, reason: reason.trim() })
toast.success('争议已发起', { description: '请等待服务方处理' })
updateOrderStatus(order.id, {
status: 'disputing',
dispute_id: dispute.id
})
setOpen(false)
onSuccess?.()
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : '发起争议失败'
toast.error('发起争议失败', { description: errorMessage })
} finally {
setLoading(false)
}
}
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-1 text-xs rounded-full text-muted-foreground hover:text-foreground"
onClick={handleButtonClick}
>
<AlertTriangle className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>发起争议</p>
</TooltipContent>
</Tooltip>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>发起争议</DialogTitle>
<DialogDescription>
如果您对该积分活动有疑问,可以发起争议,服务方将在规定时间内处理。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="reason" className={isMobile ? "text-sm" : ""}>
争议原因
</Label>
<Textarea
id="reason"
placeholder="请详细描述您遇到的问题..."
maxLength={100}
value={reason}
onChange={(e) => setReason(e.target.value)}
disabled={loading}
className={isMobile ? "text-sm" : ""}
/>
<p className="text-xs text-muted-foreground text-right">
{reason.length}/100
</p>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost" disabled={loading} className="h-8 text-xs">
取消
</Button>
</DialogClose>
<Button
onClick={(e) => { e.preventDefault(); handleCreateDispute() }}
disabled={loading}
className="bg-primary h-8 text-xs"
>
{loading ? <><Spinner /> 提交中</> : '确认发起'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
/**
* 取消争议弹窗
*/
export function CancelDisputeDialog({ order, onSuccess }: { order: Order; onSuccess?: () => void }) {
const { updateOrderStatus } = useTransaction()
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const handleCancelDispute = async () => {
try {
setLoading(true)
if (!order.dispute_id) {
toast.error('取消争议失败', { description: '未找到争议记录' })
return
}
await DisputeService.closeDispute({ dispute_id: order.dispute_id })
toast.success('争议已取消', { description: '争议已成功取消' })
updateOrderStatus(order.id, { status: 'success' })
setOpen(false)
onSuccess?.()
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : '取消争议失败'
toast.error('取消争议失败', { description: errorMessage })
} finally {
setLoading(false)
}
}
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-1 text-xs rounded-full text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => setOpen(true)}
>
<X className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>争议正在进行中,点击取消</p>
</TooltipContent>
</Tooltip>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>争议取消</DialogTitle>
<DialogDescription>
您确定取消当前的争议吗?取消后争议将恢复正常。
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost" disabled={loading} className="h-8 text-xs">
取消
</Button>
</DialogClose>
<Button
onClick={(e) => { e.preventDefault(); handleCancelDispute() }}
disabled={loading}
className="bg-primary h-8 text-xs"
>
{loading ? <><Spinner /> 取消中</> : '确认取消'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
/**
* 查看争议历史弹窗
*/
export function ViewDisputeHistoryDialog({ order, viewer = 'payer' }: { order: Order; viewer?: DisputeViewer }) {
const [open, setOpen] = useState(false)
const [disputeHistory, setDisputeHistory] = useState<Dispute | null>(null)
const [fetchingHistory, setFetchingHistory] = useState(false)
const disputeConfig = React.useMemo(() => {
switch (order.status) {
case 'refused':
return {
tooltip: '争议已拒绝,点击查看',
timelineText: '服务方驳回争议',
isRed: true,
showTimestamp: true,
showContent: false,
content: null
}
case 'refund':
return {
tooltip: '争议已退款',
timelineText: '服务方已退款',
isRed: false,
showTimestamp: true,
showContent: true,
content: '退款已完成'
}
case 'disputing':
return {
tooltip: '争议处理中,点击查看',
timelineText: '争议进行中,等待服务方处理',
isRed: false,
showTimestamp: false,
showContent: false,
content: null
}
}
}, [order.status])
if (!disputeConfig) return null
const resetForm = () => {
setDisputeHistory(null)
}
const fetchDisputeHistory = async () => {
try {
setFetchingHistory(true)
if (!order.dispute_id) {
toast.error('获取争议记录失败', { description: '未找到争议记录' })
return
}
const params = {
page: 1,
page_size: 1,
dispute_id: order.dispute_id
}
const res = viewer === 'payee'
? await DisputeService.listMerchantDisputes(params)
: await DisputeService.listDisputes(params)
if (res.disputes && res.disputes.length > 0) {
setDisputeHistory(res.disputes[0])
} else {
toast.error('未找到争议记录')
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '获取争议记录失败'
toast.error('获取争议记录失败', { description: errorMessage })
} finally {
setFetchingHistory(false)
}
}
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
setOpen(true)
resetForm()
fetchDisputeHistory()
} else {
setOpen(false)
}
}
const parseDisputeReason = (fullReason: string) => {
const match = fullReason.match(/^(.*?)\s*\[(?:服务方|受托方)拒绝理由:\s*(.*?)\]$/)
if (match) {
return { userReason: match[1].trim(), merchantReason: match[2].trim() }
}
return { userReason: fullReason, merchantReason: null }
}
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-1 text-xs rounded-full text-orange-600 hover:text-orange-700 hover:bg-orange-50"
onClick={() => handleOpenChange(true)}
>
<Layers className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>{disputeConfig.tooltip}</p>
</TooltipContent>
</Tooltip>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>争议详情</DialogTitle>
<DialogDescription>查看争议处理记录</DialogDescription>
</DialogHeader>
{fetchingHistory ? (
<div className="py-8 flex justify-center">
<Spinner className="size-6" />
</div>
) : disputeHistory ? (
<div className="py-4 relative pl-4 border-l border-border/50 space-y-8 ml-2">
<div className="relative">
<div className="absolute -left-[21px] top-1 h-2.5 w-2.5 rounded-full bg-primary ring-4 ring-background" />
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">消费方发起争议</span>
<span className="text-xs text-muted-foreground">{formatDateTime(disputeHistory.created_at)}</span>
</div>
<div className="text-sm text-muted-foreground bg-muted/50 p-3 rounded-md">
{parseDisputeReason(disputeHistory.reason).userReason}
</div>
</div>
</div>
<div className="relative">
<div className={`absolute -left-[21px] top-1 h-2.5 w-2.5 rounded-full ${ disputeConfig.isRed ? 'bg-destructive' : 'bg-primary' } ring-4 ring-background`} />
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className={`text-sm font-medium ${ disputeConfig.isRed ? 'text-destructive' : '' }`}>
{disputeConfig.timelineText}
</span>
{disputeConfig.showTimestamp && (
<span className="text-xs text-muted-foreground">
{formatDateTime(disputeHistory.updated_at)}
</span>
)}
</div>
{order.status === 'refused' && (
<div className="text-sm text-muted-foreground bg-destructive/5 border border-destructive/10 p-3 rounded-md">
{parseDisputeReason(disputeHistory.reason).merchantReason || "未提供拒绝理由"}
</div>
)}
{order.status === 'refund' && disputeConfig.showContent && disputeConfig.content && (
<div className="text-sm text-muted-foreground bg-muted/50 p-3 rounded-md">
{disputeConfig.content}
</div>
)}
</div>
</div>
</div>
) : (
<div className="py-8 text-center text-muted-foreground text-sm">
无法加载争议记录
</div>
)}
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost" className="h-8 text-xs">
关闭
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
/**
* 退款审核活动弹窗
*/
export function RefundReviewDialog({ order, onSuccess }: { order: Order; onSuccess?: () => void }) {
const { updateOrderStatus } = useTransaction()
const { refetch: refetchUser } = useUser()
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [action, setAction] = useState<'refund' | 'closed' | null>(null)
const [reason, setReason] = useState("")
const [disputeInfo, setDisputeInfo] = useState<DisputeWithOrder | null>(null)
const [fetchingDispute, setFetchingDispute] = useState(false)
const resetForm = () => {
setAction(null)
setReason("")
setDisputeInfo(null)
}
const fetchDisputeInfo = async () => {
try {
setFetchingDispute(true)
if (!order.dispute_id) {
toast.error('获取争议详情失败', { description: '未找到争议记录' })
return
}
const res = await DisputeService.listMerchantDisputes({
page: 1,
page_size: 1,
dispute_id: order.dispute_id
})
if (res.disputes && res.disputes.length > 0) {
setDisputeInfo(res.disputes[0])
} else {
toast.error('获取争议详情失败', { description: '未找到争议记录' })
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '获取争议详情失败'
toast.error('获取争议详情失败', { description: errorMessage })
} finally {
setFetchingDispute(false)
}
}
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
setOpen(true)
resetForm()
fetchDisputeInfo()
} else if (!loading) {
setOpen(false)
}
}
const handleSubmit = async () => {
if (!action) {
toast.error('请选择处理方式')
return
}
if (action === 'closed' && !reason.trim()) {
toast.error('请填写拒绝原因')
return
}
if (reason.length > 100) {
toast.error('拒绝理由不能超过 100 个字符')
return
}
try {
setLoading(true)
if (!order.dispute_id) {
toast.error('处理失败', { description: '未找到争议记录' })
return
}
await DisputeService.refundReview({
dispute_id: order.dispute_id,
status: action,
reason: action === 'closed' ? reason.trim() : undefined,
})
toast.success('处理成功', {
description: action === 'refund' ? '已同意积分退回,争议结束' : '已拒绝积分退回,此积分活动正常进行'
})
updateOrderStatus(order.id, {
status: action === 'refund' ? 'refund' : 'refused'
})
setOpen(false)
refetchUser()
onSuccess?.()
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : '处理争议失败'
toast.error('处理争议失败', { description: errorMessage })
} finally {
setLoading(false)
}
}
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-1 text-xs rounded-full text-yellow-600 hover:text-yellow-700 hover:bg-yellow-50"
onClick={() => {
setOpen(true)
fetchDisputeInfo()
}}
>
<AlertTriangle className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>处理争议</p>
</TooltipContent>
</Tooltip>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>处理争议</DialogTitle>
<DialogDescription>
请审核此争议。您可以选择同意或拒绝退回积分。
</DialogDescription>
</DialogHeader>
{fetchingDispute ? (
<div className="py-8 flex justify-center">
<Spinner className="size-6" />
</div>
) : (
<div className="space-y-4 py-2">
{disputeInfo && (
<div className="rounded-md bg-muted/40 p-3 text-sm">
<div className="flex items-center gap-2 mb-2 text-muted-foreground text-xs">
<span>发起争议理由</span>
<span>•</span>
<span>{formatDateTime(disputeInfo.created_at)}</span>
</div>
<p className="text-sm font-medium leading-relaxed">
{disputeInfo.reason}
</p>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<Button
variant="outline"
className={`h-auto py-2 flex flex-col gap-1 hover:bg-transparent hover:border-primary ${ action === 'refund' && 'border-primary text-primary' }`}
onClick={() => setAction('refund')}
>
<div className="flex items-center gap-2">
<Banknote className="h-4 w-4" />
<span className="font-semibold">同意积分退回</span>
</div>
<span className="text-[10px] text-muted-foreground font-normal">积分全额退回,争议结束</span>
</Button>
<Button
variant="outline"
className={`h-auto py-2 flex flex-col gap-1 hover:bg-transparent hover:border-destructive ${ action === 'closed' && 'border-destructive text-destructive' }`}
onClick={() => setAction('closed')}
>
<div className="flex items-center gap-2">
<TicketSlash className="h-4 w-4" />
<span className="font-semibold">拒绝积分退回</span>
</div>
<span className="text-[10px] text-muted-foreground font-normal">拒绝积分退回,此积分活动正常进行</span>
</Button>
</div>
{action === 'closed' && (
<div className="space-y-2 animate-in fade-in slide-in-from-top-2 pt-2">
<Label htmlFor="reject-reason" className="text-xs font-medium">
拒绝原因 <span className="text-destructive">*</span>
</Label>
<Textarea
id="reject-reason"
placeholder="请说明拒绝退回的原因..."
maxLength={100}
value={reason}
onChange={(e) => setReason(e.target.value)}
disabled={loading}
className="h-9 text-sm"
/>
<p className="text-[10px] text-muted-foreground text-right">
{reason.length}/100
</p>
</div>
)}
</div>
)}
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost" disabled={loading} className="h-8 text-xs">
取消
</Button>
</DialogClose>
<Button
onClick={(e) => { e.preventDefault(); handleSubmit() }}
disabled={loading || !action}
className="bg-primary h-8 text-xs"
>
{loading ? <><Spinner /> 提交中</> : '确认处理'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
@@ -0,0 +1,241 @@
import * as React from "react"
import { useState, useEffect } from "react"
import { toast } from "sonner"
import { TablePagination, FilterSelect } from "@/components/common/general/table-filter"
import { Inbox, ListRestart, Gift, Coins, Check, Copy, ExternalLink } from "lucide-react"
import { EmptyStateWithBorder } from "@/components/layout/empty"
import { LoadingStateWithBorder } from "@/components/layout/loading"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { formatDateTime } from "@/lib/utils"
import type { RedEnvelope } from "@/lib/services"
export interface ListDataProps {
fetchData: (params: { page: number, page_size: number, type: string }) => Promise<{ list: RedEnvelope[], total: number }>
tabs: { value: string, label: string, color?: string }[]
defaultTab?: string
pageSize?: number
className?: string
emptyState?: (tab: string) => React.ReactNode
refreshTrigger?: number
}
/**
* 红包列表数据组件
*
* 提供分页、标签筛选、数据加载状态管理和网格布局,专门用于展示红包列表
*/
export function ListData({
fetchData,
tabs,
defaultTab,
pageSize = 20,
className,
emptyState,
refreshTrigger = 0
}: ListDataProps) {
const [activeTab, setActiveTab] = useState<string>(defaultTab || tabs[0]?.value)
/* 数据状态 */
const [items, setItems] = useState<RedEnvelope[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const [page, setPage] = useState(1)
const [currentPageSize, setCurrentPageSize] = useState(pageSize)
const [copiedId, setCopiedId] = useState<string | null>(null)
/* 缓存页码 */
const loadData = async (type: string, p: number, s: number) => {
setLoading(true)
try {
const result = await fetchData({ page: p, page_size: s, type })
setItems(result.list)
setTotal(result.total)
} catch (error) {
toast.error('加载数据失败')
console.error(error)
} finally {
setLoading(false)
}
}
/* 初始加载和Tab切换 */
useEffect(() => {
setPage(1)
loadData(activeTab, 1, currentPageSize)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, refreshTrigger])
const handlePageChange = (p: number) => {
setPage(p)
loadData(activeTab, p, currentPageSize)
}
const handlePageSizeChange = (s: number) => {
setCurrentPageSize(s)
setPage(1)
loadData(activeTab, 1, s)
}
const totalPages = Math.ceil(total / currentPageSize)
const getEnvelopeLink = (id: string) => {
if (typeof window !== 'undefined') {
return `${ window.location.origin }/redenvelope/${ id }`
}
return `/redenvelope/${ id }`
}
const handleCopyLink = async (link: string, envelopeId: string) => {
try {
await navigator.clipboard.writeText(link)
setCopiedId(envelopeId)
toast.success("链接已复制")
setTimeout(() => setCopiedId(null), 2000)
} catch {
toast.error("复制失败")
}
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'active':
return <Badge variant="outline" className="text-[10px] px-1 py-0 h-4 leading-none text-green-600 bg-green-50/50 border-green-200">进行中</Badge>
case 'finished':
return <Badge variant="outline" className="text-[10px] px-1 py-0 h-4 leading-none text-muted-foreground bg-muted/50 border-dashed">已领完</Badge>
case 'expired':
return <Badge variant="outline" className="text-[10px] px-1 py-0 h-4 leading-none text-red-500 bg-red-50/50 border-red-200">已过期</Badge>
default:
return <Badge variant="outline" className="text-[10px] px-1 py-0 h-4 leading-none">{status}</Badge>
}
}
/* 默认空状态 */
const defaultEmptyState = (
<EmptyStateWithBorder
icon={Inbox}
description="暂无数据"
/>
)
return (
<div className={className}>
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 mb-6">
<div className="flex items-center gap-2 flex-wrap">
<FilterSelect<string>
label="视图"
selectedValues={[activeTab]}
options={tabs.reduce((acc, tab) => ({
...acc,
[tab.value]: { label: tab.label, color: tab.color }
}), {})}
onToggleValue={(v) => setActiveTab(v)}
/>
</div>
<TablePagination
currentPage={totalPages > 0 ? page : 0}
totalPages={totalPages}
total={total}
pageSize={currentPageSize}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
onRefresh={() => loadData(activeTab, page, currentPageSize)}
loading={loading}
/>
</div>
<div className="space-y-4">
{loading && items.length === 0 ? (
<LoadingStateWithBorder icon={ListRestart} />
) : items.length === 0 ? (
emptyState ? emptyState(activeTab) : defaultEmptyState
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{items.map((envelope) => {
const isSent = activeTab === 'sent'
const link = getEnvelopeLink(envelope.id)
const claimedCount = envelope.total_count - envelope.remaining_count
return (
<Card
key={envelope.id}
className="group border-dashed hover:bg-muted/30 transition-all bg-card/50 shadow-none"
>
<CardContent className="px-4 py-1">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2.5">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 border ${ isSent
? "bg-red-50 text-red-600 border-red-100 dark:bg-red-900/20 dark:text-red-400 dark:border-red-900/30"
: "bg-amber-50 text-amber-600 border-amber-100 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-900/30"
}`}>
{isSent ? <Gift size={16} /> : <Coins size={16} />}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-xs text-foreground truncate select-none">
{isSent
? (envelope.type === 'random' ? '拼手气红包' : '普通红包')
: envelope.creator_username
}
</span>
{getStatusBadge(envelope.status)}
</div>
<div className="text-[10px] text-muted-foreground truncate max-w-[140px] mt-0.5">
{envelope.greeting || "恭喜发财,大吉大利"}
</div>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-2 mb-2.5 px-2.5 py-2 bg-muted/40 rounded-md text-xs">
<div>
<div className="text-[10px] text-muted-foreground/70 mb-0.5 scale-95 origin-left">总金额</div>
<div className="font-mono font-medium text-foreground">{envelope.total_amount}</div>
</div>
<div>
<div className="text-[10px] text-muted-foreground/70 mb-0.5 scale-95 origin-left">已领取</div>
<div className="font-medium text-foreground">{claimedCount}/{envelope.total_count}</div>
</div>
</div>
<div className="flex items-center justify-between gap-2 -mb-2">
<div className="text-[10px] text-muted-foreground/60 font-mono">
{formatDateTime(envelope.created_at)}
</div>
<div className="flex gap-1 h-6">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 p-0 hover:bg-background hover:text-primary transition-all"
onClick={() => handleCopyLink(link, envelope.id)}
>
{copiedId === envelope.id ? (
<Check className="size-3 text-green-500" />
) : (
<Copy className="size-3" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 p-0 hover:bg-background hover:text-primary transition-all"
onClick={() => window.open(link, '_blank')}
>
<ExternalLink className="size-3" />
</Button>
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,393 @@
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 { ListRestart, Layers, LucideIcon, Trash2 } from "lucide-react"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
interface ManagePageProps<T> {
title: string
data: T[]
loading: boolean
error: Error | null
onReload: () => void
/** 获取初始编辑数据 */
getInitialEditData: (item: T) => Partial<T>
onSave: (item: T, editData: Partial<T>) => Promise<void>
onDelete: (item: T) => Promise<void>
/** 渲染表格 (Config-Driven) */
columns: {
header: string
cell: (item: T) => React.ReactNode
width?: string
align?: 'left' | 'center' | 'right'
className?: string
}[]
/** 渲染详情 */
renderDetail: (props: {
selected: T | null
hovered: T | null
editData: Partial<T>
onEditDataChange: (field: keyof T, value: T[keyof T]) => void
onSave: () => void
saving: boolean
}) => React.ReactNode
/** 空状态图标 */
emptyIcon?: LucideIcon
emptyDescription?: string
loadingDescription?: string
getId: (item: T) => string | number
}
export function ManagePage<T>({
title,
data,
loading,
error,
onReload,
getInitialEditData,
onSave,
onDelete,
columns,
renderDetail,
emptyIcon = Layers,
emptyDescription = "暂无数据",
loadingDescription = "加载中",
getId
}: ManagePageProps<T>) {
/** 悬停状态 */
const [hoveredItem, setHoveredItem] = useState<T | null>(null)
const [selectedItem, setSelectedItem] = useState<T | null>(null)
const [editData, setEditData] = useState<Partial<T>>({})
const [saving, setSaving] = useState(false)
const [deletingItem, setDeletingItem] = useState<T | null>(null)
/** 悬停处理 */
const handleHover = (item: T | null) => {
setHoveredItem(item)
}
/** 选择处理 */
const handleSelect = (item: T) => {
const itemId = getId(item)
const selectedId = selectedItem ? getId(selectedItem) : null
if (itemId === selectedId) {
setSelectedItem(null)
setEditData({})
} else {
setSelectedItem(item)
setEditData(getInitialEditData(item))
}
setHoveredItem(null)
}
/** 编辑数据处理 */
const handleEditDataChange = (field: keyof T, value: T[keyof T]) => {
setEditData(prev => ({
...prev,
[field]: value
}))
}
/** 保存处理 */
const handleSave = async () => {
if (!selectedItem) return
try {
setSaving(true)
await onSave(selectedItem, editData)
toast.success('保存成功')
} catch (error) {
toast.error('保存失败', {
description: error instanceof Error ? error.message : '未知错误'
})
} finally {
setSaving(false)
}
}
/** 删除处理 */
const handleDeleteClick = (item: T) => {
setDeletingItem(item)
}
const handleConfirmDelete = async () => {
if (!deletingItem) return
try {
await onDelete(deletingItem)
toast.success('删除成功')
setDeletingItem(null)
// 如果删除的是当前选中的项,清除选中状态
if (selectedItem && getId(selectedItem) === getId(deletingItem)) {
setSelectedItem(null)
setEditData({})
}
} catch (error) {
toast.error('删除失败', {
description: error instanceof Error ? error.message : '未知错误'
})
}
}
/** 渲染内容 */
const renderContent = () => {
if (loading && (!data || data.length === 0)) {
return (
<LoadingStateWithBorder
icon={ListRestart}
description={loadingDescription}
/>
)
}
if (error) {
return (
<div className="p-8 border border-dashed rounded-lg">
<ErrorInline
error={error}
onRetry={onReload}
className="justify-center"
/>
</div>
)
}
if (!data || data.length === 0) {
return (
<EmptyStateWithBorder
icon={emptyIcon}
description={emptyDescription}
/>
)
}
return (
<ManageTable
data={data}
columns={columns}
selected={selectedItem}
hovered={hoveredItem}
onSelect={handleSelect}
onHover={handleHover}
onDelete={handleDeleteClick}
getId={getId}
/>
)
}
return (
<div className="py-6">
<div className="flex border-b border-border pb-2 mb-6">
<div className="text-2xl font-semibold">{title}</div>
</div>
<div className="space-y-6">
<div>
<div className="mb-4">
<div className="font-semibold">配置列表</div>
</div>
{renderContent()}
</div>
<div>
{renderDetail({
selected: selectedItem,
hovered: hoveredItem,
editData,
onEditDataChange: handleEditDataChange,
onSave: handleSave,
saving
})}
</div>
</div>
<AlertDialog open={!!deletingItem} onOpenChange={(open) => !open && setDeletingItem(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除?</AlertDialogTitle>
<AlertDialogDescription>
此操作无法撤销。这将永久删除该配置。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmDelete} className="bg-red-600 hover:bg-red-700">
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}
/** 详情面板 props */
interface ManageDetailPanelProps {
title?: string
isEmpty: boolean
emptyDescription?: string
onSave?: () => void
saving?: boolean
children: React.ReactNode
}
/** 详情面板 */
export function ManageDetailPanel({
title = "配置信息",
isEmpty,
emptyDescription = "请选择配置查看详情",
onSave,
saving = false,
children
}: ManageDetailPanelProps) {
if (isEmpty) {
return (
<div className="space-y-4">
<div className="font-semibold mb-4">{title}</div>
<EmptyStateWithBorder
icon={Layers}
description={emptyDescription}
/>
</div>
)
}
return (
<div className="space-y-4 sticky top-6">
<div>
<div className="flex items-center justify-between mb-4">
<div className="font-semibold">{title}</div>
{onSave && (
<Button
onClick={onSave}
disabled={saving}
size="sm"
className="px-3 h-7 text-xs"
>
{saving ? (<><Spinner /> 更新中</>) : '更新'}
</Button>
)}
</div>
{children}
</div>
</div>
)
}
/** 表格面板 */
export function ManageTable<T>({
data,
columns,
selected,
hovered,
onSelect,
onHover,
onDelete,
getId
}: {
data: T[]
columns: {
header: string
cell: (item: T) => React.ReactNode
width?: string
align?: 'left' | 'center' | 'right'
className?: string
}[]
selected: T | null
hovered: T | null
onSelect: (item: T) => void
onHover: (item: T | null) => void
onDelete: (item: T) => void
getId: (item: T) => string | number
}) {
return (
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
<ScrollArea className="w-full">
<div className="relative w-full">
<table className="w-full caption-bottom text-sm">
<TableHeader>
<TableRow className="border-b border-dashed">
{columns.map((col, index) => (
<TableHead
key={index}
className={`whitespace-nowrap ${ col.width ? `w-[${ col.width }]` : '' } ${ col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : '' } ${ col.className || '' }`}
>
{col.header}
</TableHead>
))}
<TableHead className="whitespace-nowrap text-center w-[120px]">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody className="animate-in fade-in duration-200">
{data.map((item) => {
const id = getId(item)
const isSelected = selected && getId(selected) === id
const isHovered = hovered && getId(hovered) === id
return (
<TableRow
key={id}
className={`border-b border-dashed cursor-pointer transition-colors ${ isSelected
? 'bg-primary/5 hover:bg-primary/10'
: isHovered
? 'bg-gray-100 hover:bg-muted/50'
: 'hover:bg-gray-100'
}`}
onMouseEnter={() => onHover(item)}
onMouseLeave={() => onHover(null)}
onClick={() => onSelect(item)}
>
{columns.map((col, index) => (
<TableCell
key={index}
className={`text-xs py-1 ${ col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : '' } ${ col.className || '' }`}
>
{col.cell(item)}
</TableCell>
))}
<TableCell className="text-xs py-1 text-center">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-red-600 hover:bg-red-50"
onClick={(e) => {
e.stopPropagation()
onDelete(item)
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</table>
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
)
}
@@ -0,0 +1,118 @@
"use client"
import * as React from "react"
import { useState } from "react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
interface PasswordDialogProps {
isOpen: boolean
onOpenChange: (open: boolean) => void
onConfirm: (password: string) => void
loading?: boolean
title?: string
description?: string
}
/**
* 密码输入对话框
*
* 用于敏感操作前的二次确认
*/
export function PasswordDialog({
isOpen,
onOpenChange,
onConfirm,
loading = false,
title = "请输入密码",
description = "为了您的账户安全,请输入6位认证密码进行验证"
}: PasswordDialogProps) {
const [password, setPassword] = useState("")
const handleConfirm = () => {
if (password.length === 6) {
onConfirm(password)
}
}
/* 当对话框关闭时重置密码*/
React.useEffect(() => {
if (!isOpen) {
setPassword("")
}
}, [isOpen])
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="w-xs sm:w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
{description}
</DialogDescription>
</DialogHeader>
<div className="py-2 flex justify-center">
<InputOTP
maxLength={6}
value={password}
onChange={setPassword}
disabled={loading}
onKeyDown={(e) => {
if (e.key === 'Enter' && password.length === 6 && !loading) {
handleConfirm()
}
}}
>
<InputOTPGroup className="gap-2">
<InputOTPSlot index={0} className="w-10 h-10 text-base" />
<InputOTPSlot index={1} className="w-10 h-10 text-base" />
<InputOTPSlot index={2} className="w-10 h-10 text-base" />
<InputOTPSlot index={3} className="w-10 h-10 text-base" />
<InputOTPSlot index={4} className="w-10 h-10 text-base" />
<InputOTPSlot index={5} className="w-10 h-10 text-base" />
</InputOTPGroup>
</InputOTP>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={loading}
className="h-8 text-xs"
>
取消
</Button>
<Button
type="button"
className="bg-primary hover:bg-primary/90 h-8 text-xs"
onClick={handleConfirm}
disabled={password.length !== 6 || loading}
>
{loading ? (
<>
<Spinner className="mr-2 h-4 w-4" />
验证中...
</>
) : (
"确认"
)}
</Button>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,368 @@
import * as React from "react"
import { useVirtualizer } from "@tanstack/react-virtual"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { typeConfig, statusConfig } from "@/components/common/general/table-filter"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"
import { ErrorInline } from "@/components/layout/error"
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
import { EmptyStateWithBorder } from "@/components/layout/empty"
import { LoadingStateWithBorder } from "@/components/layout/loading"
import { Lightbulb, ListRestart, Layers, LucideIcon } from "lucide-react"
import { formatDateTime } from "@/lib/utils"
import type { Order } from "@/lib/services"
import { useUser } from "@/contexts/user-context"
import {
OrderDetailDialog,
CreateDisputeDialog,
ViewDisputeHistoryDialog,
RefundReviewDialog,
} from "./dispute-dialog"
import {usePublicConfig} from "@/hooks/use-public-config";
const FALLBACK_TYPE_CONFIG = {
label: '历史活动',
color: 'bg-muted/50 text-gray-800 dark:bg-gray-900 dark:text-gray-300',
}
const ROW_HEIGHT = 36
/**
* 虚拟化交易数据表格组件
* 使用 @tanstack/react-virtual 实现大数据量高性能渲染
*/
export const TransactionDataTable = React.memo(function TransactionDataTable({
transactions
}: {
transactions: Order[]
}) {
const scrollAreaRef = React.useRef<HTMLDivElement>(null)
const rowVirtualizer = useVirtualizer({
count: transactions.length,
getScrollElement: () => {
if (!scrollAreaRef.current) return null
const viewport = scrollAreaRef.current.querySelector('[data-slot="scroll-area-viewport"]')
return viewport as Element
},
estimateSize: () => ROW_HEIGHT,
overscan: 5,
})
const virtualItems = rowVirtualizer.getVirtualItems()
const totalSize = rowVirtualizer.getTotalSize()
const paddingTop = virtualItems.length > 0 ? virtualItems[0].start : 0
const paddingBottom = virtualItems.length > 0 ? totalSize - virtualItems[virtualItems.length - 1].end : 0
return (
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
<ScrollArea
ref={scrollAreaRef}
className="w-full h-[600px] whitespace-nowrap"
>
<table className="w-full caption-bottom text-sm min-w-full">
<TableHeader className="sticky top-0 z-30 bg-background">
<TableRow className="border-b border-dashed hover:bg-transparent">
<TableHead className="whitespace-nowrap w-[120px]">名称</TableHead>
<TableHead className="whitespace-nowrap text-center min-w-[76px]">积分</TableHead>
<TableHead className="whitespace-nowrap text-center min-w-[50px]">类型</TableHead>
<TableHead className="whitespace-nowrap text-center min-w-[84px]">状态</TableHead>
<TableHead className="whitespace-nowrap text-center min-w-[80px]">积分动向</TableHead>
<TableHead className="whitespace-nowrap text-center min-w-[80px]">应用名</TableHead>
<TableHead className="whitespace-nowrap text-left min-w-[120px]">编号</TableHead>
<TableHead className="whitespace-nowrap text-left min-w-[120px]">业务单号</TableHead>
<TableHead className="whitespace-nowrap text-left w-[120px]">创建时间</TableHead>
<TableHead className="whitespace-nowrap text-left w-[120px]">交易时间</TableHead>
<TableHead className="whitespace-nowrap text-left w-[120px]">订单过期时间</TableHead>
<TableHead className="whitespace-nowrap text-left min-w-[100px]">备注</TableHead>
<TableHead className="sticky right-0 whitespace-nowrap text-center bg-background shadow-[-4px_0_8px_-2px_rgba(0,0,0,0.1)] w-[150px] z-40">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody
key={transactions[0]?.id}
className="animate-in fade-in duration-200 [will-change:transform,opacity]"
>
{paddingTop > 0 && (
<tr style={{ height: `${ paddingTop }px` }}>
<td colSpan={13} />
</tr>
)}
{virtualItems.map((virtualRow) => {
const order = transactions[virtualRow.index]
return (
<TransactionTableRow
key={order.id}
order={order}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
/>
)
})}
{paddingBottom > 0 && (
<tr style={{ height: `${ paddingBottom }px` }}>
<td colSpan={13} />
</tr>
)}
</TableBody>
</table>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
)
})
/**
* 交易表格行组件
*/
const TransactionTableRow = React.memo(React.forwardRef<HTMLTableRowElement, {
order: Order
style?: React.CSSProperties
} & React.HTMLAttributes<HTMLTableRowElement>>(function TransactionTableRow({
order,
style,
...props
}, ref) {
const { user } = useUser()
const getAmountDisplay = (amount: string) => (
<span className="text-xs font-semibold">
{parseFloat(amount).toFixed(2)}
</span>
)
const { config: publicConfig } = usePublicConfig()
const isDisputeSupported = order.type === 'payment' || order.type === 'online' || order.type === 'receive'
const isCurrentUserPayer = user?.id === order.payer_user_id
const isCurrentUserPayee = user?.id === order.payee_user_id
const isDisputing = order.status === 'disputing' && isCurrentUserPayee
const hasPendingTransfer = order.payee_transfer_status === 'pending' && isCurrentUserPayee
const currentTypeConfig = typeConfig[order.type as keyof typeof typeConfig] ?? FALLBACK_TYPE_CONFIG
const pendingTransferHint = React.useMemo(() => {
const settleAt = order.payee_transfer_at ? formatDateTime(order.payee_transfer_at) : `${publicConfig?.settlement_delay_days_min ?? 7}-${publicConfig?.settlement_delay_days_max ?? 14} 天`
switch (order.status) {
case 'success':
return `延迟结算机制:积分会在 ${settleAt} 后转入可用积分`
case 'refused':
return `延迟结算机制:已拒绝退款,积分会在 ${settleAt} 后转入可用积分`
case 'disputing':
return `延迟结算机制:争议处理中,积分会在 ${settleAt} 后转入可用积分`
case 'refund':
return `延迟结算机制:已退款成功,积分会在 ${settleAt} 后转入可用积分`
default:
return `延迟结算机制:积分会在 ${settleAt} 后转入可用积分`
}
}, [order.status, order.payee_transfer_at, publicConfig?.settlement_delay_days_max, publicConfig?.settlement_delay_days_min])
return (
<TableRow
ref={ref}
style={style}
{...props}
className={`
border-dashed group hover:bg-muted/50 data-[state=selected]:bg-muted
${ isDisputing ? 'bg-yellow-50 dark:bg-yellow-900/20 hover:bg-yellow-100/50 dark:hover:bg-yellow-900/30' : '' }
`}
>
<TableCell className="text-[11px] font-medium whitespace-nowrap py-1">
{order.order_name}
</TableCell>
<TableCell className="text-[11px] font-medium whitespace-nowrap text-center py-1">
{getAmountDisplay(order.amount)}
</TableCell>
<TableCell className="text-[11px] font-medium whitespace-nowrap text-center py-1">
<Badge
variant="secondary"
className={`text-[10px] px-1 ${ currentTypeConfig.color }`}
>
{currentTypeConfig.label}
</Badge>
</TableCell>
<TableCell className="text-[11px] font-medium whitespace-nowrap text-center py-1">
<div className="flex justify-center">
<div className="relative inline-flex items-center">
<Badge
variant="secondary"
className={`text-[10px] px-1 ${ statusConfig[order.status].color }`}
>
{statusConfig[order.status].label}
</Badge>
{hasPendingTransfer && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="absolute left-full ml-1 inline-flex items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
aria-label="查看到账说明"
>
<Lightbulb className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="px-2.5 py-1.5">
{pendingTransferHint}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
</TableCell>
<TableCell className="text-[11px] font-medium whitespace-nowrap text-center py-1">
{order.status === 'pending' || order.status === 'expired' || order.type === 'community' || order.type === 'red_envelope_send' || order.type === 'red_envelope_refund' ? (
<div className="text-muted-foreground">-</div>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center cursor-pointer gap-1 justify-center">
<Avatar className="h-4 w-4">
<AvatarImage src={order.payer_avatar_url || undefined} />
<AvatarFallback className="text-[9px] bg-primary text-primary-foreground">
{order.payer_username.substring(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="text-xs font-bold">⭢</div>
<Avatar className="h-4 w-4">
<AvatarImage src={order.payee_avatar_url || undefined} />
<AvatarFallback className="text-[9px] bg-primary text-primary-foreground">
{order.payee_username.substring(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="px-2.5 py-1.5">
<div className="space-y-2">
<div>
<p className="text-xs font-semibold">消费方</p>
<p className="text-xs">ID: {order.payer_user_id}</p>
<p className="text-xs">账户: {order.payer_username}</p>
</div>
<div>
<p className="text-xs font-semibold">服务方</p>
<p className="text-xs">ID: {order.payee_user_id}</p>
<p className="text-xs">账户: {order.payee_username}</p>
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</TableCell>
<TableCell className="text-[11px] font-medium text-center py-1">
{order.app_name || '-'}
</TableCell>
<TableCell className="font-mono text-[11px] font-medium text-left py-1">
{order.order_no}
</TableCell>
<TableCell className="font-mono text-[11px] font-medium text-left py-1">
{order.merchant_order_no || '-'}
</TableCell>
<TableCell className="text-[11px] font-medium text-left py-1">
{formatDateTime(order.created_at)}
</TableCell>
<TableCell className="text-[11px] font-medium text-left py-1">
{(order.status === 'success' || order.status === 'refund') ? formatDateTime(order.trade_time) : '-'}
</TableCell>
<TableCell className="text-[11px] font-medium text-left py-1">
{formatDateTime(order.expires_at)}
</TableCell>
<TableCell className="text-[11px] font-medium text-left py-1 max-w-[250px] truncate" title={order.remark || ''}>
{order.remark || '-'}
</TableCell>
<TableCell className={`
sticky right-0 whitespace-nowrap text-center shadow-[-4px_0_8px_-2px_rgba(0,0,0,0.1)] py-1 z-20
bg-background
after:absolute after:inset-0 after:z-[-1] after:content-[''] after:pointer-events-none after:transition-colors
${ isDisputing
? 'after:bg-yellow-50 dark:after:bg-yellow-900/20 group-hover:after:bg-yellow-100/50 dark:group-hover:after:bg-yellow-900/30'
: 'group-hover:after:bg-muted/50' }
`}>
<OrderDetailDialog order={order} />
{/* 场景1:消费方对成功的订单发起争议 */}
{isDisputeSupported && isCurrentUserPayer && order.status === 'success' && (
<CreateDisputeDialog order={order} />
)}
{/* 场景2:消费方查看正在进行的争议 */}
{isDisputeSupported && isCurrentUserPayer && order.status === 'disputing' && (
<ViewDisputeHistoryDialog order={order} viewer="payer" />
)}
{/* 场景3:消费方查看已处理的争议(拒绝或退款) */}
{isDisputeSupported && isCurrentUserPayer && (order.status === 'refused' || order.status === 'refund') && (
<ViewDisputeHistoryDialog order={order} viewer="payer" />
)}
{/* 场景4:服务方处理正在进行的争议 */}
{isDisputeSupported && isCurrentUserPayee && order.status === 'disputing' && (
<RefundReviewDialog order={order} />
)}
{/* 场景5:服务方查看已处理的争议(拒绝或退款) */}
{isDisputeSupported && isCurrentUserPayee && (order.status === 'refused' || order.status === 'refund') && (
<ViewDisputeHistoryDialog order={order} viewer="payee" />
)}
</TableCell>
</TableRow>
)
}))
interface TransactionTableListProps {
loading: boolean
error: Error | null
transactions: Order[]
onRetry: () => void
emptyIcon?: LucideIcon
emptyDescription?: string
}
/**
* 交易列表容器组件
* 统一处理加载、错误、空状态
*/
export const TransactionTableList = React.memo(function TransactionTableList({
loading,
error,
transactions,
onRetry,
emptyIcon = Layers,
emptyDescription = "未发现积分活动"
}: TransactionTableListProps) {
if (loading && transactions.length === 0) {
return (
<LoadingStateWithBorder
icon={ListRestart}
/>
)
}
if (error) {
return (
<div className="p-8 border border-dashed rounded-lg">
<ErrorInline
error={error}
onRetry={onRetry}
className="justify-center"
/>
</div>
)
}
if (!transactions || transactions.length === 0) {
return (
<EmptyStateWithBorder
icon={emptyIcon}
description={emptyDescription}
/>
)
}
return (
<TransactionDataTable transactions={transactions} />
)
})
export { RefundReviewDialog, CancelDisputeDialog } from "./dispute-dialog"
@@ -0,0 +1,721 @@
import * as React from "react"
import { Filter, CalendarIcon, X, Search, ChevronLeft, ChevronRight, ChevronDown, Loader2 } from "lucide-react"
import { zhCN } from "date-fns/locale"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { Separator } from "@/components/ui/separator"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"
import type { OrderType, OrderStatus, TransactionQueryParams } from "@/lib/services"
/* 类型标签配置 */
export const typeConfig: Record<OrderType, { label: string; color: string }> = {
receive: { label: '积分收益', color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' },
payment: { label: '积分消耗', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300' },
community: { label: '社区划转', color: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300' },
online: { label: '在线活动', color: 'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-300' },
distribute: { label: '商户分发', color: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-300' },
test: { label: '应用测试', color: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300 font-bold' },
red_envelope_send: { label: '红包支出', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300' },
red_envelope_receive: { label: '红包收入', color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' },
red_envelope_refund: { label: '红包退款', color: 'bg-muted/50 text-gray-800 dark:bg-gray-900 dark:text-gray-300' }
}
/* 状态标签配置 */
export const statusConfig: Record<OrderStatus, { label: string; color: string }> = {
success: { label: '成功', color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' },
pending: { label: '处理中', color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300' },
failed: { label: '失败', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300' },
expired: { label: '已过期', color: 'bg-muted/50 text-gray-800 dark:bg-gray-900 dark:text-gray-300' },
disputing: { label: '争议中', color: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300' },
refund: { label: '已退回', color: 'bg-muted/50 text-gray-800 dark:bg-gray-900 dark:text-gray-300' },
refused: { label: '已拒绝', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300' }
}
export type DisplayOrderStatus = OrderStatus | 'transfer_pending'
export const displayStatusConfig: Record<DisplayOrderStatus, { label: string; color: string }> = {
pending: statusConfig.pending,
success: statusConfig.success,
transfer_pending: { label: '结算中', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300' },
failed: statusConfig.failed,
expired: statusConfig.expired,
disputing: statusConfig.disputing,
refund: statusConfig.refund,
refused: statusConfig.refused,
}
export function mapDisplayStatusToQuery(
selectedStatus: DisplayOrderStatus | null
): Pick<TransactionQueryParams, 'statuses' | 'payee_transfer_status'> {
if (!selectedStatus) {
return {}
}
if (selectedStatus === 'transfer_pending') {
return {
payee_transfer_status: 'pending',
}
}
return {
statuses: [selectedStatus],
}
}
/* 时间范围选项 */
export const timeRangeOptions = [
{
label: "今天", getValue: () => {
const today = new Date()
today.setHours(0, 0, 0, 0)
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
return { from: today, to: tomorrow }
}
},
{
label: "最近 7 天", getValue: () => {
const today = new Date()
today.setHours(0, 0, 0, 0)
const from = new Date(today)
from.setDate(from.getDate() - 6)
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
return { from, to: tomorrow }
}
},
{
label: "最近 1 个月", getValue: () => {
const today = new Date()
today.setHours(0, 0, 0, 0)
const from = new Date(today)
from.setDate(from.getDate() - 29)
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
return { from, to: tomorrow }
}
},
{
label: "最近 6 个月", getValue: () => {
const today = new Date()
today.setHours(0, 0, 0, 0)
const from = new Date(today)
from.setMonth(from.getMonth() - 6)
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
return { from, to: tomorrow }
}
},
{
label: "本月至今", getValue: () => {
const today = new Date()
const from = new Date(today.getFullYear(), today.getMonth(), 1)
from.setHours(0, 0, 0, 0)
const tomorrow = new Date(today)
tomorrow.setHours(0, 0, 0, 0)
tomorrow.setDate(tomorrow.getDate() + 1)
return { from, to: tomorrow }
}
},
{
label: "本季至今", getValue: () => {
const today = new Date()
const quarter = Math.floor(today.getMonth() / 3)
const from = new Date(today.getFullYear(), quarter * 3, 1) // 本季第一天 00:00:00
from.setHours(0, 0, 0, 0)
const tomorrow = new Date(today)
tomorrow.setHours(0, 0, 0, 0)
tomorrow.setDate(tomorrow.getDate() + 1)
return { from, to: tomorrow }
}
},
{ label: "所有时间", getValue: () => null },
]
export interface TableFilterProps {
/* 启用的筛选类型 */
enabledFilters?: {
type?: boolean
status?: boolean
timeRange?: boolean
search?: boolean
}
/* 当前选中的值 */
selectedTypes?: OrderType[]
selectedStatus?: DisplayOrderStatus | null
selectedTimeRange?: { from: Date; to: Date } | null
selectedQuickSelection?: string | null
/* 搜索值 */
searchValues?: SearchValues
/* 回调函数 */
onTypeChange?: (types: OrderType[]) => void
onStatusChange?: (status: DisplayOrderStatus | null) => void
onTimeRangeChange?: (range: { from: Date; to: Date } | null) => void
onQuickSelectionChange?: (selection: string | null) => void
onSearch?: (values: SearchValues) => void
// 其他选项
showClearButton?: boolean
onClearAll?: () => void
// 分页控制(可选)
enablePagination?: boolean
currentPage?: number
totalPages?: number
pageSize?: number
total?: number
onPageChange?: (page: number) => void
onPageSizeChange?: (size: number) => void
onRefresh?: () => void
loading?: boolean
}
/**
* 可复用的筛选组件
* 支持类型、状态、时间范围筛选
*/
export function TableFilter({
enabledFilters = { type: true, status: true, timeRange: true, search: true },
selectedTypes = [],
selectedStatus = null,
selectedTimeRange = null,
selectedQuickSelection = null,
searchValues = {},
onTypeChange,
onStatusChange,
onTimeRangeChange,
onQuickSelectionChange,
onSearch,
showClearButton = true,
onClearAll,
enablePagination = false,
currentPage = 1,
totalPages = 1,
pageSize = 20,
total = 0,
onPageChange,
onPageSizeChange,
onRefresh,
loading = false,
}: TableFilterProps) {
/* 通用切换筛选函数 */
const handleToggle = <T extends string>(
value: T,
selectedValues: T[],
onChange?: (values: T[]) => void
) => {
if (!onChange) return
const newValues = selectedValues.includes(value)
? selectedValues.filter(v => v !== value)
: [...selectedValues, value]
onChange(newValues)
}
/* 切换类型筛选 */
const toggleType = (type: OrderType) => handleToggle(type, selectedTypes, onTypeChange)
/* 切换状态筛选 */
const toggleStatus = (status: DisplayOrderStatus) => {
if (!onStatusChange) return
onStatusChange(selectedStatus === status ? null : status)
}
/* 处理时间范围变化 */
const handleTimeRangeChange = (range: { from: Date; to: Date } | null) => {
onTimeRangeChange?.(range)
}
/* 是否有激活的筛选 */
const hasActiveFilters =
(enabledFilters.type && selectedTypes.length > 0) ||
(enabledFilters.status && selectedStatus !== null) ||
(enabledFilters.timeRange && selectedTimeRange !== null) ||
(enabledFilters.search && Object.values(searchValues || {}).some(v => v))
return (
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap">
{enabledFilters.search && (
<SearchFilter
values={searchValues}
onSearch={onSearch}
/>
)}
{enabledFilters.type && (
<FilterSelect<OrderType>
label="类型"
selectedValues={selectedTypes}
options={typeConfig}
onToggleValue={toggleType}
/>
)}
{enabledFilters.status && (
<SingleFilterSelect<DisplayOrderStatus>
label="状态"
selectedValue={selectedStatus}
options={displayStatusConfig}
onSelectValue={toggleStatus}
/>
)}
{enabledFilters.timeRange && (
<TimeRangeFilter
selectedQuickSelection={selectedQuickSelection}
selectedTimeRange={selectedTimeRange}
onTimeRangeChange={handleTimeRangeChange}
onQuickSelectionChange={onQuickSelectionChange}
/>
)}
{showClearButton && hasActiveFilters && onClearAll && (
<>
<Separator orientation="vertical" className="h-6 hidden sm:block" />
<Button
variant="ghost"
size="sm"
onClick={onClearAll}
className="h-5 px-2 lg:px-3 text-[11px] font-medium text-muted-foreground hover:text-foreground"
>
<X className="size-3" />
清空筛选
</Button>
</>
)}
</div>
{enablePagination && onPageChange && onPageSizeChange && (
<Separator className="lg:hidden" />
)}
{enablePagination && onPageChange && onPageSizeChange && (
<TablePagination
currentPage={currentPage}
totalPages={totalPages}
total={total}
pageSize={pageSize}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
onRefresh={onRefresh}
loading={loading}
/>
)}
</div>
)
}
interface TablePaginationProps {
currentPage: number
totalPages: number
total: number
pageSize: number
onPageChange: (page: number) => void
onPageSizeChange: (size: number) => void
onRefresh?: () => void
loading?: boolean
}
export function TablePagination({
currentPage,
totalPages,
total,
pageSize,
onPageChange,
onPageSizeChange,
onRefresh,
loading = false
}: TablePaginationProps) {
return (
<div className="flex items-center gap-2 self-end lg:self-auto">
<span className="text-[10px] text-muted-foreground whitespace-nowrap hidden sm:inline-block">
{total} 条记录
</span>
<div className="flex items-center border border-dashed rounded-md shadow-none bg-background">
<Button
variant="ghost"
size="icon"
className="h-5.5 w-6 rounded-none rounded-l-md disabled:opacity-30"
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage <= 1 || loading}
>
<ChevronLeft className="size-3" />
</Button>
<span className="text-[10px] font-mono text-muted-foreground px-2 border-x border-dashed h-5.5 flex items-center">
{currentPage}/{totalPages}
</span>
<Button
variant="ghost"
size="icon"
className="h-5.5 w-6 rounded-none rounded-r-md disabled:opacity-30"
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage >= totalPages || loading}
>
<ChevronRight className="size-3" />
</Button>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-6 border-dashed text-[10px] px-2 font-mono shadow-none" disabled={loading}>
{pageSize}条/页
<ChevronDown className="size-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{[20, 50, 100].map(size => (
<DropdownMenuItem
key={size}
onClick={() => onPageSizeChange(size)}
className={cn("font-mono text-xs", pageSize === size && "bg-accent")}
>
{size}条/页
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{onRefresh && (
<Button
variant="outline"
size="icon"
className="h-6 w-6 border-dashed shadow-none"
onClick={onRefresh}
disabled={loading}
title="刷新数据"
>
<Loader2 className={cn("size-3", loading && "animate-spin")} />
</Button>
)}
</div>
)
}
/**
* 可复用的筛选选择器组件
*/
export function FilterSelect<T extends string>({ label, selectedValues, options, onToggleValue }: {
label: string
selectedValues: T[]
options: Record<T, { label: string; color: string }>
onToggleValue: (value: T) => void
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
selectedValues.length > 0 && "bg-primary/5 border-primary/20"
)}
>
<Filter className="size-3" />
{label}
{selectedValues.length > 0 && (
<>
<Separator orientation="vertical" className="mx-1" />
<Badge
variant="secondary"
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
>
{selectedValues.length}
</Badge>
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[120px]" align="start">
{(Object.keys(options) as T[]).map((value) => {
const isSelected = selectedValues.includes(value)
return (
<DropdownMenuItem
key={value}
onSelect={(e) => {
e.preventDefault()
onToggleValue(value)
}}
>
<div className={cn(
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}>
</div>
<span className="text-xs">{options[value].label}</span>
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
export function SingleFilterSelect<T extends string>({ label, selectedValue, options, onSelectValue }: {
label: string
selectedValue: T | null
options: Record<T, { label: string; color: string }>
onSelectValue: (value: T) => void
}) {
const activeLabel = selectedValue ? options[selectedValue].label : null
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
selectedValue && "bg-primary/5 border-primary/20"
)}
>
<Filter className="size-3" />
{activeLabel ? `${label}: ${activeLabel}` : label}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[160px]" align="start">
{(Object.keys(options) as T[]).map((value) => {
const isSelected = selectedValue === value
return (
<DropdownMenuItem
key={value}
onSelect={(e) => {
e.preventDefault()
onSelectValue(value)
}}
>
<div className={cn(
"mr-2 flex size-3 items-center justify-center rounded-full border border-primary",
isSelected ? "bg-primary" : "opacity-50"
)} />
<span className="text-xs">{options[value].label}</span>
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
interface TimeRangeFilterProps {
selectedQuickSelection: string | null
selectedTimeRange: { from: Date; to: Date } | null
onTimeRangeChange: (range: { from: Date; to: Date } | null) => void
onQuickSelectionChange?: (selection: string | null) => void
}
function TimeRangeFilter({
selectedQuickSelection,
selectedTimeRange,
onTimeRangeChange,
onQuickSelectionChange
}: TimeRangeFilterProps) {
/* 处理日历选择 */
const handleCalendarSelect = (range: { from?: Date; to?: Date } | undefined) => {
if (range?.from) {
const from = new Date(range.from)
from.setHours(0, 0, 0, 0)
if (range.to) {
const to = new Date(range.to)
to.setHours(0, 0, 0, 0)
onTimeRangeChange({ from, to })
onQuickSelectionChange?.(null)
} else if (range.from.getTime() === selectedTimeRange?.from?.getTime()) {
const to = new Date(from)
to.setHours(0, 0, 0, 0)
onTimeRangeChange({ from, to })
onQuickSelectionChange?.(null)
}
}
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
(selectedQuickSelection || selectedTimeRange) && "bg-primary/5 border-primary/20"
)}
>
<CalendarIcon className="mr-1 size-3" />
{selectedQuickSelection || (selectedTimeRange ? "自定义时间" : "时间范围")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-auto p-0" align="start">
<div className="flex flex-col sm:flex-row">
<div className="flex flex-col p-2 gap-1 min-w-[120px] border-r">
{timeRangeOptions.map((selection) => (
<Button
key={selection.label}
variant="ghost"
size="sm"
onClick={() => {
const range = selection.getValue()
onTimeRangeChange(range)
onQuickSelectionChange?.(selection.label)
}}
className={cn(
"justify-start text-xs font-normal h-8",
selectedQuickSelection === selection.label && "bg-accent text-accent-foreground font-medium"
)}
>
{selection.label}
</Button>
))}
</div>
<div className="p-2">
<Calendar
mode="range"
selected={selectedTimeRange ? { from: selectedTimeRange.from, to: selectedTimeRange.to } : undefined}
onSelect={handleCalendarSelect}
numberOfMonths={2}
locale={zhCN}
/>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}
export interface SearchValues {
id?: string
order_name?: string
payer_username?: string
payee_username?: string
}
export function SearchFilter({
values,
onSearch
}: {
values?: SearchValues
onSearch?: (values: SearchValues) => void
}) {
const [localValues, setLocalValues] = React.useState<SearchValues>(values || {})
const [isOpen, setIsOpen] = React.useState(false)
React.useEffect(() => {
setLocalValues(values || {})
}, [values, isOpen])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
onSearch?.(localValues)
setIsOpen(false)
}
}
const handleSearch = () => {
onSearch?.(localValues)
setIsOpen(false)
}
const handleClear = () => {
const empty = { id: '', order_name: '', payer_username: '', payee_username: '' }
setLocalValues(empty)
onSearch?.(empty)
setIsOpen(false)
}
const hasSearchValues = Object.values(values || {}).some(v => v)
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
hasSearchValues && "bg-primary/5 border-primary/20"
)}
>
<Search className="size-3" />
搜索
{hasSearchValues && (
<>
<Separator orientation="vertical" className="mx-1 h-3" />
<Badge
variant="secondary"
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
>
!
</Badge>
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[280px] p-4" align="start">
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-xs">编号 ID</Label>
<Input
value={localValues.id || ''}
onChange={e => setLocalValues(prev => ({ ...prev, id: e.target.value }))}
onKeyDown={handleKeyDown}
placeholder="精确匹配"
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">活动名称</Label>
<Input
value={localValues.order_name || ''}
onChange={e => setLocalValues(prev => ({ ...prev, order_name: e.target.value }))}
onKeyDown={handleKeyDown}
placeholder="模糊匹配"
className="h-8 text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label className="text-xs">消费方</Label>
<Input
value={localValues.payer_username || ''}
onChange={e => setLocalValues(prev => ({ ...prev, payer_username: e.target.value }))}
onKeyDown={handleKeyDown}
placeholder="模糊匹配"
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">服务方</Label>
<Input
value={localValues.payee_username || ''}
onChange={e => setLocalValues(prev => ({ ...prev, payee_username: e.target.value }))}
onKeyDown={handleKeyDown}
placeholder="模糊匹配"
className="h-8 text-xs"
/>
</div>
</div>
<div className="flex gap-2 pt-2">
<Button size="sm" variant="outline" className="flex-1 h-7 text-xs" onClick={handleClear}>
重置
</Button>
<Button size="sm" className="flex-1 h-7 text-xs" onClick={handleSearch}>
搜索
</Button>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,260 @@
import * as React from "react"
import { Area, AreaChart, XAxis } from "recharts"
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
import { CountingNumber } from '@/components/animate-ui/primitives/texts/counting-number'
import { Skeleton } from "@/components/ui/skeleton"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { Info } from "lucide-react"
import { useUser } from "@/contexts/user-context"
import { DashboardService } from "@/lib/services"
import type { DailyStatsItem } from "@/lib/services"
const chartConfig = {
total: {
label: "总额",
color: "hsl(217, 91%, 60%)",
},
income: {
label: "收入",
color: "#10b981",
},
expense: {
label: "支出",
color: "#f43f5e",
},
} satisfies ChartConfig
function ResponsiveInfoTip({ children }: { children: React.ReactNode }) {
const triggerClassName =
"shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
return (
<>
<Popover>
<PopoverTrigger asChild>
<button type="button" aria-label="查看详情" className={`inline-flex ${triggerClassName} md:hidden`}>
<Info className="size-3.5" />
</button>
</PopoverTrigger>
<PopoverContent side="top" className="w-fit max-w-[calc(100vw-2rem)] p-3 md:hidden">
<p className="text-xs break-words">{children}</p>
</PopoverContent>
</Popover>
<Tooltip>
<TooltipTrigger asChild>
<button type="button" aria-label="查看详情" className={`hidden ${triggerClassName} md:inline-flex`}>
<Info className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} className="w-fit max-w-[calc(100vw-2rem)] px-2.5 py-1.5">
<p className="text-xs leading-5 break-words">{children}</p>
</TooltipContent>
</Tooltip>
</>
)
}
/**
* 数据面板组件
* 展示用户数据和积分余额
* @returns {React.ReactNode} 数据面板组件
*/
export function DataPanel() {
const { user, loading: userLoading } = useUser()
const [dailyStats, setDailyStats] = React.useState<DailyStatsItem[]>([])
const [loading, setLoading] = React.useState(true)
const availableBalance = parseFloat(user?.available_balance || '0')
const pendingBalance = parseFloat(user?.pending_balance || '0')
const remainQuota = parseFloat(user?.remain_quota || '0')
/* 获取每日统计数据 */
React.useEffect(() => {
const fetchDailyStats = async () => {
setLoading(true)
const data = await DashboardService.getDailyStats(7)
setDailyStats(data)
setLoading(false)
}
if (!userLoading && user) {
fetchDailyStats()
}
}, [userLoading, user])
/* 根据当前余额和每日收支反推历史积分总额 */
const chartData = React.useMemo(() => {
if (!dailyStats.length || userLoading) return []
const sortedStats = [...dailyStats].sort((a, b) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
)
let currentBalance = availableBalance
const balanceHistory: Array<{ date: string; total: number; income: number; expense: number }> = []
for (let i = 0; i < sortedStats.length; i++) {
const stat = sortedStats[i]
const income = parseFloat(stat.income || '0')
const expense = parseFloat(stat.expense || '0')
balanceHistory.unshift({
date: new Date(stat.date).toLocaleDateString('zh-CN', {
month: 'numeric',
day: 'numeric'
}),
total: Math.max(0, currentBalance),
income,
expense
})
currentBalance = currentBalance - income + expense
}
return balanceHistory
}, [dailyStats, availableBalance, userLoading])
return (
<div className="flex flex-col md:grid md:grid-cols-3 gap-8 md:gap-12">
<div className="md:col-span-2 order-1 md:order-none">
<h3 className="text-sm text-muted-foreground font-medium mb-2">积分趋势</h3>
{loading || userLoading ? (
<div className="w-full h-[240px] flex items-center justify-center">
<Skeleton className="h-full w-full rounded-lg" />
</div>
) : chartData.length > 0 ? (
<ChartContainer config={chartConfig} className="w-full h-[240px] font-bold">
<AreaChart
data={chartData}
margin={{
left: 6,
right: 6,
top: 12,
bottom: 12,
}}
>
<defs>
<linearGradient id="colorTotal" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(217, 91%, 60%)" stopOpacity={0.2} />
<stop offset="95%" stopColor="hsl(217, 91%, 60%)" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorIncome" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.2} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorExpense" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#f43f5e" stopOpacity={0.2} />
<stop offset="95%" stopColor="#f43f5e" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="date" hide />
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" />}
/>
<Area
dataKey="total"
type="monotone"
fill="url(#colorTotal)"
fillOpacity={0.4}
stroke="var(--color-total)"
strokeWidth={2}
dot={false}
activeDot={{
fill: "hsl(217, 91%, 60%)",
stroke: "var(--background)",
strokeWidth: 2,
r: 4,
}}
/>
<Area
dataKey="income"
type="monotone"
fill="none"
stroke="#10b981"
strokeWidth={1.5}
strokeDasharray="4 4"
dot={false}
activeDot={{
fill: "#10b981",
stroke: "var(--background)",
strokeWidth: 2,
r: 4,
}}
/>
<Area
dataKey="expense"
type="monotone"
fill="none"
stroke="#f43f5e"
strokeWidth={1.5}
strokeDasharray="4 4"
dot={false}
activeDot={{
fill: "#f43f5e",
stroke: "var(--background)",
strokeWidth: 2,
r: 4,
}}
/>
</AreaChart>
</ChartContainer>
) : (
<div className="w-full h-[240px] flex items-center justify-center border border-dashed rounded-lg">
<p className="text-xs text-muted-foreground">暂无统计数据</p>
</div>
)}
</div>
<div className="md:col-span-1 order-2 md:order-none flex flex-col divide-y divide-border/70 md:divide-y-0 md:pt-px">
<div className="py-3 first:pt-0 md:border-b md:pb-4 md:pt-0">
<div className="flex items-start justify-between gap-4 md:block">
<div className="min-w-0 text-sm text-muted-foreground font-medium flex items-center gap-1 flex-wrap md:whitespace-nowrap">
<span className="min-[400px]:hidden">可用LDC</span>
<span className="hidden min-[400px]:inline">可用 LINUX DO Credits</span>
<ResponsiveInfoTip>
当前可立即使用的积分,不包含延迟到账中的未来积分。
</ResponsiveInfoTip>
</div>
<div className="shrink-0 text-right text-xl font-semibold leading-none md:pt-2 md:text-left">
{userLoading ? '-' : <CountingNumber number={availableBalance} decimalPlaces={2} />}
</div>
</div>
</div>
<div className="py-3 md:border-b md:pt-4 md:pb-4">
<div className="flex items-start justify-between gap-4 md:block">
<div className="min-w-0 text-sm text-muted-foreground font-medium flex items-center gap-1 flex-wrap md:whitespace-nowrap">
<span className="min-[400px]:hidden">未来LDC</span>
<span className="hidden min-[400px]:inline">未来 LINUX DO Credits</span>
<ResponsiveInfoTip>
流转后延迟到账的积分,到期后自动转入可用积分。
</ResponsiveInfoTip>
</div>
<div className="shrink-0 text-right text-xl font-semibold leading-none md:pt-2 md:text-left">
{userLoading ? '-' : <CountingNumber number={pendingBalance} decimalPlaces={2} />}
</div>
</div>
</div>
<div className="py-3 last:pb-0 md:pt-4 md:pb-0">
<div className="flex items-start justify-between gap-4 md:block">
<div className="min-w-0 text-sm text-muted-foreground font-medium flex items-center gap-1 flex-wrap">
今日剩余额度
<ResponsiveInfoTip>
每日积分消耗额度限制,每日 0 点自动重置
</ResponsiveInfoTip>
</div>
<div className="shrink-0 text-right text-xl font-semibold leading-none md:pt-2 md:text-left">
{userLoading ? '-' : remainQuota < 0 ? "无限制" : <CountingNumber number={remainQuota} decimalPlaces={2} />}
</div>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,148 @@
"use client"
import * as React from "react"
import { useState, useCallback } from "react"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { TransactionService } from "@/lib/services"
import type { Order } from "@/lib/services"
import { TransactionTableList } from "@/components/common/general/table-data"
import { AlertTriangle } from "lucide-react"
import { toast } from "sonner"
const DISPUTE_PAGE_SIZE = 20
/**
* 服务争议对话框模式
*/
type DisputeDialogMode = 'pending' | 'my-disputes'
/**
* 服务争议对话框属性
*/
interface DisputeDialogProps {
/** 对话框模式 */
mode: DisputeDialogMode
/** 是否打开 */
open: boolean
/** 打开状态变化回调 */
onOpenChange: (open: boolean) => void
}
/**
* 争议对话框组件
* 支持两种模式:待处理的服务争议(服务提供者)和我发起的服务争议(积分消费者)
*/
export function DisputeDialog({ mode, open, onOpenChange }: DisputeDialogProps) {
const [disputes, setDisputes] = useState<Order[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const fetchDisputes = useCallback(async (page: number = 1) => {
try {
setLoading(true)
setError(null)
let result
let filteredOrders: Order[] = []
if (mode === 'pending') {
result = await TransactionService.getTransactions({
page,
page_size: DISPUTE_PAGE_SIZE,
types: ['receive'],
statuses: ['disputing']
})
filteredOrders = result.orders
const uniqueOrders = new Map()
filteredOrders.forEach(order => uniqueOrders.set(order.id, order))
filteredOrders = Array.from(uniqueOrders.values())
} else {
const paymentResult = await TransactionService.getTransactions({
page,
page_size: Math.ceil(DISPUTE_PAGE_SIZE / 2),
types: ['payment']
})
const onlineResult = await TransactionService.getTransactions({
page,
page_size: Math.ceil(DISPUTE_PAGE_SIZE / 2),
types: ['online']
})
const allOrders = [...paymentResult.orders, ...onlineResult.orders]
const uniqueOrders = new Map()
allOrders.forEach(order => uniqueOrders.set(order.id, order))
filteredOrders = Array.from(uniqueOrders.values()).filter((order: Order) =>
order.status === 'disputing' || order.status === 'refused' || order.status === 'refund'
)
filteredOrders.sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
const startIndex = (page - 1) * DISPUTE_PAGE_SIZE
filteredOrders = filteredOrders.slice(startIndex, startIndex + DISPUTE_PAGE_SIZE)
result = {
orders: filteredOrders,
total: allOrders.filter((order: Order) =>
order.status === 'disputing' || order.status === 'refused' || order.status === 'refund'
).length,
page,
page_size: DISPUTE_PAGE_SIZE
}
}
setDisputes(filteredOrders)
} catch (err) {
const error = err as Error
setError(error)
toast.error('获取争议数据失败', {
description: error.message || '无法加载争议列表'
})
} finally {
setLoading(false)
}
}, [mode])
React.useEffect(() => {
if (open) {
fetchDisputes(1)
} else {
setDisputes([])
setError(null)
}
}, [open, fetchDisputes])
const handleRetry = () => {
fetchDisputes(1)
}
const dialogTitle = mode === 'pending' ? '待处理的争议' : '我发起的争议'
const dialogDescription = mode === 'pending'
? '您作为服务提供者,需要处理所有争议中的活动'
: '您作为积分消费者发起的所有争议'
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogDescription>{dialogDescription}</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto">
<TransactionTableList
loading={loading}
error={error}
transactions={disputes}
onRetry={handleRetry}
emptyIcon={AlertTriangle}
emptyDescription={mode === 'pending' ? '暂无待处理的争议' : '暂无您发起的争议'}
/>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,25 @@
"use client"
import * as React from "react"
import { TransactionProvider } from "@/contexts/transaction-context"
import { DataPanel } from "@/components/common/home/data-panel"
import { OverviewPanel } from "@/components/common/home/overview-panel"
export function HomeMain() {
return (
<TransactionProvider>
<div className="py-6 space-y-12">
<div>
<h1 className="text-2xl font-semibold border-b pb-2 mb-6">今天</h1>
<DataPanel />
</div>
<div>
<h1 className="text-2xl font-semibold border-b pb-2">近期概览</h1>
<OverviewPanel />
</div>
</div>
</TransactionProvider>
)
}
@@ -0,0 +1,746 @@
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"
import { Eye, EyeOff } from "lucide-react"
import { ScrollArea } from "@/components/ui/scroll-area"
import { DisputeService, TransactionService, DashboardService } from "@/lib/services"
import type { DisputeWithOrder, Order, DailyStatsItem, TopCustomer } from "@/lib/services"
import { RefundReviewDialog, CancelDisputeDialog } from "@/components/common/general/table-data"
import { CountingNumber } from '@/components/animate-ui/primitives/texts/counting-number'
import { useDisputeData } from "@/hooks/use-dispute"
import { DisputeDialog } from "@/components/common/home/dispute-dialog"
import { useRouter } from "next/navigation"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { Progress } from "@/components/ui/progress"
import { Spinner } from "@/components/ui/spinner"
function AnimatedProgressBar({ value, className }: { value: number, className?: string }) {
const [progress, setProgress] = React.useState(0)
React.useEffect(() => {
const timer = setTimeout(() => setProgress(value), 100)
return () => clearTimeout(timer)
}, [value])
return (
<Progress
value={progress}
className={cn("h-2 bg-muted", className)}
/>
)
}
interface OverviewCardProps {
title: string
titleExtra?: React.ReactNode
headerExtra?: React.ReactNode
action?: React.ReactNode
loading?: boolean
onRefresh?: () => void
onViewAll?: () => void
children: React.ReactNode
className?: string
}
function OverviewCard({
title,
titleExtra,
headerExtra,
action,
loading,
onRefresh,
onViewAll,
children,
className
}: OverviewCardProps) {
return (
<Card className={cn("bg-background border-0 shadow-none rounded-lg min-h-[200px] flex flex-col h-full", className)}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 h-6">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
{titleExtra && <div className="font-semibold">{titleExtra}</div>}
</div>
<div className="flex items-center gap-2">
{action}
{onRefresh && (
<Button variant="ghost" size="icon" className="size-4" onClick={onRefresh} disabled={loading}>
<Spinner className={cn("size-3.5", !loading && "animate-none")} />
</Button>
)}
</div>
</div>
{headerExtra && <div className="pt-1">{headerExtra}</div>}
</CardHeader>
<CardContent className="relative flex-1 -mt-4">
{children}
</CardContent>
<CardFooter className="border-t h-8">
<div className="flex items-center justify-between text-xs text-muted-foreground w-full">
<span>更新时间:{new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</span>
{onViewAll && (
<Button variant="link" className="h-4 px-0 text-xs text-blue-600" disabled={loading} onClick={onViewAll}>
查看全部
</Button>
)}
</div>
</CardFooter>
</Card>
)
}
/**
* 创建争议对象
* @param dispute 争议对象
* @param type 争议类型
* @returns 争议对象
*/
const createDisputeOrder = (dispute: DisputeWithOrder, type: 'receive' | 'payment'): Order => ({
id: dispute.order_id,
dispute_id: dispute.id,
type,
status: 'disputing' as const,
order_no: '',
order_name: dispute.order_name,
merchant_order_no: '',
payer_user_id: '0',
payee_user_id: '0',
payer_username: '',
payee_username: dispute.payee_username,
amount: dispute.amount,
remark: '',
client_id: '',
trade_time: '',
expires_at: '',
created_at: '',
updated_at: '',
payment_type: '',
payee_transfer_status: 'completed' as const,
payee_transfer_at: '',
})
/**
* 争议列表骨架屏
* 用于显示争议列表的加载状态
*
* @returns 争议列表骨架屏
*/
const DisputeListSkeleton = () => (
<div className="space-y-1">
{Array.from({ length: 7 }).map((_, index) => (
<div key={`skeleton-${ index }`} className="flex items-center justify-between py-1 px-2 rounded-md bg-muted/50">
<div className="flex-1 min-w-0">
<Skeleton className="h-3 w-32 mb-1" />
<Skeleton className="h-2.5 w-20" />
</div>
<div className="ml-2">
<Skeleton className="size-5 rounded-full" />
</div>
</div>
))}
</div>
)
/**
* 争议列表数据骨架屏
*/
const PaymentListSkeleton = () => (
<div className="space-y-1">
{Array.from({ length: 7 }).map((_, index) => (
<div key={`payment-skeleton-${ index }`} className="flex items-center justify-between py-1 px-2 rounded-md bg-muted/50">
<div className="flex-1 min-w-0">
<Skeleton className="h-3 w-32 mb-1" />
<Skeleton className="h-2.5 w-20" />
</div>
<div className="ml-2">
<Skeleton className="h-5 w-16 rounded" />
</div>
</div>
))}
</div>
)
/**
* 获取活动状态显示文本
*/
const getStatusText = (status: string) => {
const statusMap: Record<string, string> = {
success: '成功',
pending: '待处理',
failed: '失败',
expired: '已过期',
disputing: '争议中',
refund: '已退回',
refused: '已拒绝'
}
return statusMap[status] || status
}
/**
* 获取活动状态样式
*/
const getStatusVariant = (status: string): "default" | "secondary" | "destructive" | "outline" => {
switch (status) {
case 'success':
return 'default'
case 'pending':
return 'secondary'
case 'failed':
case 'refused':
return 'destructive'
default:
return 'outline'
}
}
/**
* 活动卡片
* 用于显示活动列表数据
*
* @returns 活动列表卡片
*/
function PaymentCard({ onViewAll }: { onViewAll: () => void }) {
const [isHidden, setIsHidden] = React.useState(false)
const [payments, setPayments] = React.useState<Order[]>([])
const [loading, setLoading] = React.useState(true)
const [total, setTotal] = React.useState(0)
const fetchPayments = React.useCallback(async () => {
try {
setLoading(true)
const response = await TransactionService.getTransactions({
page: 1,
page_size: 10,
types: ['payment'],
statuses: ['success']
})
setPayments(response.orders)
setTotal(response.total)
} catch (error) {
console.error('Failed to fetch payments:', error)
} finally {
setLoading(false)
}
}, [])
React.useEffect(() => {
fetchPayments()
}, [fetchPayments])
return (
<OverviewCard
title="活动"
titleExtra={loading ? '-' : <CountingNumber number={total} decimalPlaces={0} />}
loading={loading}
onRefresh={fetchPayments}
onViewAll={onViewAll}
action={
<Button variant="ghost" size="icon" className="size-4" onClick={() => setIsHidden(!isHidden)}>
{isHidden ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
</Button>
}
>
<ScrollArea className="h-46">
{loading ? (
<PaymentListSkeleton />
) : payments.length > 0 ? (
<div className="space-y-1">
{payments.map((payment) => (
<div key={`payment-${ payment.id }`} className="flex items-center justify-between py-1 px-2 rounded-md bg-muted/50">
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate leading-tight">{payment.order_name}</p>
<p className="text-[10px] text-muted-foreground leading-tight">LDC {payment.amount}</p>
</div>
<Badge variant={getStatusVariant(payment.status)} className="text-[10px] px-1.5 py-0 ml-2">
{getStatusText(payment.status)}
</Badge>
</div>
))}
</div>
) : (
<div className="h-46 flex items-center justify-center">
<p className="text-muted-foreground text-xs">暂无积分活动记录</p>
</div>
)}
</ScrollArea>
{isHidden && (
<div className="absolute inset-0 backdrop-blur-md bg-background/30 rounded-lg flex items-center justify-center">
<EyeOff className="h-8 w-8 text-muted-foreground/50" />
</div>
)}
</OverviewCard>
)
}
/**
* 收入统计卡片
* 用于显示每日收入统计
*
* @returns 收入统计卡片
*/
function IncomeStatsCard() {
const [stats, setStats] = React.useState<DailyStatsItem[]>([])
const [loading, setLoading] = React.useState(true)
const [days] = React.useState(7)
const fetchDailyStats = React.useCallback(async () => {
try {
setLoading(true)
const data = await DashboardService.getDailyStats(days)
//setStats(data)
const newStats: DailyStatsItem[] = []
data.forEach((stat, i) => {
let income = parseFloat(stat.income || "0");
if (income < 0) {
income = 0;
}
newStats[i] = {
date: stat.date,
income: String(income),
expense: stat.expense
}
});
setStats(newStats);
} catch (error) {
console.error('Failed to fetch daily stats:', error)
} finally {
setLoading(false)
}
}, [days])
React.useEffect(() => {
fetchDailyStats()
}, [fetchDailyStats])
// 计算总收入
const totalIncome = stats.reduce((sum, stat) => sum + parseFloat(stat.income || '0'), 0)
// 找出最大收入值用于比例计算
const maxIncome = Math.max(...stats.map(s => parseFloat(s.income || '0')))
return (
<OverviewCard
title="7天收入统计"
headerExtra={
<div className="text-xl font-semibold">
{loading ? (
<Skeleton className="h-6 w-24" />
) : (
<>LDC <CountingNumber number={totalIncome} decimalPlaces={2} /></>
)}
</div>
}
loading={loading}
onRefresh={fetchDailyStats}
>
{loading ? (
<ScrollArea className="h-46 w-full">
<div className="space-y-1.5 pr-3">
{Array.from({ length: 7 }).map((_, index) => (
<div key={`income-skeleton-${ index }`} className="space-y-0.5">
<div className="flex items-center justify-between">
<Skeleton className="h-3 w-10" />
<Skeleton className="h-3 w-12" />
</div>
<Skeleton className="h-2 w-full rounded-sm" />
</div>
))}
</div>
</ScrollArea>
) : stats.length > 0 ? (
<ScrollArea className="h-46 w-full">
<div className="space-y-1.5 pr-3">
{stats.slice().reverse().map((stat, index) => {
const income = parseFloat(stat.income || '0')
const incomeWidth = maxIncome > 0 ? (income / maxIncome) * 100 : 0
return (
<div key={stat.date || index} className="space-y-0.5">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">
{new Date(stat.date).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })}
</span>
<span className="text-[10px] text-green-600 font-medium">
+{income.toFixed(2)}
</span>
</div>
<div className="bg-muted rounded-sm overflow-hidden h-2">
<AnimatedProgressBar
value={incomeWidth}
className="h-full rounded-sm [&>[data-slot=progress-indicator]]:bg-green-500/80"
/>
</div>
</div>
)
})}
</div>
</ScrollArea>
) : (
<div className="h-46 w-full flex items-center justify-center border border-dashed rounded-lg">
<p className="text-xs text-muted-foreground">暂无统计数据</p>
</div>
)}
</OverviewCard>
)
}
/**
* 支出统计卡片
* 用于显示每日支出统计
*
* @returns 支出统计卡片
*/
function ExpenseStatsCard() {
const [stats, setStats] = React.useState<DailyStatsItem[]>([])
const [loading, setLoading] = React.useState(true)
const [days] = React.useState(7)
const fetchDailyStats = React.useCallback(async () => {
try {
setLoading(true)
const data = await DashboardService.getDailyStats(days)
//setStats(data)
const newStats: DailyStatsItem[] = []
data.forEach((stat, i) => {
let income = parseFloat(stat.income || '0');
let expense = parseFloat(stat.expense || '0');
if (income < 0) {
expense = expense - income;
income = 0;
}
newStats[i] = {
date: stat.date,
income: String(income),
expense: String(expense),
}
})
setStats(newStats);
} catch (error) {
console.error('Failed to fetch daily stats:', error)
} finally {
setLoading(false)
}
}, [days])
React.useEffect(() => {
fetchDailyStats()
}, [fetchDailyStats])
/* 计算总支出 */
const totalExpense = stats.reduce((sum, stat) => sum + parseFloat(stat.expense || '0'), 0)
/* 找出最大支出值用于比例计算 */
const maxExpense = Math.max(...stats.map(s => parseFloat(s.expense || '0')))
return (
<OverviewCard
title="7天支出统计"
headerExtra={
<div className="text-xl font-semibold">
{loading ? (
<Skeleton className="h-6 w-24" />
) : (
<>LDC <CountingNumber number={totalExpense} decimalPlaces={2} /></>
)}
</div>
}
loading={loading}
onRefresh={fetchDailyStats}
>
{loading ? (
<ScrollArea className="h-46 w-full">
<div className="space-y-1.5 pr-3">
{Array.from({ length: 7 }).map((_, index) => (
<div key={`expense-skeleton-${ index }`} className="space-y-0.5">
<div className="flex items-center justify-between">
<Skeleton className="h-3 w-10" />
<Skeleton className="h-3 w-12" />
</div>
<Skeleton className="h-2 w-full rounded-sm" />
</div>
))}
</div>
</ScrollArea>
) : stats.length > 0 ? (
<ScrollArea className="h-46 w-full">
<div className="space-y-1.5 pr-3">
{stats.slice().reverse().map((stat, index) => {
const expense = parseFloat(stat.expense || '0')
const expenseWidth = maxExpense > 0 ? (expense / maxExpense) * 100 : 0
return (
<div key={stat.date || index} className="space-y-0.5">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">
{new Date(stat.date).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })}
</span>
<span className="text-[10px] text-red-600 font-medium">
-{expense.toFixed(2)}
</span>
</div>
<div className="bg-muted rounded-sm overflow-hidden h-2">
<AnimatedProgressBar
value={expenseWidth}
className="h-full rounded-sm [&>[data-slot=progress-indicator]]:bg-red-500/80"
/>
</div>
</div>
)
})}
</div>
</ScrollArea>
) : (
<div className="h-46 w-full flex items-center justify-center border border-dashed rounded-lg">
<p className="text-xs text-muted-foreground">暂无统计数据</p>
</div>
)}
</OverviewCard>
)
}
/**
* Top 客户卡片
* 用于显示向当前用户付款最多的客户排行
*
* @returns Top 客户卡片
*/
function TopCustomersCard() {
const [customers, setCustomers] = React.useState<TopCustomer[]>([])
const [loading, setLoading] = React.useState(true)
const [days] = React.useState(7)
const [limit] = React.useState(5)
const fetchTopCustomers = React.useCallback(async () => {
try {
setLoading(true)
const data = await DashboardService.getTopCustomers(days, limit)
setCustomers(data)
} catch (error) {
console.error('Failed to fetch top customers:', error)
} finally {
setLoading(false)
}
}, [days, limit])
React.useEffect(() => {
fetchTopCustomers()
}, [fetchTopCustomers])
/* 找出最大金额用于显示进度条 */
const maxAmount = customers && customers.length > 0
? Math.max(...customers.map(c => parseFloat(c.total_amount || '0')))
: 0
return (
<OverviewCard
title="Top 客户统计"
loading={loading}
onRefresh={fetchTopCustomers}
>
<ScrollArea className="h-46">
{loading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, index) => (
<div key={`customer-skeleton-${ index }`} className="space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Skeleton className="h-3 w-6" />
<Skeleton className="h-3 w-20" />
</div>
<Skeleton className="h-3 w-16" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-2 w-full rounded-full" />
<Skeleton className="h-3 w-8" />
</div>
</div>
))}
</div>
) : customers && customers.length > 0 ? (
<div className="space-y-2 pr-3">
{customers.map((customer, index) => {
const amount = parseFloat(customer.total_amount || '0')
const percentage = maxAmount > 0 ? (amount / maxAmount) * 100 : 0
return (
<div key={customer.user_id} className="space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className="text-xs font-semibold text-muted-foreground shrink-0">#{index + 1}</span>
<span className="text-xs font-medium truncate">{customer.username}</span>
</div>
<div className="text-xs font-semibold shrink-0 ml-2">
{amount.toFixed(2)} LDC
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex-1">
<AnimatedProgressBar
value={percentage}
className="h-2 rounded-full"
/>
</div>
<span className="text-[10px] text-muted-foreground shrink-0 w-8 text-right">
{customer.order_count}单
</span>
</div>
</div>
)
})}
</div>
) : (
<div className="h-46 flex items-center justify-center">
<p className="text-xs text-muted-foreground">暂无客户数据</p>
</div>
)}
</ScrollArea>
</OverviewCard>
)
}
/**
* 待处理的争议卡片
* 用于显示待处理的争议数据
*
* @returns 待处理的争议卡片
*/
function PendingDisputesCard({ onViewAll }: { onViewAll: () => void }) {
const { disputes, loading, handleRefresh, refetchData } = useDisputeData({
fetchFn: (params) => DisputeService.listMerchantDisputes(params)
})
return (
<OverviewCard
title="待处理的争议"
titleExtra={loading ? '-' : <CountingNumber number={disputes.count} decimalPlaces={0} />}
loading={loading}
onRefresh={handleRefresh}
onViewAll={onViewAll}
>
<ScrollArea className="h-46">
{loading ? (
<DisputeListSkeleton />
) : disputes.list.length > 0 ? (
<div className="space-y-1">
{disputes.list.map((dispute) => (
<div key={`merchant-${ dispute.id }`} className="flex items-center justify-between py-1 px-2 rounded-md bg-muted/50">
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate leading-tight">{dispute.order_name}</p>
<p className="text-[10px] text-muted-foreground leading-tight">{dispute.payee_username}</p>
</div>
<div className="ml-2">
<RefundReviewDialog order={createDisputeOrder(dispute, 'receive')} onSuccess={refetchData} />
</div>
</div>
))}
</div>
) : (
<div className="h-46 flex items-center justify-center">
<p className="text-muted-foreground text-xs">暂无待处理的积分活动争议</p>
</div>
)}
</ScrollArea>
</OverviewCard>
)
}
/**
* 我发起的争议卡片
* 用于显示我发起的争议数据
*
* @returns 我发起的争议卡片
*/
function MyDisputesCard({ onViewAll }: { onViewAll: () => void }) {
const { disputes, loading, handleRefresh, refetchData } = useDisputeData({
fetchFn: (params) => DisputeService.listDisputes(params)
})
return (
<OverviewCard
title="我发起的争议"
titleExtra={loading ? '-' : <CountingNumber number={disputes.count} decimalPlaces={0} />}
loading={loading}
onRefresh={handleRefresh}
onViewAll={onViewAll}
>
<ScrollArea className="h-46">
{loading ? (
<DisputeListSkeleton />
) : disputes.list.length > 0 ? (
<div className="space-y-1">
{disputes.list.map((dispute) => (
<div key={`user-${ dispute.id }`} className="flex items-center justify-between py-1 px-2 rounded-md bg-muted/50">
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate leading-tight">{dispute.order_name}</p>
<p className="text-[10px] text-muted-foreground leading-tight">积分活动发起者正在处理争议</p>
</div>
<div className="ml-2">
<CancelDisputeDialog order={createDisputeOrder(dispute, 'payment')} onSuccess={refetchData} />
</div>
</div>
))}
</div>
) : (
<div className="h-46 flex items-center justify-center">
<p className="text-muted-foreground text-xs">暂无我发起的积分活动争议</p>
</div>
)}
</ScrollArea>
</OverviewCard>
)
}
/**
* 概览面板组件
* 用于显示概览面板
*
* @returns 概览面板组件
*/
export function OverviewPanel() {
const router = useRouter()
const [disputeDialogOpen, setDisputeDialogOpen] = React.useState(false)
const [disputeDialogMode, setDisputeDialogMode] = React.useState<'pending' | 'my-disputes'>('pending')
const handleViewAllPending = () => {
setDisputeDialogMode('pending')
setDisputeDialogOpen(true)
}
const handleViewAllMyDisputes = () => {
setDisputeDialogMode('my-disputes')
setDisputeDialogOpen(true)
}
const handleViewAllPayments = () => {
router.push('/trade?type=payment')
}
return (
<>
<div className="bg-muted rounded-lg p-2 mt-2">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
<PaymentCard onViewAll={handleViewAllPayments} />
<IncomeStatsCard />
<ExpenseStatsCard />
<TopCustomersCard />
<PendingDisputesCard onViewAll={handleViewAllPending} />
<MyDisputesCard onViewAll={handleViewAllMyDisputes} />
</div>
</div>
<DisputeDialog
mode={disputeDialogMode}
open={disputeDialogOpen}
onOpenChange={setDisputeDialogOpen}
/>
</>
)
}
@@ -0,0 +1,102 @@
"use client"
import * as React from "react"
import { CircleHelp, RefreshCw } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { useUser } from "@/contexts/user-context"
import { useLeaderboard } from "@/hooks/use-leaderboard"
import { LeaderboardPodium } from "@/components/common/leaderboard/leaderboard-podium"
import { LeaderboardTable } from "@/components/common/leaderboard/leaderboard-table"
import { cn } from "@/lib/utils"
/**
* 排行榜主组件
*/
export function LeaderboardMain() {
const { user } = useUser()
const {
items,
myRank,
loading,
loadingMore,
myRankLoading,
hasMore,
loadNextPage,
refresh,
} = useLeaderboard()
const isInitialLoading = loading && items.length === 0
const currentUserEntry = React.useMemo(() => {
if (!myRank?.user) return undefined
const matchedEntry = items.find((item) => item.user_id === myRank.user.user_id)
if (matchedEntry) return matchedEntry
if (!user) return undefined
return {
user_id: myRank.user.user_id,
username: user.nickname || user.username,
avatar_url: user.avatar_url,
available_balance: myRank.user.available_balance,
}
}, [items, myRank, user])
return (
<div className="py-6 space-y-10">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold tracking-tight">全局排行榜</h1>
<div className="flex items-center gap-2">
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-muted-foreground">
<CircleHelp className="size-4" />
规则
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>排行榜如何运作</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm text-muted-foreground">
<p>排行榜按可用积分实时排序,数据会定期刷新。</p>
<p>参与社区活动、进行积分流转,都会影响可用积分与当前排名。</p>
</div>
</DialogContent>
</Dialog>
<Button variant="ghost" size="icon" className="size-8 text-muted-foreground" onClick={refresh} aria-label="刷新排行榜">
<RefreshCw className={cn("size-4", (loading || loadingMore || myRankLoading) && "animate-spin")} />
</Button>
</div>
</div>
<LeaderboardPodium
items={items.slice(0, 3)}
loading={isInitialLoading}
/>
<section>
<h2 className="font-semibold mb-4">完整榜单</h2>
<LeaderboardTable
items={items}
loading={isInitialLoading}
currentUserId={myRank?.user.user_id}
currentUserEntry={currentUserEntry}
currentUserRank={myRank?.user.rank}
onLoadMore={loadNextPage}
hasMore={hasMore}
startRank={1}
/>
</section>
</div>
)
}
@@ -0,0 +1,140 @@
"use client"
import * as React from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
import type { LeaderboardEntry } from "@/lib/services/leaderboard"
interface LeaderboardPodiumProps {
items: LeaderboardEntry[]
loading?: boolean
}
type PodiumRank = 1 | 2 | 3
function formatBalance(value: string) {
return Number.parseFloat(value).toLocaleString("zh-CN", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
const rankConfig = {
1: {
numColor: "from-amber-500/60 to-amber-500/8",
scoreColor: "text-amber-500",
avatarSize: "size-16 sm:size-[72px]",
numSize: "text-[72px] sm:text-[88px]",
nameSize: "text-base font-bold",
scoreSize: "text-lg font-black",
avatarNegMt: "-mt-6 sm:-mt-7",
order: "md:order-2",
shift: "",
},
2: {
numColor: "from-zinc-400/45 to-zinc-400/5",
scoreColor: "text-zinc-400",
avatarSize: "size-12 sm:size-[52px]",
numSize: "text-[52px] sm:text-[64px]",
nameSize: "text-sm font-semibold",
scoreSize: "text-sm font-bold",
avatarNegMt: "-mt-5",
order: "md:order-1",
shift: "md:translate-y-9",
},
3: {
numColor: "from-orange-500/55 to-orange-500/8",
scoreColor: "text-orange-500",
avatarSize: "size-12 sm:size-[52px]",
numSize: "text-[52px] sm:text-[64px]",
nameSize: "text-sm font-semibold",
scoreSize: "text-sm font-bold",
avatarNegMt: "-mt-5",
order: "md:order-3",
shift: "md:translate-y-14",
},
} as const
function PodiumSkeleton() {
return (
<div className="rounded-2xl bg-muted/25 px-6 pb-10 pt-6 sm:px-14">
<div className="grid grid-cols-3 items-end gap-4 sm:gap-12">
{([2, 1, 3] as PodiumRank[]).map((rank) => {
const cfg = rankConfig[rank]
return (
<div key={rank} className={cn("flex flex-col items-center gap-0", cfg.order)}>
<Skeleton className={cn("rounded-lg", rank === 1 ? "h-[100px] w-[76px]" : "h-[72px] w-[52px]")} />
<Skeleton className={cn("shrink-0 rounded-full", cfg.avatarSize, cfg.avatarNegMt)} />
<Skeleton className="mt-3 h-4 w-[70%]" />
<Skeleton className="mt-1.5 h-4 w-[45%]" />
</div>
)
})}
</div>
</div>
)
}
function PodiumColumn({ entry, rank }: { entry: LeaderboardEntry; rank: PodiumRank }) {
const cfg = rankConfig[rank]
return (
<article className={cn("flex min-w-0 flex-col items-center", cfg.order, cfg.shift)}>
<div
className={cn(
"select-none bg-clip-text text-transparent font-black leading-none tracking-[-0.05em] -mt-8",
cfg.numSize,
`bg-linear-to-b ${cfg.numColor}`,
)}
>
{String(rank).padStart(2, "0")}
</div>
<Avatar className={cn("shrink-0 rounded-full", cfg.avatarSize, cfg.avatarNegMt)}>
<AvatarImage src={entry.avatar_url} alt={entry.username} className="object-cover" />
<AvatarFallback className={cn("rounded-full font-bold", rank === 1 ? "text-sm" : "text-xs")}>
{entry.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className={cn("mt-2.5 w-full truncate text-center tracking-tight text-foreground", cfg.nameSize)}>
{entry.username}
</div>
<div className={cn("mt-0.5 mb-3 tabular-nums tracking-tight", cfg.scoreSize, cfg.scoreColor)}>
{formatBalance(entry.available_balance)}
</div>
</article>
)
}
export const LeaderboardPodium = React.memo(function LeaderboardPodium({
items,
loading,
}: LeaderboardPodiumProps) {
if (loading) return <PodiumSkeleton />
if (items.length === 0) {
return <div className="py-16 text-center text-muted-foreground">暂无排行数据</div>
}
const ordered = [
items[1] ? { entry: items[1], rank: 2 as PodiumRank } : null,
items[0] ? { entry: items[0], rank: 1 as PodiumRank } : null,
items[2] ? { entry: items[2], rank: 3 as PodiumRank } : null,
].filter(Boolean) as Array<{ entry: LeaderboardEntry; rank: PodiumRank }>
return (
<div className="rounded-2xl bg-muted/25 px-6 pb-10 pt-6 sm:px-14">
<div className="grid grid-cols-3 items-end gap-4 sm:gap-12">
{ordered.map(({ entry, rank }) => (
<PodiumColumn key={entry.user_id} entry={entry} rank={rank} />
))}
</div>
</div>
)
})
@@ -0,0 +1,182 @@
"use client"
import * as React from "react"
import { useVirtualizer } from "@tanstack/react-virtual"
import { Skeleton } from "@/components/ui/skeleton"
import { ScrollArea } from "@/components/ui/scroll-area"
import { RankRowItem } from "./rank-row-item"
import type { LeaderboardEntry } from "@/lib/services/leaderboard"
const leaderboardTableColumns = "grid-cols-[72px_minmax(0,1fr)_132px] sm:grid-cols-[88px_minmax(0,1fr)_152px]"
const leaderboardRowHeight = 60
interface LeaderboardTableProps {
items: LeaderboardEntry[]
loading?: boolean
currentUserId?: number
currentUserEntry?: LeaderboardEntry
currentUserRank?: number
onLoadMore?: () => void
hasMore?: boolean
startRank?: number
}
export const LeaderboardTable = React.memo(function LeaderboardTable({
items,
loading,
currentUserId,
currentUserEntry,
currentUserRank,
onLoadMore,
hasMore,
startRank = 1,
}: LeaderboardTableProps) {
const parentRef = React.useRef<HTMLDivElement>(null)
const maxBalance = React.useMemo(() => {
if (items.length === 0) return 1
return Math.max(...items.map(item => parseFloat(item.available_balance))) || 1
}, [items])
const computePercentage = React.useCallback(
(balance: number) => {
const denominator = Math.log(maxBalance + 1)
if (denominator <= 0) return 0
const safeBalance = Math.max(balance, 0)
return (Math.log(safeBalance + 1) / denominator) * 100
},
[maxBalance]
)
const rankedItems = React.useMemo(
() =>
items
.map((entry, index) => ({
entry,
rank: startRank + index,
percentage: computePercentage(parseFloat(entry.available_balance)),
}))
.filter(({ entry }) => entry.user_id !== currentUserEntry?.user_id),
[items, startRank, currentUserEntry?.user_id, computePercentage]
)
const virtualizer = useVirtualizer({
count: rankedItems.length,
getScrollElement: () => parentRef.current,
estimateSize: () => leaderboardRowHeight,
overscan: 6,
useFlushSync: false,
})
const virtualItems = virtualizer.getVirtualItems()
React.useEffect(() => {
const lastItem = virtualItems[virtualItems.length - 1]
if (!lastItem) return
if (lastItem.index >= rankedItems.length - 1 && hasMore && !loading && onLoadMore) {
onLoadMore()
}
}, [virtualItems, rankedItems.length, hasMore, loading, onLoadMore])
const currentUserPercentage = currentUserEntry
? computePercentage(parseFloat(currentUserEntry.available_balance))
: 0
if (loading && items.length === 0) {
return (
<div>
<div className={`grid ${leaderboardTableColumns} items-center px-3 py-2.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground`}>
<div>排名</div>
<div>用户</div>
<div className="text-right">可用积分</div>
</div>
<div className="mt-1 overflow-hidden rounded-xl bg-linear-to-b from-muted/30 to-transparent">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className={`grid h-[60px] ${leaderboardTableColumns} items-center gap-3 px-3`}>
<Skeleton className="h-4 w-8" />
<div className="flex items-center gap-3">
<Skeleton className="size-9 rounded-full" />
<Skeleton className="h-4 w-24" />
</div>
<Skeleton className="ml-auto h-5 w-16" />
</div>
))}
</div>
</div>
)
}
if (items.length === 0) {
return <div className="py-16 text-center text-muted-foreground">暂无排行数据</div>
}
return (
<div>
{/* 列头 */}
<div className={`grid ${leaderboardTableColumns} items-center px-3 py-2.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground`}>
<div>排名</div>
<div>用户</div>
<div className="text-right">可用积分</div>
</div>
{/* 当前用户置顶行 */}
{currentUserEntry && currentUserRank && (
<div className="mt-1 overflow-hidden rounded-xl bg-primary/[0.04]">
<RankRowItem
entry={currentUserEntry}
rank={currentUserRank}
percentage={currentUserPercentage}
isCurrentUser
pinned
metaLabel="我的位置"
/>
</div>
)}
{/* 榜单主体 — 从 muted 过渡到页面背景色 */}
<div className="relative mt-2">
<div className="overflow-hidden rounded-xl bg-linear-to-b from-muted/30 via-muted/10 to-transparent">
<ScrollArea className="h-[440px] sm:h-[480px]" viewportRef={parentRef}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{virtualItems.map((virtualRow) => {
const { entry, rank, percentage } = rankedItems[virtualRow.index]
return (
<div
key={entry.user_id}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<RankRowItem
entry={entry}
rank={rank}
percentage={percentage}
isCurrentUser={entry.user_id === currentUserId}
/>
</div>
)
})}
</div>
</ScrollArea>
</div>
</div>
{loading && items.length > 0 && (
<div className="py-3 text-center text-sm text-muted-foreground">
正在加载更多...
</div>
)}
</div>
)
})
@@ -0,0 +1,80 @@
"use client"
import * as React from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { cn } from "@/lib/utils"
import type { LeaderboardEntry } from "@/lib/services/leaderboard"
const leaderboardRowColumns = "grid-cols-[72px_minmax(0,1fr)_112px] sm:grid-cols-[88px_minmax(0,1fr)_128px]"
interface RankRowItemProps {
entry: LeaderboardEntry
rank: number
percentage?: number
isCurrentUser?: boolean
metaLabel?: string
pinned?: boolean
}
export const RankRowItem = React.memo(function RankRowItem({
entry,
rank,
percentage = 0,
isCurrentUser,
metaLabel,
pinned = false,
}: RankRowItemProps) {
const rankClass =
rank === 1 ? "text-amber-500 font-black" :
rank === 2 ? "text-zinc-400 font-bold" :
rank === 3 ? "text-orange-500 font-bold" :
"text-muted-foreground"
return (
<div
className={cn(
"relative grid h-[60px] items-center px-3 transition-colors hover:bg-muted/30",
leaderboardRowColumns,
isCurrentUser && !pinned && "bg-primary/[0.03]",
pinned && "bg-transparent"
)}
>
{/* 进度条背景 */}
{percentage > 0 && (
<div
className="pointer-events-none absolute inset-y-0 left-0 transition-all duration-500"
style={{
width: `${Math.max(percentage, 4)}%`,
background: "linear-gradient(to right, oklch(58.5% 51% 277.117 / 0.15) 65%, transparent)",
}}
/>
)}
<div className={cn("relative truncate pr-2 text-sm tabular-nums", rankClass)}>
#{rank}
</div>
<div className="relative flex min-w-0 items-center gap-3">
<Avatar className="size-9 shrink-0">
<AvatarImage src={entry.avatar_url} alt={entry.username} />
<AvatarFallback className="text-xs font-semibold">
{entry.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="truncate text-sm font-medium">{entry.username}</div>
{metaLabel && (
<div className={cn("text-[11px]", pinned ? "text-primary/70" : "text-muted-foreground")}>
{metaLabel}
</div>
)}
</div>
</div>
<div className="relative text-right text-sm font-semibold tabular-nums text-foreground sm:text-[15px]">
{parseFloat(entry.available_balance).toFixed(2)}
</div>
</div>
)
})
@@ -0,0 +1,68 @@
"use client"
import * as React from "react"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
import type { UserRankResponse } from "@/lib/services/leaderboard"
import { UserRound } from "lucide-react"
interface UserRankCardProps {
data: UserRankResponse | null
loading?: boolean
className?: string
}
export const UserRankCard = React.memo(function UserRankCard({
data,
loading,
className,
}: UserRankCardProps) {
if (loading) {
return (
<section className={cn("space-y-4", className)}>
<div className="flex items-center gap-2">
<UserRound className="size-4 text-muted-foreground" />
<h2 className="text-lg font-semibold">我的排名</h2>
</div>
<div className="space-y-4 px-1 py-1">
{Array.from({ length: 2 }).map((_, index) => (
<div key={index} className="space-y-2">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-8 w-24" />
</div>
))}
</div>
</section>
)
}
return (
<section className={cn("space-y-4", className)}>
<div className="flex items-center gap-2">
<UserRound className="size-4 text-muted-foreground" />
<h2 className="text-lg font-semibold">我的排名</h2>
</div>
<div className="space-y-5 px-1 py-1">
{data ? (
<>
<div className="space-y-1">
<div className="text-[11px] font-medium text-muted-foreground">当前名次</div>
<div className="text-3xl font-semibold tabular-nums">#{data.user.rank.toLocaleString()}</div>
</div>
<div className="space-y-1">
<div className="text-[11px] font-medium text-muted-foreground">可用积分</div>
<div className="text-2xl font-semibold tabular-nums">{parseFloat(data.user.available_balance).toFixed(2)}</div>
</div>
</>
) : (
<div className="space-y-1">
<div className="text-[11px] font-medium text-muted-foreground">当前状态</div>
<div className="text-base font-medium">暂未进入榜单</div>
<div className="text-sm text-muted-foreground">继续积累可用积分后会出现在排行榜中。</div>
</div>
)}
</div>
</section>
)
})
@@ -0,0 +1,183 @@
"use client"
import * as React from "react"
import { useEffect } from "react"
import { useRouter } from "next/navigation"
import { Undo2, FileText, Link2 } from "lucide-react"
import { toast } from "sonner"
import { TableFilter, mapDisplayStatusToQuery, type DisplayOrderStatus, type SearchValues } from "@/components/common/general/table-filter"
import { TransactionTableList } from "@/components/common/general/table-data"
import { type MerchantAPIKey, type OrderType } from "@/lib/services"
import { TransactionProvider, useTransaction } from "@/contexts/transaction-context"
import { formatLocalDate } from "@/lib/utils"
/** 集市功能列表 */
const MERCHANT_ACTIONS = [
{ title: "处理争议", description: "获取此应用的所有争议", icon: Undo2, color: "text-orange-600", bgColor: "bg-orange-50 dark:bg-orange-950/20", action: "refund" as const },
{ title: "所有活动", description: "显示此应用的所有活动", icon: FileText, color: "text-blue-600", bgColor: "bg-blue-50 dark:bg-blue-950/20", action: "view-all" as const },
{ title: "在线流转", description: "创建在线积分流转活动", icon: Link2, color: "text-purple-600", bgColor: "bg-purple-50 dark:bg-purple-950/20", action: "online-payment" as const },
]
/** 获取默认日期范围(最近30天) */
const getDefaultDateRange = () => {
const today = new Date()
today.setHours(0, 0, 0, 0)
const from = new Date(today)
from.setDate(from.getDate() - 29)
return { from, to: today }
}
interface MerchantDataProps {
apiKey: MerchantAPIKey
}
/**
* 集市数据组件
* 显示应用的活动数据和统计信息
*/
export function MerchantData({ apiKey }: MerchantDataProps) {
const { from: start, to: end } = getDefaultDateRange()
return (
<TransactionProvider
defaultParams={{
page_size: 20,
startTime: formatLocalDate(start),
endTime: formatLocalDate(end),
client_id: apiKey.client_id
}}
>
<MerchantDataContent apiKey={apiKey} />
</TransactionProvider>
)
}
/**
* 集市数据内容组件
*/
function MerchantDataContent({ apiKey }: MerchantDataProps) {
const router = useRouter()
const { transactions, total, currentPage, totalPages, pageSize, loading, error, fetchTransactions, goToPage, setPageSize: setPageSizeHandler } = useTransaction()
const [selectedTypes, setSelectedTypes] = React.useState<OrderType[]>([])
const [selectedStatus, setSelectedStatus] = React.useState<DisplayOrderStatus | null>(null)
const [selectedQuickSelection, setSelectedQuickSelection] = React.useState<string | null>("最近 1 个月")
const [dateRange, setDateRange] = React.useState<{ from: Date; to: Date } | null>(getDefaultDateRange)
const [selectedSearch, setSelectedSearch] = React.useState<SearchValues>({})
const buildQueryParams = React.useCallback((page: number) => {
const stateQuery = mapDisplayStatusToQuery(selectedStatus)
return {
page,
page_size: pageSize,
types: selectedTypes.length > 0 ? selectedTypes : undefined,
...stateQuery,
startTime: dateRange ? formatLocalDate(dateRange.from) : undefined,
endTime: dateRange ? (() => {
const endDate = new Date(dateRange.to)
endDate.setHours(0, 0, 0, 0)
endDate.setDate(endDate.getDate() + 1)
return formatLocalDate(endDate)
})() : undefined,
client_id: apiKey.client_id,
id: selectedSearch.id || undefined,
order_name: selectedSearch.order_name || undefined,
payer_username: selectedSearch.payer_username || undefined,
payee_username: selectedSearch.payee_username || undefined,
}
}, [apiKey.client_id, dateRange, pageSize, selectedSearch, selectedStatus, selectedTypes])
const clearAllFilters = () => {
setSelectedTypes([])
setSelectedStatus(null)
setDateRange(null)
setSelectedQuickSelection(null)
setSelectedSearch({})
}
useEffect(() => {
fetchTransactions(buildQueryParams(1))
}, [buildQueryParams, fetchTransactions])
const actionHandlers = {
refund: () => {
setSelectedStatus('disputing')
toast.success('待处理的争议', { description: '显示此应用的所有待处理的争议' })
},
'view-all': () => {
clearAllFilters()
toast.success('全部活动', { description: '显示此应用的所有积分活动' })
},
'online-payment': () => router.push(`/merchant/online-paying?apiKeyId=${ apiKey.id }`)
}
return (
<div className="space-y-6">
<div>
<h2 className="font-semibold mb-4">应用服务</h2>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
{MERCHANT_ACTIONS.map((action) => (
<button
key={action.action}
className="rounded-lg p-4 border border-dashed hover:border-primary/50 transition-all text-left group cursor-pointer"
onClick={() => actionHandlers[action.action]()}
>
<div className="flex items-center gap-4">
<div className={`rounded-lg p-2 ${ action.bgColor }`}>
<action.icon className={`size-4 ${ action.color }`} />
</div>
<div className="flex-1">
<h3 className="font-medium text-sm group-hover:text-foreground">{action.title}</h3>
<p className="text-xs text-muted-foreground">{action.description}</p>
</div>
</div>
</button>
))}
</div>
</div>
<div>
<h2 className="font-semibold mb-4">活动记录</h2>
<div className="space-y-2">
<TableFilter
enabledFilters={{
type: true,
status: true,
timeRange: true,
search: true
}}
selectedTypes={selectedTypes}
selectedStatus={selectedStatus}
selectedTimeRange={dateRange}
selectedQuickSelection={selectedQuickSelection}
onTypeChange={setSelectedTypes}
onStatusChange={setSelectedStatus}
onTimeRangeChange={setDateRange}
onQuickSelectionChange={setSelectedQuickSelection}
onSearch={setSelectedSearch}
searchValues={selectedSearch}
onClearAll={clearAllFilters}
enablePagination={true}
currentPage={currentPage}
totalPages={totalPages}
pageSize={pageSize}
total={total}
onPageChange={goToPage}
onPageSizeChange={setPageSizeHandler}
loading={loading}
/>
<TransactionTableList
loading={loading}
error={error}
transactions={transactions}
onRetry={() => fetchTransactions(buildQueryParams(1))}
emptyDescription="未发现积分活动记录"
/>
</div>
</div>
</div>
)
}
@@ -0,0 +1,366 @@
"use client"
import * as React from "react"
import { useState, useEffect } from "react"
import { toast } from "sonner"
import { Info } from "lucide-react"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import services, { type MerchantAPIKey, type CreateAPIKeyRequest, type UpdateAPIKeyRequest } from "@/lib/services"
interface MerchantDialogProps {
/** 模式:创建或更新 */
mode: 'create' | 'update'
/** API Key*/
apiKey?: MerchantAPIKey
/** 创建成功回调 */
onSuccess: (newKey: MerchantAPIKey) => void
/** 更新成功回调 */
onUpdate?: (updatedKey: MerchantAPIKey) => void
/** 触发按钮 */
trigger?: React.ReactNode
/** 自定义创建函数 */
createAPIKey?: (data: CreateAPIKeyRequest) => Promise<MerchantAPIKey>
/** 更新函数 */
updateAPIKey?: (id: string, data: UpdateAPIKeyRequest) => Promise<void>
}
/**
* 集市中心应用对话框组件
* 负责创建和更新集市中心应用的表单和验证逻辑
*/
export function MerchantDialog({
mode,
apiKey,
onSuccess,
onUpdate,
trigger,
createAPIKey,
updateAPIKey
}: MerchantDialogProps) {
const [open, setOpen] = useState(false)
const [processing, setProcessing] = useState(false)
const [mounted, setMounted] = useState(false)
const [showNotifyTooltip, setShowNotifyTooltip] = useState(false)
const [tooltipSide, setTooltipSide] = useState<'top' | 'bottom'>('bottom')
const [formData, setFormData] = useState<CreateAPIKeyRequest | UpdateAPIKeyRequest>({
app_name: '',
app_homepage_url: '',
app_description: '',
redirect_uri: '',
notify_url: '',
public_key: '',
test_mode: false,
})
useEffect(() => {
setMounted(true)
}, [])
/**
* 获取初始表单数据
*/
const getInitialFormData = React.useCallback((): CreateAPIKeyRequest | UpdateAPIKeyRequest => {
if (mode === 'update' && apiKey) {
return {
app_name: apiKey.app_name,
app_homepage_url: apiKey.app_homepage_url,
app_description: apiKey.app_description,
redirect_uri: apiKey.redirect_uri,
notify_url: apiKey.notify_url,
public_key: apiKey.public_key,
test_mode: apiKey.test_mode,
}
}
return {
app_name: '',
app_homepage_url: '',
app_description: '',
redirect_uri: '',
notify_url: '',
test_mode: false,
}
}, [mode, apiKey])
/**
* 初始化表单数据
*/
useEffect(() => {
setFormData(getInitialFormData())
}, [getInitialFormData])
const isValidUrl = (url: string): boolean => {
if (!url || url.trim() === '') return false
try {
const urlObj = new URL(url.trim())
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:'
} catch {
return false
}
}
const resetForm = () => {
setFormData(getInitialFormData())
}
const validateForm = (): { valid: boolean; error?: string } => {
/* 验证必填项 */
if (!formData.app_name || !formData.app_homepage_url || !formData.notify_url) {
return { valid: false, error: '请填写所有必填项' }
}
/* 验证应用名称长度 */
if (formData.app_name.length > 20) {
return { valid: false, error: '应用名称不能超过 20 个字符' }
}
/* 验证应用主页 URL */
if (!isValidUrl(formData.app_homepage_url)) {
return { valid: false, error: '应用主页 URL 格式不正确' }
}
/* 验证回调 URI */
if (formData.redirect_uri && !isValidUrl(formData.redirect_uri)) {
return { valid: false, error: '回调 URI 格式不正确' }
}
/* 验证通知 URL */
if (!isValidUrl(formData.notify_url)) {
return { valid: false, error: '通知 URL 格式不正确' }
}
/* 验证应用描述长度 */
if (formData.app_description && formData.app_description.length > 100) {
return { valid: false, error: '应用描述不能超过 100 个字符' }
}
return { valid: true }
}
const handleSubmit = async () => {
const validation = validateForm()
if (!validation.valid) {
toast.error('表单验证失败', {
description: validation.error
})
return
}
try {
setProcessing(true)
if (mode === 'create') {
const newKey = await (createAPIKey ? createAPIKey(formData as CreateAPIKeyRequest) : services.merchant.createAPIKey(formData as CreateAPIKeyRequest))
toast.success('创建成功', {
description: '新应用已创建,请妥善保管您的 Client Secret'
})
onSuccess(newKey)
} else if (mode === 'update' && apiKey) {
if (updateAPIKey) {
await updateAPIKey(apiKey.id, formData as UpdateAPIKeyRequest)
} else {
await services.merchant.updateAPIKey(apiKey.id, formData as UpdateAPIKeyRequest)
}
toast.success('更新成功', {
description: '应用信息已更新'
})
/* 更新本地状态 */
const updatedKey = { ...apiKey, ...formData }
onUpdate?.(updatedKey)
}
setOpen(false)
} catch (error) {
const errorMessage = (error as Error).message || `无法${mode === 'create' ? '创建' : '更新'}应用`
toast.error(`${mode === 'create' ? '创建' : '更新'}失败`, {
description: errorMessage
})
throw error
} finally {
setProcessing(false)
}
}
const handleOpenChange = (newOpen: boolean) => {
if (newOpen && !processing) {
resetForm()
}
setOpen(newOpen)
}
if (!mounted) {
return null
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
{trigger || (
<Button className="h-8 text-xs">
{mode === 'create' ? '创建应用' : '更新应用'}
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{mode === 'create' ? '创建应用' : '更新应用信息'}</DialogTitle>
<DialogDescription>
{mode === 'create' ? '您可以创建一个集市中心应用来接入积分服务,请仔细填写以下信息' : '修改集市中心应用的基本信息和配置'}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="app_name">应用名称 <span className="text-red-500">*</span></Label>
<Input
id="app_name"
placeholder="您的应用名称。最多 20 个字符,用于标识您的应用"
maxLength={20}
value={formData.app_name}
onChange={(e) => setFormData({ ...formData, app_name: e.target.value })}
disabled={processing}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="app_description">应用描述</Label>
<Input
id="app_description"
placeholder="您的应用描述。最多 100 个字符,用于描述您的应用,可选"
maxLength={100}
value={formData.app_description}
onChange={(e) => setFormData({ ...formData, app_description: e.target.value })}
disabled={processing}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="app_homepage_url">应用主页 URL <span className="text-red-500">*</span></Label>
<Input
id="app_homepage_url"
type="url"
placeholder="URL 必须包含 http:// 或 https:// ,用于展示您的应用主页"
maxLength={100}
value={formData.app_homepage_url}
onChange={(e) => setFormData({ ...formData, app_homepage_url: e.target.value })}
disabled={processing}
/>
</div>
<div className="grid gap-2">
<div className="flex items-center gap-1.5">
<Label htmlFor="notify_url">通知 URL <span className="text-red-500">*</span></Label>
<TooltipProvider>
<Tooltip open={showNotifyTooltip} onOpenChange={setShowNotifyTooltip}>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
onMouseEnter={() => {
setTooltipSide('bottom')
setShowNotifyTooltip(true)
}}
onClick={() => {
setTooltipSide('bottom')
setShowNotifyTooltip(!showNotifyTooltip)
}}
>
<Info className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side={tooltipSide} className="w-64">
<div className="space-y-2">
<p className="text-xs">如果您只需要使用在线积分流转服务,建议直接填写 http://localhost:3000/ 即可</p>
<Button
size="sm"
variant="secondary"
className="w-full h-6 text-xs"
onClick={() => {
setFormData({ ...formData, notify_url: 'http://localhost:3000/' })
setShowNotifyTooltip(false)
}}
>
快速填入
</Button>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Input
id="notify_url"
type="url"
placeholder="URL 必须为包含 http:// 或 https:// ,用于接收积分服务成功的异步通知"
maxLength={100}
value={formData.notify_url}
onChange={(e) => {
const value = e.target.value
setFormData({ ...formData, notify_url: value })
if (value.trim()) {
setShowNotifyTooltip(false)
}
}}
onClick={() => {
if (!formData.notify_url || !formData.notify_url.trim()) {
setTooltipSide('top')
setShowNotifyTooltip(true)
}
}}
onBlur={() => setShowNotifyTooltip(false)}
disabled={processing}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="redirect_uri">回调 URI</Label>
<Input
id="redirect_uri"
type="url"
placeholder="URL 必须包含 http:// 或 https:// ,用于接收积分服务完成后的回调,可选"
maxLength={100}
value={formData.redirect_uri}
onChange={(e) => setFormData({ ...formData, redirect_uri: e.target.value })}
disabled={processing}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="public_key">Ed25519 公钥 (Base64)</Label>
<Input
id="public_key"
placeholder="可选,用于请求签名验证,必须为 32 字节的 Ed25519 公钥(Base64 编码)"
maxLength={100}
value={formData.public_key || ''}
onChange={(e) => setFormData({ ...formData, public_key: e.target.value })}
disabled={processing}
/>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost" disabled={processing} className="h-8 text-xs">取消</Button>
</DialogClose>
<Button
onClick={(e) => {
e.preventDefault()
handleSubmit()
}}
disabled={processing}
className="h-8 text-xs"
>
{processing ? <><Spinner /> {mode === 'create' ? '创建中' : '更新中'}</> : (mode === 'create' ? '创建' : '更新')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog >
)
}
@@ -0,0 +1,221 @@
"use client"
import * as React from "react"
import { useState } from "react"
import { toast } from "sonner"
import { Coins } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Spinner } from "@/components/ui/spinner"
import { MerchantService, type MerchantAPIKey } from "@/lib/services"
interface DistributeDialogProps {
/** 自定义触发器 */
trigger?: React.ReactNode
/** 商户凭证 */
apiKey?: Pick<MerchantAPIKey, "client_id" | "client_secret">
}
/**
* 商户分发对话框
* 商户向用户分发积分
*/
export function DistributeDialog({ trigger, apiKey }: DistributeDialogProps) {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [userId, setUserId] = useState("")
const [username, setUsername] = useState("")
const [amount, setAmount] = useState("")
const [remark, setRemark] = useState("")
const resetForm = () => {
setUserId("")
setUsername("")
setAmount("")
setRemark("")
}
const handleOpenChange = (newOpen: boolean) => {
if (newOpen && !loading) {
resetForm()
}
setOpen(newOpen)
}
const handleSubmit = async () => {
if (!userId.trim()) {
toast.error('表单验证失败', { description: '请填写用户 ID' })
return
}
if (!username.trim()) {
toast.error('表单验证失败', { description: '请填写用户名' })
return
}
if (!amount.trim()) {
toast.error('表单验证失败', { description: '请填写分发金额' })
return
}
const amountNum = parseFloat(amount)
if (isNaN(amountNum) || amountNum <= 0) {
toast.error('表单验证失败', { description: '分发金额必须大于 0' })
return
}
if (amountNum > 999999.99) {
toast.error('表单验证失败', { description: '分发金额不能超过 999999.99' })
return
}
if (remark.length > 100) {
toast.error('表单验证失败', { description: '备注不能超过 100 个字符' })
return
}
try {
setLoading(true)
if (!apiKey?.client_id || !apiKey?.client_secret) {
toast.error('缺少应用凭证', { description: '请先创建应用并确认凭证可用' })
return
}
const result = await MerchantService.distribute({
user_id: Number(userId.trim()),
username: username.trim(),
amount: amountNum,
remark: remark.trim() || undefined,
}, {
client_id: apiKey.client_id,
client_secret: apiKey.client_secret,
})
toast.success('分发成功', {
description: `订单号: ${ result.trade_no }`
})
setOpen(false)
resetForm()
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : '分发失败'
toast.error('分发失败', { description: errorMessage })
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
{trigger || (
<Button variant="outline" className="text-xs h-8 border-dashed w-full shadow-none">
<Coins className="size-3 mr-1" />
积分分发
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>积分分发</DialogTitle>
<DialogDescription>
向指定用户分发积分。您的积分将被扣除,对方收到的积分会扣除分发费率。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="user-id" className="text-xs">
用户 ID <span className="text-destructive">*</span>
</Label>
<Input
id="user-id"
placeholder="被分发者的用户 ID"
value={userId}
onChange={(e) => setUserId(e.target.value)}
disabled={loading}
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="username" className="text-xs">
用户名 <span className="text-destructive">*</span>
</Label>
<Input
id="username"
placeholder="被分发者的用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
className="h-8 text-xs"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="amount" className="text-xs">
分发积分 <span className="text-destructive">*</span>
</Label>
<Input
id="amount"
type="number"
step="0.01"
min="0"
max="999999.99"
placeholder="积分数量,用户将收到扣除分发费率后的积分"
value={amount}
onChange={(e) => setAmount(e.target.value)}
disabled={loading}
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label htmlFor="remark" className="text-xs">
备注
</Label>
<Textarea
id="remark"
placeholder="积分分发备注,最多100字 (可选)"
value={remark}
onChange={(e) => setRemark(e.target.value)}
disabled={loading}
maxLength={100}
className="h-16 text-xs resize-none"
/>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost" disabled={loading} className="h-8 text-xs">
取消
</Button>
</DialogClose>
<Button
onClick={(e) => { e.preventDefault(); handleSubmit() }}
disabled={loading}
className="h-8 text-xs"
>
{loading ? <><Spinner /> 分发中</> : '分发'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,263 @@
import * as React from "react"
import { useState } from "react"
import Link from "next/link"
import { toast } from "sonner"
import { Copy, Eye, EyeOff, Trash2, ExternalLink, Edit } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { MerchantDialog } from "@/components/common/merchant/merchant-dialog"
import { TestModeToggle } from "@/components/common/merchant/merchant-test"
import { DistributeDialog } from "@/components/common/merchant/merchant-distribute"
import { formatDateTime } from "@/lib/utils"
import { type UpdateAPIKeyRequest, type MerchantAPIKey } from "@/lib/services"
interface MerchantInfoProps {
/** API Key */
apiKey: MerchantAPIKey
/** 更新回调 */
onUpdate?: (updatedKey: MerchantAPIKey) => void
/** 删除回调 */
onDelete: (id: string) => void
/** 更新 API Key */
updateAPIKey?: (id: string, data: UpdateAPIKeyRequest) => Promise<void>
}
/**
* 集市中心应用信息组件
* 显示集市中心应用的凭证信息(Client ID 和 Secret)
*/
export function MerchantInfo({ apiKey, onUpdate, onDelete, updateAPIKey }: MerchantInfoProps) {
const [showClientId, setShowClientId] = useState(false)
const [showClientSecret, setShowClientSecret] = useState(false)
/* 复制到剪贴板 */
const copyToClipboard = async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text)
toast.success(`${ label } 已复制`)
} catch {
try {
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.opacity = '0'
document.body.appendChild(textArea)
textArea.select()
const successful = document.execCommand('copy')
document.body.removeChild(textArea)
if (successful) {
toast.success(`${ label } 已复制`)
} else {
toast.error('复制失败')
}
} catch {
toast.error('复制失败')
}
}
}
const maskText = (text: string, showLength: number = 8) => {
if (text.length <= showLength * 2) return text
return `${ text.substring(0, showLength) }${ '•'.repeat(20) }${ text.substring(text.length - showLength) }`
}
return (
<div className="space-y-6 sticky top-0">
<div>
<h2 className="font-semibold mb-4">应用信息</h2>
<div className="border border-dashed rounded-lg">
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">应用名称</label>
<div className="flex items-center gap-2 max-w-[70%]">
<p className="text-xs font-medium truncate">{apiKey.app_name}</p>
{apiKey.test_mode && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-orange-500/10 text-orange-600 border border-orange-500/20">
测试
</span>
)}
</div>
</div>
{apiKey.app_description && (
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">应用描述</label>
<p className="text-xs text-muted-foreground truncate text-right max-w-[70%]">{apiKey.app_description}</p>
</div>
)}
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">创建时间</label>
<p className="text-xs text-muted-foreground text-right max-w-[70%]">{formatDateTime(apiKey.created_at)}</p>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">应用地址</label>
<Link
href={apiKey.app_homepage_url}
target="_blank"
className="text-xs text-indigo-500 hover:underline flex items-center gap-1 text-right max-w-[70%]"
>
<span className="truncate flex-1 min-w-0">
{apiKey.app_homepage_url}
</span>
<ExternalLink className="size-3 flex-shrink-0 ml-1" />
</Link>
</div>
{apiKey.redirect_uri && (
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">回调 URI</label>
<Link
href={apiKey.redirect_uri}
target="_blank"
className="text-xs text-indigo-500 hover:underline flex items-center gap-1 text-right max-w-[70%]"
>
<span className="truncate flex-1 min-w-0">
{apiKey.redirect_uri}
</span>
<ExternalLink className="size-3 flex-shrink-0 ml-1" />
</Link>
</div>
)}
<div className="px-3 py-2 flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">通知 URL</label>
<Link
href={apiKey.notify_url}
target="_blank"
className="text-xs text-indigo-500 hover:underline flex items-center gap-1 text-right max-w-[70%]"
>
<span className="truncate flex-1 min-w-0">
{apiKey.notify_url}
</span>
<ExternalLink className="size-3 flex-shrink-0 ml-1" />
</Link>
</div>
</div>
</div>
<div>
<h2 className="font-semibold mb-4">API 配置</h2>
<div className="border border-dashed rounded-lg px-3 py-2 space-y-4">
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-xs font-medium text-muted-foreground">Client ID</label>
<span className="text-[10px] text-muted-foreground">客户端标识</span>
</div>
<div className="flex items-center p-2 h-8 border border-dashed rounded-sm">
<code className="text-xs text-muted-foreground font-mono flex-1 overflow-x-auto p-1 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
{showClientId ? apiKey.client_id : maskText(apiKey.client_id, 8)}
</code>
<Button
variant="ghost"
className="size-6 p-1"
onClick={() => setShowClientId(!showClientId)}
>
{showClientId ? <EyeOff className="size-3 text-muted-foreground" /> : <Eye className="size-3 text-muted-foreground" />}
</Button>
<Button
variant="ghost"
className="size-6 p-1"
onClick={() => copyToClipboard(apiKey.client_id, 'Client ID')}
>
<Copy className="size-3 text-muted-foreground" />
</Button>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-xs font-medium text-muted-foreground">Client Secret</label>
<span className="text-[10px] text-muted-foreground">客户端密钥</span>
</div>
<div className="flex items-center p-2 h-8 border border-dashed rounded-sm">
<code className="text-xs text-muted-foreground font-mono flex-1 overflow-x-auto p-1 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
{showClientSecret ? apiKey.client_secret : '•'.repeat(40)}
</code>
<Button
variant="ghost"
className="size-6 p-1"
onClick={() => setShowClientSecret(!showClientSecret)}
>
{showClientSecret ? <EyeOff className="size-3 text-muted-foreground" /> : <Eye className="size-3 text-muted-foreground" />}
</Button>
<Button
variant="ghost"
onClick={() => copyToClipboard(apiKey.client_secret, 'Client Secret')}
className="size-6 p-1"
>
<Copy className="size-3" />
</Button>
</div>
</div>
</div>
</div>
<div>
<h2 className="font-semibold mb-4">应用管理</h2>
<div className="grid grid-cols-2 gap-2">
<MerchantDialog
mode="update"
apiKey={apiKey}
onSuccess={() => { }}
onUpdate={onUpdate}
updateAPIKey={updateAPIKey}
trigger={
<Button variant="outline" className="text-xs text-primary h-8 border-dashed border-primary/50 hover:bg-primary/5 w-full">
<Edit className="size-3 mr-1" />
编辑应用
</Button>
}
/>
<DistributeDialog apiKey={apiKey} />
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="text-xs text-destructive h-8 border-dashed border-destructive/50 hover:bg-destructive/5 w-full">
<Trash2 className="size-3 mr-1" />
删除应用
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除应用</AlertDialogTitle>
<AlertDialogDescription>
确定要删除应用 &ldquo;{apiKey.app_name}&rdquo; 吗?
此操作将永久删除该应用的所有凭证和配置,且无法恢复。
使用此应用凭证的所有集成将立即失效。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
onClick={() => onDelete(apiKey.id)}
className="bg-destructive hover:bg-destructive/90"
>
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<TestModeToggle
apiKey={apiKey}
onUpdate={onUpdate}
updateAPIKey={updateAPIKey}
/>
</div>
</div>
</div>
)
}
@@ -0,0 +1,141 @@
"use client"
import * as React from "react"
import { useState, useEffect } from "react"
import { motion } from "motion/react"
import { toast } from "sonner"
import { ErrorDisplay } from "@/components/layout/error"
import { EmptyState } from "@/components/layout/empty"
import { LoadingPage } from "@/components/layout/loading"
import { MerchantSelector } from "@/components/common/merchant/merchant-selector"
import { MerchantInfo } from "@/components/common/merchant/merchant-info"
import { MerchantData } from "@/components/common/merchant/merchant-data"
import { MerchantDialog } from "@/components/common/merchant/merchant-dialog"
import { type MerchantAPIKey } from "@/lib/services"
import { useMerchant } from "@/contexts/merchant-context"
/**
* 集市中心主页面组件
* 负责组装集市中心的各个子组件
*/
export function MerchantMain() {
const { apiKeys, loading, error, loadAPIKeys, createAPIKey, updateAPIKey, deleteAPIKey } = useMerchant()
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(null)
const selectedKey = apiKeys.find(key => key.id === selectedKeyId) || null
/* 加载 API Keys */
useEffect(() => {
loadAPIKeys()
}, [loadAPIKeys])
/* 选择默认 API Key */
useEffect(() => {
if (apiKeys.length > 0 && !selectedKeyId) {
setSelectedKeyId(apiKeys[0].id)
}
}, [apiKeys, selectedKeyId])
/* 创建成功回调 */
const handleCreateSuccess = (newKey: MerchantAPIKey) => {
setSelectedKeyId(newKey.id)
}
/* 删除成功回调 */
const handleDelete = async (id: string) => {
try {
await deleteAPIKey(id)
toast.success('删除成功')
if (selectedKeyId === id) {
const remainingKeys = apiKeys.filter(key => key && key.id !== id)
setSelectedKeyId(remainingKeys.length > 0 ? remainingKeys[0].id : null)
}
} catch (error) {
toast.error('删除失败', {
description: (error as Error).message || '无法删除应用'
})
}
}
/* 正在加载中 */
if (loading) {
return <LoadingPage text="集市中心" badgeText="集市" />
}
return (
<div className="py-6">
<div className="flex items-center justify-between border-b pb-2 mb-4">
<h1 className="text-2xl font-semibold">集市中心</h1>
<div className="flex items-center gap-3">
{apiKeys.length > 0 && (
<MerchantSelector
apiKeys={apiKeys}
selectedKeyId={selectedKeyId}
onSelect={setSelectedKeyId}
/>
)}
<MerchantDialog
mode="create"
onSuccess={handleCreateSuccess}
createAPIKey={createAPIKey}
/>
</div>
</div>
{error ? (
<motion.div
key="error"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="flex items-center justify-center min-h-[60vh]"
>
<ErrorDisplay
title="加载失败"
message={error}
onRetry={loadAPIKeys}
retryText="重试"
/>
</motion.div>
) : apiKeys.length === 0 ? (
<motion.div
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="flex items-center justify-center min-h-[60vh]"
>
<EmptyState
title="应用列表为空"
description="请创建您的第一个集市应用,开始您的积分服务"
/>
</motion.div>
) : (
<motion.div
key="content"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="space-y-6"
>
{selectedKey && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<MerchantData apiKey={selectedKey} />
</div>
<div className="lg:col-span-1">
<MerchantInfo
apiKey={selectedKey}
onDelete={handleDelete}
updateAPIKey={updateAPIKey}
/>
</div>
</div>
)}
</motion.div>
)}
</div>
)
}
@@ -0,0 +1,836 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { motion, AnimatePresence } from "motion/react"
import { Key, Smartphone, Tablet, Monitor, RotateCw, Plus, Trash2, Copy, ExternalLink, CreditCard, Store, Loader2, Eye, EyeOff, Expand, Minimize, Sun, Moon } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label"
import { Spinner } from "@/components/ui/spinner"
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter } from "@/components/ui/sheet"
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
import { useMerchant } from "@/contexts/merchant-context"
import { TransactionProvider, useTransaction } from "@/contexts/transaction-context"
import { useUser } from "@/contexts/user-context"
import { MerchantService, ConfigService, type PaymentLink, type GetMerchantOrderResponse, type MerchantAPIKey, type UserPayConfig } from "@/lib/services"
import { PayingInfo } from "@/components/common/pay/paying/paying-info"
import { PayingNow } from "@/components/common/pay/paying/paying-now"
import { MerchantSelector } from "@/components/common/merchant/merchant-selector"
import { LoadingPage } from "@/components/layout/loading"
import { EmptyState } from "@/components/layout/empty"
import { TransactionTableList } from "@/components/common/general/table-data"
/** 设备预览配置 */
const DEVICE_CONFIG = {
mobile: {
width: 375,
height: 812,
borderRadius: 50,
borderWidth: 14,
scale: 0.6
},
tablet: {
width: 768,
height: 1024,
borderRadius: 32,
borderWidth: 18,
scale: 0.45
},
desktop: {
width: 1200,
height: 750,
borderRadius: 16,
borderWidth: 8,
scale: 0.35
}
} as const
export function MerchantOnline() {
const router = useRouter()
const searchParams = useSearchParams()
const { apiKeys, loading: loadingKeys, loadAPIKeys } = useMerchant()
useEffect(() => {
loadAPIKeys()
}, [loadAPIKeys])
const apiKeyId = searchParams.get("apiKeyId")
const selectedKey = apiKeys.find(k => k.id.toString() === apiKeyId) || null
if (loadingKeys) {
return <LoadingPage text="在线流转" badgeText="积分" />
}
if (apiKeys.length === 0) {
return (
<div className="py-6">
<div className="flex items-center justify-between border-b pb-2 mb-4 shrink-0">
<h1 className="text-2xl font-semibold">在线流转</h1>
<Button variant="outline" size="sm" className="h-8 text-xs" onClick={() => router.push('/merchant')}>
<Store className="size-3 mr-1" /> 前往集市
</Button>
</div>
<div className="flex items-center justify-center min-h-[60vh]">
<EmptyState
title="暂未创建应用"
description="您还没有创建任何应用。请前往集市中心创建应用,即可开始使用在线积分流转服务。"
icon={Key}
/>
</div>
</div>
)
}
return (
<TransactionProvider
defaultParams={{
page_size: 20,
types: ['online'],
client_id: selectedKey?.client_id
}}
>
<MerchantOnlineContent apiKeys={apiKeys} />
</TransactionProvider>
)
}
interface MerchantOnlineContentProps {
apiKeys: MerchantAPIKey[]
}
function MerchantOnlineContent({ apiKeys }: MerchantOnlineContentProps) {
const router = useRouter()
const searchParams = useSearchParams()
const {
transactions,
loading: loadingTransactions,
error: transactionsError,
fetchTransactions,
refresh
} = useTransaction()
/* 派生 selectedKey */
const apiKeyId = searchParams.get("apiKeyId")
/* 处理集市应用切换 */
const handleMerchantSelect = useCallback((id: string) => {
const params = new URLSearchParams(searchParams.toString())
params.set("apiKeyId", id.toString())
router.push(`${ window.location.pathname }?${ params.toString() }`)
}, [searchParams, router])
/* 自动选择第一个集市应用 */
useEffect(() => {
if (apiKeys.length > 0 && !apiKeyId) {
handleMerchantSelect(apiKeys[0].id)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [apiKeys.length, apiKeyId])
const selectedKey = apiKeys.find(k => k.id.toString() === apiKeyId) || null
/* 集市状态 */
const [paymentLinks, setPaymentLinks] = useState<PaymentLink[]>([])
const [selectedLink, setSelectedLink] = useState<PaymentLink | null>(null)
const [previewLink, setPreviewLink] = useState<PaymentLink | null>(null)
const [isCreating, setIsCreating] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [loading, setLoading] = useState(false)
/* 创建/编辑表单状态 */
const [productName, setProductName] = useState("")
const [amount, setAmount] = useState("")
const [remark, setRemark] = useState("")
const [totalLimit, setTotalLimit] = useState("")
const [userLimit, setUserLimit] = useState("")
/* 设备预览状态 */
const [previewDevice, setPreviewDevice] = useState<'mobile' | 'tablet' | 'desktop'>('mobile')
const [showToken, setShowToken] = useState(false)
const [isFullscreen, setIsFullscreen] = useState(false)
const [previewTheme, setPreviewTheme] = useState<'light' | 'dark'>('light')
/* 获取应用列表 */
const fetchLinks = useCallback(async () => {
if (!selectedKey) {
setPaymentLinks([])
return
}
try {
setLoading(true)
const links = await MerchantService.listPaymentLinks(selectedKey.id)
setPaymentLinks(links)
/* 自动设置第一个应用为预览 */
if (links.length > 0) {
setPreviewLink(prev => prev || links[0])
}
} catch (error) {
console.error(error)
toast.error("获取应用列表失败")
} finally {
setLoading(false)
}
}, [selectedKey])
/* 当 selectedKey 变化时,重新获取应用列表和数据 */
useEffect(() => {
if (selectedKey?.client_id) {
fetchLinks()
fetchTransactions({
page: 1,
page_size: 20,
types: ['online'],
client_id: selectedKey.client_id
})
setIsCreating(false)
setSelectedLink(null)
} else {
setPaymentLinks([])
setIsCreating(false)
setSelectedLink(null)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedKey?.client_id])
/* 处理创建 */
const handleCreate = async () => {
if (!selectedKey) return
if (!productName || !amount) {
toast.error("请填写在线积分流转服务的名称和数量")
return
}
if (isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) {
toast.error("积分数量必须设置为大于0的数值")
return
}
// 验证限制字段
if (totalLimit && (isNaN(parseInt(totalLimit)) || parseInt(totalLimit) < 1)) {
toast.error("总数量限制必须是大于0的整数")
return
}
if (userLimit && (isNaN(parseInt(userLimit)) || parseInt(userLimit) < 1)) {
toast.error("单用户限制必须是大于0的整数")
return
}
try {
setLoading(true)
const newLink = await MerchantService.createPaymentLink(selectedKey.id, {
product_name: productName,
amount: parseFloat(amount),
remark,
...(totalLimit && { total_limit: parseInt(totalLimit) }),
...(userLimit && { user_limit: parseInt(userLimit) })
})
toast.success("在线积分流转服务创建成功")
/* 重置表单 */
setProductName("")
setAmount("")
setRemark("")
setTotalLimit("")
setUserLimit("")
/* 更新UI */
fetchLinks()
setIsCreating(false)
setSelectedLink(newLink)
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "在线积分流转服务创建失败"
toast.error(message)
} finally {
setLoading(false)
}
}
/* 处理删除 */
const handleDelete = async (link: PaymentLink) => {
if (!selectedKey) return
if (!confirm("确定要删除这个在线积分流转服务吗?该操作不可恢复。")) return
try {
setLoading(true)
await MerchantService.deletePaymentLink(selectedKey.id, link.id)
toast.success("在线积分流转服务已删除")
if (selectedLink?.id === link.id) {
setSelectedLink(null)
}
fetchLinks()
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "在线积分流转服务删除失败"
toast.error(message)
} finally {
setLoading(false)
}
}
/* 处理编辑 */
const handleEdit = (link: PaymentLink) => {
setProductName(link.product_name)
setAmount(link.amount)
setRemark(link.remark || "")
setTotalLimit(link.total_limit?.toString() || "")
setUserLimit(link.user_limit?.toString() || "")
setIsEditing(true)
}
/* 处理更新 */
const handleUpdate = async () => {
if (!selectedKey || !selectedLink) return
if (!productName || !amount) {
toast.error("请填写在线积分流转服务的名称和数量")
return
}
if (isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) {
toast.error("积分数量必须设置为大于0的数值")
return
}
// 验证限制字段
if (totalLimit && (isNaN(parseInt(totalLimit)) || parseInt(totalLimit) < 1)) {
toast.error("总数量限制必须是大于0的整数")
return
}
if (userLimit && (isNaN(parseInt(userLimit)) || parseInt(userLimit) < 1)) {
toast.error("单用户限制必须是大于0的整数")
return
}
try {
setLoading(true)
await MerchantService.updatePaymentLink(selectedKey.id, selectedLink.id, {
product_name: productName,
amount: parseFloat(amount),
remark,
...(totalLimit && { total_limit: parseInt(totalLimit) }),
...(userLimit && { user_limit: parseInt(userLimit) })
})
toast.success("在线积分流转服务已更新")
/* 更新UI */
fetchLinks()
setIsEditing(false)
setSelectedLink(null)
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "在线积分流转服务更新失败"
toast.error(message)
} finally {
setLoading(false)
}
}
/* 处理复制链接 */
const handleCopyLink = (token: string) => {
const url = `${ window.location.origin }/paying/online?token=${ token }`
navigator.clipboard.writeText(url)
toast.success("在线积分流转服务链接已复制")
}
/* 获取用户支付配置 */
const [userPayConfigs, setUserPayConfigs] = useState<UserPayConfig[]>([])
const { user } = useUser()
useEffect(() => {
const loadConfigs = async () => {
try {
const configs = await ConfigService.getUserPayConfigs()
setUserPayConfigs(configs)
} catch (error) {
console.error("Failed to load user pay configs:", error)
}
}
loadConfigs()
}, [])
/* 预览订单信息 */
const currentUserConfig = userPayConfigs.find(c => user?.pay_level !== undefined && c.level === user.pay_level)
const previewOrderInfo: GetMerchantOrderResponse = {
merchant: {
app_name: selectedKey?.app_name || "服务名称",
redirect_uri: "",
},
order: {
id: '0',
order_no: previewLink ? `LINK-${ previewLink.id }` : "PREVIEW",
order_name: isCreating ? (productName || "服务名称") : (previewLink?.product_name || "服务名称"),
payer_username: "user",
payee_username: selectedKey?.app_name || "应用",
amount: isCreating ? (amount || "0.00") : (previewLink?.amount || "0.00"),
status: "pending",
type: "payment",
payment_type: "api",
remark: isCreating ? remark : (previewLink?.remark || ""),
client_id: "",
return_url: "",
notify_url: "",
trade_time: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
user_pay_config: currentUserConfig ? {
...currentUserConfig,
fee_rate: String(currentUserConfig.fee_rate)
} : {
id: '0',
level: user?.pay_level ?? 0,
min_score: 0,
max_score: null,
daily_limit: null,
fee_rate: "0.00",
created_at: "",
updated_at: ""
}
}
return (
<div className="py-6">
<div className="flex items-center justify-between border-b pb-2 mb-4 shrink-0">
<h1 className="text-2xl font-semibold">在线流转</h1>
<div className="flex items-center gap-3">
{apiKeys.length > 0 && (
<MerchantSelector
apiKeys={apiKeys}
selectedKeyId={selectedKey?.id || null}
onSelect={handleMerchantSelect}
/>
)}
<Button variant="outline" size="sm" className="h-8 text-xs" onClick={() => router.push('/merchant')}>
<Store className="size-3 mr-1" /> 前往集市
</Button>
</div>
</div>
{!selectedKey ? (
<div className="flex items-center justify-center min-h-[60vh]">
<EmptyState
title={apiKeys.length === 0 ? "暂未创建应用" : "未选择应用"}
description={apiKeys.length === 0
? "您还没有创建任何应用。请前往集市中心创建一个应用,即可开始管理在线积分流转服务。"
: "请先在右上角选择一个应用以管理此应用的所有在线积分流转服务。"}
icon={Key}
/>
</div>
) : (
<div className="flex flex-col gap-6 h-full min-h-0">
<div className="space-y-4 shrink-0">
<h2 className="font-semibold -mt-2">所有服务</h2>
<ScrollArea className="w-full whitespace-nowrap">
<div className="flex gap-4 pb-3">
<button
onClick={() => {
setIsCreating(true)
setSelectedLink(null)
setProductName("")
setAmount("")
setRemark("")
setTotalLimit("")
setUserLimit("")
}}
className="rounded-lg p-4 border border-dashed hover:border-primary/50 shadow-none transition-all text-left group bg-background min-h-[100px] w-[180px] shrink-0 flex flex-col items-center justify-center gap-2 cursor-pointer"
>
<div className="rounded-lg p-2 bg-purple-50 dark:bg-purple-950/20">
<Plus className="size-4 text-purple-600" />
</div>
<div className="text-center">
<h3 className="font-medium text-sm group-hover:text-foreground">创建服务</h3>
<p className="text-xs text-muted-foreground">添加新的在线积分流转服务</p>
</div>
</button>
{loading && paymentLinks.length === 0 && (
<div className="flex items-center justify-center h-[100px] w-[180px] shrink-0">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
)}
{paymentLinks.map((link) => (
<button
key={link.id}
onClick={() => {
if (previewLink?.id === link.id) {
setSelectedLink(link)
setIsCreating(false)
} else {
setPreviewLink(link)
setIsCreating(false)
toast.info("再次点击打开详情", {
duration: 1500,
position: "bottom-center"
})
}
}}
className={`rounded-lg p-4 border border-dashed shadow-none transition-all text-left group bg-background min-h-[100px] w-[180px] shrink-0 flex flex-col justify-between ${ previewLink?.id === link.id
? "border-primary bg-primary/5"
: "hover:border-primary/50"
}`}
>
<div className="flex items-start justify-between gap-2 w-full">
<div className="flex-1 min-w-0">
<h3 className="font-medium text-sm truncate">{link.product_name}</h3>
<p className="text-xs text-muted-foreground truncate">{link.remark || "无备注"}</p>
</div>
<div className="rounded-lg p-1.5 bg-green-50 dark:bg-green-950/20 shrink-0">
<CreditCard className="size-3 text-green-600" />
</div>
</div>
<div className="flex items-baseline gap-1 mt-2">
<span className="text-xs text-muted-foreground">LDC</span>
<span className="text-lg font-mono font-semibold">{parseFloat(link.amount).toFixed(2)}</span>
</div>
</button>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-4">
<div className="flex items-center justify-between shrink-0">
<h2 className="font-semibold">活动数据</h2>
</div>
<div className="h-[600px] overflow-hidden">
<TransactionTableList
loading={loadingTransactions}
error={transactionsError}
transactions={transactions}
onRetry={refresh}
emptyDescription="暂无在线流转服务数据"
/>
</div>
</div>
<div className={`space-y-4 ${ isFullscreen ? 'fixed inset-0 z-50 bg-background/95 backdrop-blur-sm p-6 flex flex-col' : '' }`}>
<div className="flex items-center justify-between shrink-0">
<h2 className="font-semibold">{isFullscreen ? '全屏预览' : '实时预览'}</h2>
</div>
<div className={`border-none overflow-hidden relative h-[600px] ${ isFullscreen ? 'border-0 rounded-none h-full flex-1' : '' }`}>
<div className="absolute z-15 left-1/2 -translate-x-1/2 top-4">
<div className="h-8 bg-background border border-border rounded-full p-1.5 flex items-center gap-0.5 shadow-lg">
<Button variant={previewDevice === 'mobile' ? 'secondary' : 'ghost'} size="icon" className="size-6 rounded-full" onClick={() => setPreviewDevice('mobile')}>
<Smartphone className="size-3.5" />
</Button>
<Button variant={previewDevice === 'tablet' ? 'secondary' : 'ghost'} size="icon" className="size-6 rounded-full" onClick={() => setPreviewDevice('tablet')}>
<Tablet className="size-3.5" />
</Button>
<Button variant={previewDevice === 'desktop' ? 'secondary' : 'ghost'} size="icon" className="size-6 rounded-full" onClick={() => setPreviewDevice('desktop')}>
<Monitor className="size-3.5" />
</Button>
<div className="w-px h-5 bg-border mx-1" />
<Button variant="ghost" size="icon" className="size-6 rounded-full" onClick={() => setIsFullscreen(!isFullscreen)}>
{isFullscreen ? <Minimize className="size-3.5" /> : <Expand className="size-3.5" />}
</Button>
<Button variant="ghost" size="icon" className="size-6 rounded-full" onClick={() => { const current = previewDevice; setPreviewDevice('mobile'); setTimeout(() => setPreviewDevice(current), 50) }}>
<RotateCw className="size-3.5" />
</Button>
<div className="w-px h-5 bg-border mx-1" />
<Button variant="ghost" size="icon" className="size-6 rounded-full" onClick={() => setPreviewTheme(previewTheme === 'dark' ? 'light' : 'dark')}>
{previewTheme === 'dark' ? <Sun className="size-3.5" /> : <Moon className="size-3.5" />}
</Button>
</div>
</div>
<div className={`flex justify-center items-center p-6 relative overflow-hidden h-full ${ isFullscreen ? 'min-h-screen' : '' }`}>
<div className="absolute inset-0 pattern-grid-lg opacity-5" />
<motion.div
layout
initial={false}
animate={{
width: DEVICE_CONFIG[previewDevice].width,
height: DEVICE_CONFIG[previewDevice].height,
borderRadius: DEVICE_CONFIG[previewDevice].borderRadius,
borderWidth: DEVICE_CONFIG[previewDevice].borderWidth,
scale: isFullscreen
? (previewDevice === 'mobile' ? 0.9 : previewDevice === 'tablet' ? 0.75 : 0.65)
: DEVICE_CONFIG[previewDevice].scale,
}}
transition={{ type: "spring", stiffness: 200, damping: 25 }}
className="shrink-0 overflow-hidden shadow-2xl bg-black relative border-[#1f1f1f] origin-center rounded-[50px]"
>
<AnimatePresence>
{previewDevice === 'mobile' && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute top-0 left-0 w-full h-full pointer-events-none z-10" key="mobile-frame">
<div className="absolute top-[11px] left-1/2 -translate-x-1/2 w-[120px] h-[35px] bg-black rounded-full flex items-center justify-center gap-3 shadow-sm border border-white/5">
<div className="w-2 h-2 rounded-full bg-[#1a1a1a]" />
<div className="w-1.5 h-1.5 rounded-full bg-[#0f0f0f]" />
</div>
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 w-32 h-1.5 bg-white/20 rounded-full" />
</motion.div>
)}
{previewDevice === 'tablet' && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute top-0 left-0 w-full h-full pointer-events-none z-50" key="tablet-frame">
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-2 h-2 rounded-full bg-gray-600 border border-gray-500" />
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 w-32 h-1.5 bg-white/20 rounded-full" />
</motion.div>
)}
{previewDevice === 'desktop' && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute top-0 left-0 w-full h-full pointer-events-none z-50" key="desktop-frame">
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-48 h-8 bg-[#1f1f1f] rounded-b-xl flex items-center justify-center border-b border-x border-black/50">
<div className="w-1.5 h-1.5 rounded-full bg-black/50" />
</div>
</motion.div>
)}
</AnimatePresence>
<div className={`h-full w-full overflow-y-auto custom-scrollbar flex items-center justify-center p-4 transition-colors duration-500 ${ previewTheme === 'dark' ? 'dark bg-zinc-900' : 'bg-white' }`}>
<div className={`flex ${ previewDevice === 'mobile' ? 'flex-col' : 'flex-row' } w-full max-w-4xl backdrop-blur-2xl border rounded-3xl overflow-hidden shadow-2xl relative shrink-0 transition-colors duration-500 ${ previewTheme === 'dark' ? 'bg-card/70 border-border/50' : 'bg-white border-gray-200' }`}>
<PayingInfo orderInfo={previewOrderInfo} loading={loading} forceMobile={previewDevice === 'mobile'} />
<PayingNow orderInfo={previewOrderInfo} paying={false} payKey="" currentStep="method" selectedMethod="alipay" isOpen={false} loading={false} onPayKeyChange={() => { }} onCurrentStepChange={() => { }} onSelectedMethodChange={() => { }} onIsOpenChange={() => { }} onPayOrder={() => { }} forceMobile={previewDevice === 'mobile'} />
<div className="absolute inset-0 z-50 bg-transparent cursor-default" />
</div>
</div>
<div className="absolute inset-0 z-40 bg-transparent cursor-default" />
</motion.div>
</div>
</div>
</div>
</div>
<Sheet open={isCreating || !!selectedLink} onOpenChange={(open) => { if (!open) { setIsCreating(false); setSelectedLink(null); } }}>
<SheetContent className="w-full sm:max-w-md overflow-y-auto custom-scrollbar px-4">
{isCreating ? (
<div className="space-y-6">
<SheetHeader className="px-0">
<SheetTitle>创建新服务</SheetTitle>
<SheetDescription className="text-xs">创建一个在线积分流转服务。</SheetDescription>
</SheetHeader>
<div className="border border-dashed rounded-lg p-4 space-y-4">
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">服务名称 <span className="text-red-500">*</span></Label>
<Input
placeholder="例如:高级会员订阅服务"
value={productName}
onChange={e => setProductName(e.target.value)}
maxLength={50}
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">积分 (LDC) <span className="text-red-500">*</span></Label>
<Input
type="number"
placeholder="0.00"
className="font-mono"
value={amount}
onChange={e => setAmount(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">总次数限制 (可选)</Label>
<Input
type="number"
placeholder="不限制"
min="1"
value={totalLimit}
onChange={e => setTotalLimit(e.target.value)}
/>
<p className="text-[10px] text-muted-foreground">设置该链接总共可以支付的次数</p>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">单用户限制 (可选)</Label>
<Input
type="number"
placeholder="不限制"
min="1"
value={userLimit}
onChange={e => setUserLimit(e.target.value)}
/>
<p className="text-[10px] text-muted-foreground">设置每个用户最多可以支付的次数</p>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">备注 (可选)</Label>
<Textarea
placeholder="服务备注信息..."
className="resize-none h-20"
value={remark}
onChange={e => setRemark(e.target.value)}
maxLength={200}
/>
</div>
</div>
<SheetFooter className="flex flex-row justify-end items-center gap-2">
<Button variant="ghost" onClick={() => setIsCreating(false)} className="h-8 text-xs">取消</Button>
<Button onClick={handleCreate} disabled={loading} className="h-8 text-xs">
{loading && <Spinner className="mr-1 size-3" />} 创建
</Button>
</SheetFooter>
</div>
) : selectedLink ? (
<div className="space-y-6">
<SheetHeader className="px-0">
<SheetTitle>{selectedLink.product_name}</SheetTitle>
<SheetDescription className="text-xs">
{isEditing ? "编辑在线积分流转服务信息" : "在线积分流转服务内容详情"}
</SheetDescription>
</SheetHeader>
{isEditing ? (
/* 编辑模式 */
<div className="space-y-6">
<div className="border border-dashed rounded-lg p-4 space-y-4">
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">服务名称 <span className="text-red-500">*</span></Label>
<Input
placeholder="例如:高级会员订阅服务"
value={productName}
onChange={e => setProductName(e.target.value)}
maxLength={50}
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">积分 (LDC) <span className="text-red-500">*</span></Label>
<Input
type="number"
placeholder="0.00"
className="font-mono"
value={amount}
onChange={e => setAmount(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">总次数限制 (可选)</Label>
<Input
type="number"
placeholder="不限制"
min="1"
value={totalLimit}
onChange={e => setTotalLimit(e.target.value)}
/>
<p className="text-[10px] text-muted-foreground">设置该链接总共可以支付的次数</p>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">单用户限制 (可选)</Label>
<Input
type="number"
placeholder="不限制"
min="1"
value={userLimit}
onChange={e => setUserLimit(e.target.value)}
/>
<p className="text-[10px] text-muted-foreground">设置每个用户最多可以支付的次数</p>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">备注 (可选)</Label>
<Textarea
placeholder="服务备注信息..."
className="resize-none h-20"
value={remark}
onChange={e => setRemark(e.target.value)}
maxLength={200}
/>
</div>
</div>
<SheetFooter className="flex flex-row justify-end items-center gap-2">
<Button variant="ghost" onClick={() => setIsEditing(false)} className="h-8 text-xs">取消</Button>
<Button onClick={handleUpdate} disabled={loading} className="h-8 text-xs">
{loading && <Spinner className="mr-1 size-3" />} 保存
</Button>
</SheetFooter>
</div>
) : (
/* 查看模式 */
<>
<div>
<h2 className="text-sm font-semibold mb-4">服务信息</h2>
<div className="border border-dashed rounded-lg">
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">服务名称</label>
<p className="text-xs font-medium truncate text-right max-w-[70%]">{selectedLink.product_name}</p>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">积分数量</label>
<p className="text-xs font-bold text-primary">LDC {parseFloat(selectedLink.amount).toFixed(2)}</p>
</div>
{(selectedLink.total_limit || selectedLink.user_limit) && (
<>
{selectedLink.total_limit && (
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">总次数限制</label>
<p className="text-xs font-medium">{selectedLink.total_limit}</p>
</div>
)}
{selectedLink.user_limit && (
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">单用户限制</label>
<p className="text-xs font-medium">{selectedLink.user_limit}</p>
</div>
)}
</>
)}
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">服务备注</label>
<p className="text-xs text-muted-foreground truncate text-right max-w-[70%]">{selectedLink.remark || "无备注"}</p>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">创建时间</label>
<p className="text-xs text-muted-foreground">{new Date(selectedLink.created_at).toLocaleString()}</p>
</div>
<div className="px-3 py-2 border-b border-dashed last:border-b-0">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground shrink-0">服务令牌</label>
<div className="flex items-center p-1 h-7 border border-dashed rounded-sm bg-background max-w-[200px]">
<code className="text-xs text-muted-foreground font-mono flex-1 overflow-x-auto px-1 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none] whitespace-nowrap">
{showToken ? selectedLink.token : '•'.repeat(36)}
</code>
<Button
variant="ghost"
className="p-0.5 w-5 h-5 shrink-0"
onClick={() => setShowToken(!showToken)}
>
{showToken ? <EyeOff className="size-2.5 text-muted-foreground" /> : <Eye className="size-2.5 text-muted-foreground" />}
</Button>
<Button
variant="ghost"
onClick={() => handleCopyLink(selectedLink.token)}
className="p-0.5 w-5 h-5 shrink-0"
>
<Copy className="size-2.5" />
</Button>
</div>
</div>
</div>
</div>
</div>
<div>
<h2 className="text-sm font-semibold mb-4">流转服务管理</h2>
<div className="grid grid-cols-2 gap-2">
<Button variant="outline" className="text-xs h-8 border-dashed border-green-500 hover:bg-green-50 shadow-none" onClick={() => handleCopyLink(selectedLink.token)}>
<Copy className="size-3 mr-1" />
复制链接
</Button>
<Button variant="outline" className="text-xs h-8 border-dashed border-green-500 hover:bg-green-50 shadow-none" onClick={() => window.open(`/paying/online?token=${ selectedLink.token }`, '_blank')}>
<ExternalLink className="size-3 mr-1" />
查看服务
</Button>
<Button variant="outline" className="text-xs h-8 border-dashed border-blue-500 hover:bg-blue-50 shadow-none" onClick={() => handleEdit(selectedLink)}>
<svg className="size-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
编辑服务
</Button>
<Button variant="outline" className="text-xs text-destructive h-8 border-dashed border-destructive/50 hover:bg-destructive/5 shadow-none" onClick={() => handleDelete(selectedLink)}>
<Trash2 className="size-3 mr-1" />
删除服务
</Button>
</div>
</div>
</>
)}
</div>
) : null}
</SheetContent>
</Sheet>
</div>
)
}
</div >
)
}
@@ -0,0 +1,55 @@
import * as React from "react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import type { MerchantAPIKey } from "@/lib/services"
interface MerchantSelectorProps {
/** API Keys 列表 */
apiKeys: MerchantAPIKey[]
/** 选中的 API Key */
selectedKeyId: string | null
/** 选择回调 */
onSelect: (id: string) => void
/** 是否正在加载 */
loading?: boolean
}
/**
* 应用选择器组件
* 用于在多个应用之间切换
*/
export function MerchantSelector({
apiKeys,
selectedKeyId,
onSelect,
loading = false,
}: MerchantSelectorProps) {
return (
<Select
value={selectedKeyId || ''}
onValueChange={(value) => onSelect(value)}
disabled={loading || apiKeys.length === 0}
>
<SelectTrigger className="w-fit h-8 text-xs" size="sm">
<SelectValue placeholder="请选择应用" />
</SelectTrigger>
<SelectContent>
{apiKeys.map((apiKey) => (
<SelectItem
key={apiKey.id}
value={apiKey.id}
title={apiKey.app_description ? `${ apiKey.app_name } - ${ apiKey.app_description }` : apiKey.app_name}
>
<div className="flex items-center gap-2 text-xs">
<span className="font-medium">{apiKey.app_name}</span>
{apiKey.app_description && (
<span className="text-xs text-muted-foreground truncate">
- {apiKey.app_description}
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)
}
@@ -0,0 +1,176 @@
"use client"
import * as React from "react"
import { useState } from "react"
import { toast } from "sonner"
import { Power, PowerOff } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import type { MerchantAPIKey, UpdateAPIKeyRequest } from "@/lib/services"
interface TestModeToggleProps {
/** API Key */
apiKey: MerchantAPIKey
/** 更新回调 */
onUpdate?: (updatedKey: MerchantAPIKey) => void
/** 更新 API Key */
updateAPIKey?: (id: string, data: UpdateAPIKeyRequest) => Promise<void>
}
/**
* 测试模式切换组件
* 提供测试模式开关按钮,带有详细的说明提示
*/
export function TestModeToggle({ apiKey, onUpdate, updateAPIKey }: TestModeToggleProps) {
const [open, setOpen] = useState(false)
const [processing, setProcessing] = useState(false)
const [pendingValue, setPendingValue] = useState<boolean>(false)
const handleToggle = async () => {
try {
setProcessing(true)
const updateData = {
app_name: apiKey.app_name,
app_homepage_url: apiKey.app_homepage_url,
app_description: apiKey.app_description,
redirect_uri: apiKey.redirect_uri,
notify_url: apiKey.notify_url,
test_mode: pendingValue
}
if (updateAPIKey) {
await updateAPIKey(apiKey.id, updateData)
} else {
const services = await import("@/lib/services")
await services.default.merchant.updateAPIKey(apiKey.id, updateData)
}
toast.success(pendingValue ? '测试模式已开启' : '测试模式已关闭', {
description: pendingValue
? '现在订单不会真实扣费,仅用于测试调试'
: '现在订单将正常扣费,可以接收真实付款'
})
const updatedKey = { ...apiKey, test_mode: pendingValue }
onUpdate?.(updatedKey)
setOpen(false)
} catch (error) {
const errorMessage = (error as Error).message || '更新测试模式失败'
toast.error('更新失败', { description: errorMessage })
} finally {
setProcessing(false)
}
}
const handleClick = (e: React.MouseEvent) => {
e.preventDefault()
setPendingValue(!apiKey.test_mode)
setOpen(true)
}
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<Button
variant="outline"
onClick={handleClick}
className={`h-8 text-xs border-dashed w-full ${ apiKey.test_mode
? "text-orange-600 border-orange-500/50 hover:bg-orange-500/10"
: "text-green-600 border-green-500/50 hover:bg-green-500/10"
}`}
>
{apiKey.test_mode ? (
<Power className="size-3 mr-1" />
) : (
<PowerOff className="size-3 mr-1" />
)}
{apiKey.test_mode ? "关闭测试" : "开启测试"}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
{pendingValue ? (
<>
<Power className="size-5 text-orange-600" />
开启测试模式
</>
) : (
<>
<PowerOff className="size-5 text-green-600" />
关闭测试模式
</>
)}
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3 text-sm">
{pendingValue ? (
<>
<p className="font-medium">测试模式开启后:</p>
<ul className="space-y-2 ml-4 list-disc text-muted-foreground">
<li><strong>订单不会真实扣费</strong>:用户余额不会扣减</li>
<li><strong>商户不会收款</strong>:你的余额不会增加</li>
<li><strong>仅限自己测试</strong>:只有你可以支付测试订单</li>
<li><strong>订单标记明显</strong>:测试订单会显示警告标识</li>
</ul>
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-3">
<p className="text-xs text-orange-800 dark:text-orange-300">
<strong>使用场景:</strong>在正式上线前测试支付流程、回调通知等功能
</p>
</div>
</>
) : (
<>
<p className="font-medium">测试模式关闭后:</p>
<ul className="space-y-2 ml-4 list-disc text-muted-foreground">
<li><strong>订单真实扣费</strong>:用户余额会正常扣减</li>
<li><strong>商户正常收款</strong>:你的余额会正常增加</li>
<li><strong>其他用户可支付</strong>:任何人都可以支付订单</li>
<li><strong>正式生产环境</strong>:可以接收真实的业务订单</li>
</ul>
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3">
<p className="text-xs text-green-800 dark:text-green-300">
<strong>注意:</strong>关闭后应用将进入生产模式,请确保已完成测试
</p>
</div>
</>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={processing}>取消</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault()
handleToggle()
}}
disabled={processing}
className={pendingValue ? "bg-orange-600 hover:bg-orange-700" : "bg-green-600 hover:bg-green-700"}
>
{processing ? (
<>
<Spinner className="size-4" />
{pendingValue ? '开启中' : '关闭中'}
</>
) : (
pendingValue ? '确认开启' : '确认关闭'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -0,0 +1,179 @@
"use client"
import * as React from "react"
import { Skeleton } from "@/components/ui/skeleton"
import { ShoppingBag } from "lucide-react"
import type { GetMerchantOrderResponse } from "@/lib/services"
/**
* 加载中骨架组件
* 显示活动信息加载中的骨架
*/
function LoadingSkeleton() {
return (
<div className="w-full">
<div className="mb-6 text-center md:text-left">
<Skeleton className="h-4 w-32 mb-2 bg-white/10 mx-auto md:mx-0" />
<Skeleton className="h-8 w-40 bg-white/10 mx-auto md:mx-0" />
</div>
<div className="space-y-4">
<div className="rounded-xl bg-white/5 p-4 space-y-4">
<div className="flex justify-between items-start pb-4">
<div className="flex flex-col items-start space-y-1.5">
<Skeleton className="h-4 w-24 bg-white/10" />
<Skeleton className="h-3 w-16 bg-white/10" />
</div>
<div className="flex flex-col items-end space-y-1.5">
<Skeleton className="h-4 w-20 bg-white/10" />
<Skeleton className="h-3 w-24 bg-white/10" />
</div>
</div>
<div className="flex justify-between items-center pt-2">
<Skeleton className="h-6 w-20 bg-white/10" />
<Skeleton className="h-6 w-24 bg-white/10" />
</div>
</div>
<div className="space-y-1.5 px-1">
<Skeleton className="h-3 w-full bg-white/10" />
<Skeleton className="h-3 w-3/4 bg-white/10" />
</div>
</div>
</div>
)
}
/**
* 活动信息组件
* 显示活动信息
*/
function OrderContent({ orderInfo, forceMobile = false }: { orderInfo: GetMerchantOrderResponse, forceMobile?: boolean }) {
const amount = parseFloat(orderInfo.order.amount).toFixed(2)
const feeRateValue = orderInfo.user_pay_config?.fee_rate
const feeRate = feeRateValue ? (parseFloat(feeRateValue) * 100).toFixed(2) : '0.00'
return (
<div className="w-full">
<div className={`mb-4 text-center ${ forceMobile ? '' : 'md:mb-6 md:text-left' }`}>
<div className={`inline-flex items-center justify-center gap-1.5 mb-1 text-white/50 text-[10px] font-medium uppercase tracking-wider ${ forceMobile ? '' : 'md:justify-start md:gap-2 md:mb-1.5 md:text-xs' }`}>
<span>服务方</span>
<span className="w-1 h-1 rounded-full bg-white/30" />
<span className="text-white hover:text-blue-400 transition-colors cursor-pointer font-bold">{orderInfo.merchant.app_name}</span>
</div>
<h1 className={`text-2xl font-bold text-white tracking-tight flex items-baseline justify-center gap-1 ${ forceMobile ? '' : 'md:text-4xl md:justify-start' }`}>
<span className={`text-lg text-white/50 font-normal ${ forceMobile ? '' : 'md:text-2xl' }`}>LDC</span>
{amount}
</h1>
</div>
<div className={`space-y-3 ${ forceMobile ? '' : 'md:space-y-4' }`}>
<div className={`rounded-2xl bg-white/5 p-3 backdrop-blur-sm transition-colors ${ forceMobile ? '' : 'md:p-4' }`}>
<div className={`flex justify-between items-start pb-3 mb-3 ${ forceMobile ? '' : 'md:pb-4 md:mb-4' }`}>
<div className={`flex flex-col items-start pr-3 ${ forceMobile ? '' : 'md:pr-4' }`}>
<p className={`text-xs font-medium text-white mb-0.5 line-clamp-2 ${ forceMobile ? '' : 'md:text-sm' }`}>{orderInfo.order.order_name}</p>
<p className="text-white/40 text-[10px]">数量: 1</p>
</div>
<div className="flex flex-col items-end text-right whitespace-nowrap">
<p className={`text-xs font-medium text-white mb-0.5 ${ forceMobile ? '' : 'md:text-sm' }`}>LDC {amount}</p>
<p className="text-white/40 text-[10px]">LDC {amount} / 个</p>
</div>
</div>
<button className="w-full text-xs text-left mb-3 text-white/40 hover:text-white transition-colors duration-200 flex items-center gap-2 group">
<span>添加优惠码</span>
<span className="px-1.5 py-0.5 rounded text-[10px] bg-white/5 text-white/30">暂未开放</span>
</button>
<div className="flex justify-between items-center pt-2">
<p className="text-base font-bold text-white">积分合计</p>
<p className="text-lg font-bold text-white tracking-tight">LDC {amount}</p>
</div>
</div>
<div className="px-2">
<p className="text-[10px] text-white/30 space-y-0.5 leading-relaxed">
* 包含 {feeRate}% 的服务手续费 (由服务方承担)。
<br />
服务手续费率根据服务方等级和平台活动动态调整,不会消耗您(消费者)的积分。
</p>
</div>
</div>
</div>
)
}
/**
* 活动信息组件
* 显示活动信息
*/
export function PayingInfo({ orderInfo, loading = false, forceMobile = false }: { orderInfo: GetMerchantOrderResponse, loading: boolean, forceMobile?: boolean }) {
return (
<div className={`flex flex-col w-full p-4 justify-center bg-gradient-to-br from-neutral-900/95 to-black dark:from-neutral-900 dark:to-black relative text-white rounded-t-3xl ${ forceMobile ? '' : 'md:w-[45%] md:p-8 md:rounded-l-3xl md:rounded-tr-none' }`}>
<div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none opacity-30">
<div className="absolute -top-[20%] -left-[10%] w-[70%] h-[70%] bg-blue-900/20 rounded-full blur-3xl" />
<div className="absolute top-[40%] -right-[10%] w-[60%] h-[60%] bg-purple-900/20 rounded-full blur-3xl" />
</div>
<div className={`w-full max-w-xs mx-auto relative z-10 flex flex-col justify-center ${ forceMobile ? '' : 'md:h-full md:min-h-[360px]' }`}>
<div className={`text-center mb-2 ${ forceMobile ? '' : 'md:hidden' }`}>
{loading ? (
<div className="flex flex-col items-center gap-2">
<Skeleton className="h-3 w-32 bg-white/10" />
<div className="flex items-baseline justify-center gap-1">
<Skeleton className="h-8 w-24 bg-white/10" />
</div>
</div>
) : (
<>
<div className="text-white/50 text-[10px] uppercase tracking-wider mb-1">服务方 {orderInfo.merchant.app_name}</div>
<div className="text-3xl font-bold text-white tracking-tight flex items-baseline justify-center gap-1">
<span className="text-lg text-white/50 font-normal">LDC</span>
{(parseFloat(orderInfo.order.amount)).toFixed(2)}
</div>
</>
)}
</div>
<div className={`mb-10 flex justify-center hidden ${ forceMobile ? '' : 'md:block md:justify-start' }`}>
<div className="flex items-center gap-2">
<div className="w-8 h-8 flex items-center justify-center shadow-sm backdrop-blur-sm">
{loading ? (
<Skeleton className="w-5 h-5 rounded-full bg-white/20" />
) : (
<ShoppingBag className="size-4 text-white/80" />
)}
</div>
{loading ? (
<Skeleton className="h-5 w-24 bg-white/10" />
) : (
<span className="text-xs font-bold text-white/60">积分流转信息</span>
)}
</div>
</div>
<div className={`hidden ${ forceMobile ? '' : 'md:block' }`}>
{loading ? <LoadingSkeleton /> : <OrderContent orderInfo={orderInfo} forceMobile={forceMobile} />}
</div>
{!loading && (
<div className={`text-center ${ forceMobile ? '' : 'md:hidden' }`}>
<div className="inline-flex items-center gap-1 text-[10px] text-white/40 bg-white/5 px-2 py-1 rounded-full">
<span>{orderInfo.order.order_name}</span>
</div>
</div>
)}
<div className={`hidden mt-auto pt-8 ${ forceMobile ? '' : 'md:block' }`}>
<div className="text-[10px] text-white/20 font-mono">
ORDER: {orderInfo?.order?.order_no || 'LOADING...'}
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,240 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { useSearchParams, useRouter } from "next/navigation"
import { toast } from "sonner"
import { motion } from "motion/react"
import { PayingNow } from "@/components/common/pay/paying/paying-now"
import { PayingInfo } from "@/components/common/pay/paying/paying-info"
import services from "@/lib/services"
import type { GetMerchantOrderResponse } from "@/lib/services"
/**
* 积分认证主页面组件
* 通过 order_no 查询订单信息并完成积分认证
*/
export function PayingMain() {
/** 获取URL参数中的认证编号 */
const searchParams = useSearchParams()
const router = useRouter()
const encryptedOrderNo = searchParams.get('order_no')
/** 组件状态管理 */
const [orderInfo, setOrderInfo] = useState<GetMerchantOrderResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const [paying, setPaying] = useState(false)
const [payKey, setPayKey] = useState("")
const [currentStep, setCurrentStep] = useState<'method' | 'pay'>('method')
const [selectedMethod, setSelectedMethod] = useState<string>('')
const [isOpen, setIsOpen] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const isMountedRef = useRef(true)
useEffect(() => {
isMountedRef.current = true
return () => {
isMountedRef.current = false
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}
}, [])
/** 统一的服务错误处理函数 */
const handleServiceError = (error: unknown, operation: string) => {
console.error(`${ operation }失败:`, error)
let errorMessage = `${ operation }失败`
if (error && typeof error === 'object' && 'message' in error) {
errorMessage = String(error.message)
}
const errorMap: Record<string, string> = {
'订单不存在': "此积分流转服务不存在或已过期",
'未登录': "请先登录后再进行认证",
'余额不足': "您的积分余额不足",
'安全密码': "安全密码错误",
'不能认证自己的订单': "不能认证自己创建的积分流转服务",
'每日限额': "已达到每日认证限额",
'已完成': "认证完成"
}
const userMessage = Object.entries(errorMap).find(([key]) =>
errorMessage.includes(key)
)?.[1] || errorMessage
toast.error(userMessage, { id: 'payment-error' })
}
/** 组件初始化时查询订单信息 */
useEffect(() => {
if (encryptedOrderNo) {
setError(false)
handleQueryOrder(encryptedOrderNo)
} else {
setLoading(false)
setError(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [encryptedOrderNo])
/** 查询订单信息 */
const handleQueryOrder = async (queryOrderNo?: string) => {
const targetOrderNo = queryOrderNo || encryptedOrderNo
if (!targetOrderNo) {
toast.error("缺少认证编号", { id: 'missing-order-no' })
return
}
setLoading(true)
try {
const data = await services.merchant.getMerchantOrder({ order_no: targetOrderNo })
setOrderInfo(data)
setError(false)
} catch (error: unknown) {
handleServiceError(error, "查询认证信息")
setOrderInfo(null)
setError(true)
} finally {
setLoading(false)
}
}
/** 执行积分认证操作 */
const handlePayOrder = async () => {
if (!orderInfo) return
if (!payKey.trim()) {
toast.error("请输入安全密码")
return
}
if (payKey.length !== 6) {
toast.error("安全密码长度为6位,请重新输入")
return
}
setPaying(true)
try {
const freshOrderInfo = await services.merchant.getMerchantOrder({
order_no: encryptedOrderNo!
})
if (freshOrderInfo.order.status !== 'pending') {
toast.error("此积分流转服务状态已变更,无法认证")
setOrderInfo(freshOrderInfo)
return
}
await services.merchant.payMerchantOrder({
order_no: encryptedOrderNo!,
pay_key: payKey
})
toast.success("积分流转服务认证成功!", { id: 'payment-success' })
/** 支付成功后立即更新订单状态为成功 */
if (orderInfo) {
setOrderInfo({
...orderInfo,
order: {
...orderInfo.order,
status: 'success'
}
})
}
/** 5秒后跳转到redirect_uri或刷新页面 */
timeoutRef.current = setTimeout(() => {
if (!isMountedRef.current) return
const redirectUri = orderInfo?.merchant?.redirect_uri
if (redirectUri && redirectUri.trim()) {
window.location.href = redirectUri
return
}
router.push('/home')
}, 5000)
} catch (error: unknown) {
handleServiceError(error, "认证")
if (
error &&
typeof error === 'object' &&
'message' in error &&
String(error.message).includes('已完成')
) {
timeoutRef.current = setTimeout(() => {
if (!isMountedRef.current) return
window.location.reload()
}, 500)
}
} finally {
setPaying(false)
}
}
if (error) {
return (
<div className="relative min-h-screen w-full flex flex-col items-center justify-center bg-background">
<div className="relative z-10 w-full h-full flex items-center justify-center p-4">
<motion.div
className="relative z-10 flex flex-col items-center justify-center min-h-screen text-center px-4"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
>
<div className="w-full rounded-3xl p-8 max-w-lg">
<div className="w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-6 bg-red-500/10">
<div className="size-8 text-red-500 font-bold text-4xl flex items-center justify-center">!</div>
</div>
<h1 className="text-2xl font-bold text-foreground mb-4">
出了点问题
</h1>
<p className="text-muted-foreground text-sm">
您访问的认证编号已经完成认证或不存在。请检查您的积分流转服务链接是否正确,对此如有疑问请联系社区技术支持。
</p>
</div>
</motion.div>
</div>
</div>
)
}
return (
<div className="relative min-h-screen w-full font-sans text-foreground overflow-hidden bg-background selection:bg-primary/30">
<div className="relative min-h-screen w-full flex flex-col items-center justify-center bg-background">
<div className="relative z-10 w-full h-full flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, y: 30, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="flex flex-col md:flex-row w-full max-w-4xl bg-card/70 backdrop-blur-2xl border border-border/50 rounded-3xl overflow-hidden shadow-2xl"
style={{ minHeight: 'auto' }}
>
<PayingInfo orderInfo={orderInfo!} loading={loading} />
<PayingNow
orderInfo={orderInfo}
paying={paying}
payKey={payKey}
currentStep={currentStep}
selectedMethod={selectedMethod}
isOpen={isOpen}
loading={loading}
onPayKeyChange={setPayKey}
onCurrentStepChange={setCurrentStep}
onSelectedMethodChange={setSelectedMethod}
onIsOpenChange={setIsOpen}
onPayOrder={handlePayOrder}
/>
</motion.div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,445 @@
import * as React from "react"
import { motion, AnimatePresence } from "motion/react"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Send } from "@/components/animate-ui/icons/send"
import { Check } from "@/components/animate-ui/icons/check"
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/animate-ui/primitives/radix/collapsible"
import { AlertCircle, CreditCard, ArrowLeft, ShieldCheck, Lock } from "lucide-react"
import type { GetMerchantOrderResponse } from "@/lib/services"
import { useUser } from "@/contexts/user-context"
type PaymentStep = typeof PAYMENT_STEPS[keyof typeof PAYMENT_STEPS]
const PAYMENT_METHODS = {
LINUX_DO_PAY: 'linux-do-pay'
} as const
const PAYMENT_STEPS = {
METHOD: 'method' as const,
Credit: 'pay' as const
} as const
const ORDER_STATUSES = {
SUCCESS: 'success',
PENDING: 'pending'
} as const
/**
* 加载中骨架组件
* 显示支付方式加载中的骨架
*/
function LoadingSkeleton() {
return (
<div className="space-y-6 w-full max-w-xs mx-auto">
<div className="space-y-3">
<Skeleton className="h-6 w-32 bg-muted/50" />
<Skeleton className="h-4 w-48 bg-muted/50" />
</div>
<div className="space-y-3">
<div className="rounded-xl border border-border/50 p-3 bg-card/50">
<div className="flex items-center space-x-3">
<Skeleton className="w-9 h-9 rounded-lg bg-muted/50" />
<Skeleton className="h-3.5 w-24 bg-muted/50" />
</div>
</div>
<div className="rounded-xl border border-border/50 p-3 bg-card/50">
<div className="flex items-center space-x-3">
<Skeleton className="w-9 h-9 rounded-lg bg-muted/50" />
<Skeleton className="h-3.5 w-20 bg-muted/50" />
</div>
</div>
</div>
<Skeleton className="h-10 w-full rounded-full bg-muted/50" />
</div>
)
}
/**
* 成功状态组件
* 显示认证成功状态
*/
function SuccessState() {
const [countdown, setCountdown] = React.useState(5)
React.useEffect(() => {
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(timer)
return 0
}
return prev - 1
})
}, 1000)
return () => clearInterval(timer)
}, [])
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
duration: 0.6,
ease: "easeOut",
type: "spring",
stiffness: 100
}}
className="text-center py-10"
>
<div className="relative inline-block mb-6">
<div className="absolute inset-0 bg-green-500/20 blur-xl rounded-full" />
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{
delay: 0.2,
duration: 0.5,
type: "spring",
stiffness: 200
}}
className="relative w-10 h-10 bg-green-500 rounded-full flex items-center justify-center "
>
<Check
className="size-6 text-white"
animate={true}
loop={false}
/>
</motion.div>
</div>
<motion.h3
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4, duration: 0.4 }}
className="text-xl font-bold text-foreground mb-2"
>
认证成功
</motion.h3>
<motion.p
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6, duration: 0.4 }}
className="text-muted-foreground text-sm"
>
认证已安全完成,{countdown > 0 ? `${ countdown }秒后自动跳转...` : '正在跳转...'}
</motion.p>
</motion.div>
)
}
/**
* 认证方式选择步骤组件
* 显示认证方式选择步骤
*/
function MethodSelectionStep({
orderInfo,
selectedMethod,
isOpen,
onSelectedMethodChange,
onIsOpenChange,
onCurrentStepChange,
forceMobile = false
}: {
orderInfo: GetMerchantOrderResponse
selectedMethod: string
isOpen: boolean
onSelectedMethodChange: (method: string) => void
onIsOpenChange: (isOpen: boolean) => void
onCurrentStepChange: (step: PaymentStep) => void
forceMobile?: boolean
}) {
const { user } = useUser()
return (
<motion.div
key="method-step"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
className="space-y-6 w-full max-w-xs mx-auto"
>
<div className={`space-y-2 text-center ${ forceMobile ? '' : 'md:text-left' }`}>
<h2 className="text-xl font-bold text-foreground tracking-tight">选择认证平台</h2>
<p className="text-xs text-muted-foreground">请选择您想要使用的服务认证平台</p>
</div>
<div className="space-y-3">
<Collapsible open={isOpen} onOpenChange={onIsOpenChange}>
<CollapsibleTrigger asChild>
<div
className={`group cursor-pointer rounded-xl p-2.5 transition-all duration-300 border backdrop-blur-sm ${ selectedMethod === PAYMENT_METHODS.LINUX_DO_PAY
? 'border-primary/50 bg-primary/5'
: 'border-border/50 bg-card hover:bg-muted/50 hover:border-border'
}`}
onClick={() => {
if (selectedMethod === PAYMENT_METHODS.LINUX_DO_PAY) {
onSelectedMethodChange('')
onIsOpenChange(false)
} else {
onSelectedMethodChange(PAYMENT_METHODS.LINUX_DO_PAY)
onIsOpenChange(true)
}
}}
>
<div className="flex items-center gap-2.5">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center transition-all duration-300 ${ selectedMethod === PAYMENT_METHODS.LINUX_DO_PAY
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground group-hover:bg-muted/80'
}`}>
<CreditCard className="size-4" />
</div>
<div className="flex-1 text-left">
<div className="font-bold text-xs text-foreground">LINUX DO Credit</div>
<div className="text-[9px] text-muted-foreground mt-0.5">即时安全认证</div>
</div>
<div className={`w-5 h-5 rounded-full border flex items-center justify-center transition-all duration-300 ${ selectedMethod === PAYMENT_METHODS.LINUX_DO_PAY
? 'border-primary bg-primary text-primary-foreground'
: 'border-border'
}`}>
{selectedMethod === PAYMENT_METHODS.LINUX_DO_PAY && <Check className="size-3" />}
</div>
</div>
</div>
</CollapsibleTrigger>
{selectedMethod === PAYMENT_METHODS.LINUX_DO_PAY && (
<CollapsibleContent>
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="pt-3 px-1"
>
<div className="flex items-center gap-2.5 text-xs text-muted-foreground mb-4 bg-muted/50 p-2.5 rounded-lg border">
<div className="p-1 rounded">
<Lock className="size-3 text-primary" />
</div>
<div className="flex flex-col">
<span className="text-[10px] opacity-70">消费方账户</span>
<span className="font-medium text-foreground">{orderInfo.order.payer_username || user?.username}</span>
</div>
</div>
<Button
onClick={() => onCurrentStepChange(PAYMENT_STEPS.Credit)}
className="w-full rounded-full font-bold text-sm shadow-md transition-all hover:scale-[1.02] active:scale-[0.98]"
>
确认并继续
</Button>
</motion.div>
</CollapsibleContent>
)}
</Collapsible>
</div>
</motion.div>
)
}
/**
* 认证步骤组件
* 显示认证步骤
*/
function PaymentStep({
orderInfo,
payKey,
paying,
onPayKeyChange,
onCurrentStepChange,
onPayOrder
}: {
orderInfo: GetMerchantOrderResponse
payKey: string
paying: boolean
onPayKeyChange: (value: string) => void
onCurrentStepChange: (step: PaymentStep) => void
onPayOrder: () => void
}) {
return (
<motion.div
key="pay-step"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ duration: 0.3 }}
className="space-y-6 w-full max-w-xs mx-auto"
>
<div className="space-y-1.5">
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8 text-muted-foreground hover:text-foreground mb-1"
onClick={() => onCurrentStepChange(PAYMENT_STEPS.METHOD)}
>
<ArrowLeft className="size-3.5 mr-1" /> 返回
</Button>
<h2 className="text-xl font-bold text-foreground tracking-tight">身份验证</h2>
<p className="text-xs text-muted-foreground">请输入您的6位安全密码</p>
</div>
<div className="flex justify-center py-4">
<InputOTP
maxLength={6}
value={payKey}
onChange={onPayKeyChange}
onKeyDown={(e) => {
if (e.key === 'Enter' && payKey.length === 6 && !paying) {
onPayOrder()
}
}}
>
<InputOTPGroup className="gap-2">
{[0, 1, 2, 3, 4, 5].map((index) => (
<InputOTPSlot
key={index}
index={index}
className="size-11 bg-background border-border text-foreground text-lg font-bold transition-all focus:border-primary focus:ring-4 focus:ring-primary/10 ring-offset-0"
/>
))}
</InputOTPGroup>
</InputOTP>
</div>
<Button
onClick={onPayOrder}
disabled={paying || payKey.length !== 6}
className="w-full rounded-full font-bold text-sm shadow-md transition-all hover:scale-[1.02] active:scale-[0.98]"
>
{paying ? (
<span className="flex items-center gap-2">
<Send className="size-4" animate={true} loop={true} loopDelay={2000} /> 处理中...
</span>
) : (
`消耗 LDC ${ (parseFloat(orderInfo.order.amount)).toFixed(2) }`
)}
</Button>
</motion.div>
)
}
/**
* 错误状态组件
* 显示错误状态
*/
function ErrorState({ status }: { status: string }) {
return (
<div className="text-center py-10">
<div className="w-14 h-14 bg-red-500/10 rounded-full flex items-center justify-center mx-auto mb-4 border border-red-500/20">
<AlertCircle className="size-6 text-red-500" />
</div>
<h3 className="text-lg font-bold text-foreground mb-1">认证出现问题</h3>
<p className="text-muted-foreground text-sm">状态:<span className="font-medium text-foreground">{status}</span></p>
</div>
)
}
/**
* 底部组件
* 显示底部
*/
function Footer({ loading }: { loading?: boolean }) {
return loading ? (
<div className="flex flex-col items-center justify-center space-y-2 mt-8 opacity-50">
<Skeleton className="h-3 w-32" />
</div>
) : (
<div className="flex items-center justify-center gap-2 text-[10px] uppercase font-bold text-muted-foreground/40 mt-10">
<ShieldCheck className="size-3 text-green-500" />
<span>由 LINUX DO Credit 提供安全认证</span>
</div>
)
}
interface PayingNowProps {
orderInfo: GetMerchantOrderResponse | null
paying: boolean
payKey: string
currentStep: PaymentStep
selectedMethod: string
isOpen: boolean
loading?: boolean
onPayKeyChange: (value: string) => void
onCurrentStepChange: (step: PaymentStep) => void
onSelectedMethodChange: (method: string) => void
onIsOpenChange: (isOpen: boolean) => void
onPayOrder: () => void
forceMobile?: boolean
}
/**
* 支付现在组件
* 显示支付现在组件
*/
export function PayingNow({
orderInfo,
paying,
payKey,
currentStep,
selectedMethod,
isOpen,
loading = false,
onPayKeyChange,
onCurrentStepChange,
onSelectedMethodChange,
onIsOpenChange,
onPayOrder,
forceMobile = false
}: PayingNowProps) {
const renderContent = () => {
if (loading) return <LoadingSkeleton />
if (!orderInfo) return null
switch (orderInfo.order.status) {
case ORDER_STATUSES.SUCCESS:
return <SuccessState />
case ORDER_STATUSES.PENDING:
return (
<AnimatePresence mode="wait">
{currentStep === PAYMENT_STEPS.METHOD ? (
<MethodSelectionStep
orderInfo={orderInfo}
selectedMethod={selectedMethod}
isOpen={isOpen}
onSelectedMethodChange={onSelectedMethodChange}
onIsOpenChange={onIsOpenChange}
onCurrentStepChange={onCurrentStepChange}
forceMobile={forceMobile}
/>
) : (
<PaymentStep
orderInfo={orderInfo}
payKey={payKey}
paying={paying}
onPayKeyChange={onPayKeyChange}
onCurrentStepChange={onCurrentStepChange}
onPayOrder={onPayOrder}
/>
)}
</AnimatePresence>
)
default:
return <ErrorState status={orderInfo.order.status} />
}
}
return (
<div className={`flex-1 flex flex-col items-center justify-center p-6 relative overflow-hidden bg-white dark:bg-neutral-900 transition-colors duration-500 ${ forceMobile ? '' : 'md:p-8' }`}>
<div className="w-full relative z-10 flex flex-col h-full justify-center">
<div className="flex-1 flex flex-col justify-center">
{renderContent()}
</div>
<Footer loading={loading} />
</div>
</div>
)
}
@@ -0,0 +1,282 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { useSearchParams } from "next/navigation"
import { toast } from "sonner"
import { motion } from "motion/react"
import { PayingNow } from "@/components/common/pay/paying/paying-now"
import { PayingInfo } from "@/components/common/pay/paying/paying-info"
import { useUser } from "@/contexts/user-context"
import services from "@/lib/services"
import { ConfigService } from "@/lib/services"
import type { PaymentLink, GetMerchantOrderResponse, UserPayConfig } from "@/lib/services"
/**
* 支付链接支付页面组件
* 通过 token 查询支付链接信息并完成支付
*/
export function PayingOnline() {
/* 获取URL参数中的Token */
const searchParams = useSearchParams()
const token = searchParams.get('token')
const { user } = useUser()
/* 组件状态管理 */
const [paymentLink, setPaymentLink] = useState<PaymentLink | null>(null)
const [orderInfo, setOrderInfo] = useState<GetMerchantOrderResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const [paying, setPaying] = useState(false)
const [payKey, setPayKey] = useState("")
const [currentStep, setCurrentStep] = useState<'method' | 'pay'>('method')
const [selectedMethod, setSelectedMethod] = useState<string>('')
const [isOpen, setIsOpen] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const isMountedRef = useRef(true)
useEffect(() => {
isMountedRef.current = true
return () => {
isMountedRef.current = false
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}
}, [])
/* 获取用户支付配置 */
const [userPayConfigs, setUserPayConfigs] = useState<UserPayConfig[]>([])
useEffect(() => {
const loadConfigs = async () => {
try {
const configs = await ConfigService.getUserPayConfigs()
setUserPayConfigs(configs)
} catch (error) {
console.error("Failed to load user pay configs:", error)
}
}
loadConfigs()
}, [])
/* 统一的服务错误处理函数 */
const handleServiceError = (error: unknown, operation: string) => {
console.error(`${ operation }失败:`, error)
let errorMessage = `${ operation }失败`
if (error && typeof error === 'object' && 'message' in error) {
errorMessage = String(error.message)
}
const errorMap: Record<string, string> = {
'不存在': "此积分流转服务不存在或已失效",
'未登录': "请先登录后再进行认证",
'余额不足': "账户积分不足",
'支付密码': "安全密码错误",
'不能支付': "不能支付自己创建的积分流转服务",
'每日限额': "已达到每日认证限额",
}
const userMessage = Object.entries(errorMap).find(([key]) =>
errorMessage.includes(key)
)?.[1] || errorMessage
toast.error(userMessage, { id: 'payment-error' })
}
/* 组件初始化时查询支付链接信息 */
useEffect(() => {
if (token) {
setError(false)
handleQueryPaymentLink(token)
} else {
setLoading(false)
setError(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, userPayConfigs.length, user?.pay_level])
/* 查询支付链接信息 */
const handleQueryPaymentLink = async (queryToken?: string) => {
const targetToken = queryToken || token
if (!targetToken) {
toast.error("缺少支付链接", { id: 'missing-token' })
return
}
setLoading(true)
setLoading(true)
try {
const data = await services.merchant.getPaymentLinkByToken(targetToken)
setPaymentLink(data)
/* 将支付链接信息转换为订单信息格式,以复用PayingInfo和PayingNow组件 */
const mockOrderInfo: GetMerchantOrderResponse = {
merchant: {
app_name: data.app_name || "商户",
redirect_uri: data.redirect_uri || "",
},
order: {
id: '0',
order_no: `LINK-${ data.id }`,
order_name: data.product_name,
payer_username: user?.username || "",
payee_username: "",
amount: data.amount,
status: "pending",
type: "payment",
payment_type: "link",
remark: data.remark || "",
client_id: "",
return_url: "",
notify_url: "",
trade_time: null,
created_at: data.created_at,
updated_at: data.updated_at,
},
user_pay_config: (() => {
const currentUserConfig = userPayConfigs.find(c => user?.pay_level !== undefined && c.level === user.pay_level)
return currentUserConfig ? {
...currentUserConfig,
fee_rate: String(currentUserConfig.fee_rate)
} : {
id: '0',
level: user?.pay_level ?? 0,
min_score: 0,
max_score: null,
daily_limit: null,
fee_rate: "0.00",
created_at: "",
updated_at: ""
}
})(),
}
setOrderInfo(mockOrderInfo)
setError(false)
} catch (error: unknown) {
handleServiceError(error, "查询积分流转服务")
setPaymentLink(null)
setOrderInfo(null)
setError(true)
} finally {
setLoading(false)
}
}
/* 执行支付操作 */
const handlePayOrder = async () => {
if (!paymentLink || !token) return
if (!payKey.trim()) {
toast.error("请输入安全密码")
return
}
if (payKey.length < 6 || payKey.length > 10) {
toast.error("安全密码长度必须为6位")
return
}
setPaying(true)
try {
await services.merchant.payByLink({
token: token,
pay_key: payKey,
remark: paymentLink.remark || undefined
})
toast.success("积分流转服务认证成功!", { id: 'payment-success' })
/* 支付成功后更新订单状态 */
if (orderInfo) {
setOrderInfo({
...orderInfo,
order: {
...orderInfo.order,
status: 'success'
}
})
}
/* 5秒后跳转到首页或回调地址 */
timeoutRef.current = setTimeout(() => {
if (!isMountedRef.current) return
const redirectUri = orderInfo?.merchant?.redirect_uri
if (redirectUri && redirectUri.trim()) {
window.location.href = redirectUri
return
}
window.location.href = '/home'
}, 5000)
} catch (error: unknown) {
handleServiceError(error, "认证")
} finally {
setPaying(false)
}
}
if (error) {
return (
<div className="relative min-h-screen w-full flex flex-col items-center justify-center bg-background">
<div className="relative z-10 w-full h-full flex items-center justify-center p-4">
<motion.div
className="relative z-10 flex flex-col items-center justify-center min-h-screen text-center px-4"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
>
<div className="w-full rounded-3xl p-8 max-w-lg">
<div className="w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-6 bg-red-500/10">
<div className="size-8 text-red-500 font-bold text-4xl flex items-center justify-center">!</div>
</div>
<h1 className="text-2xl font-bold text-foreground mb-4">
出了点问题
</h1>
<p className="text-muted-foreground text-sm">
您访问的积分流转服务不存在或已失效。请检查链接地址是否正确,对此如有疑问请联系社区技术支持。
</p>
</div>
</motion.div>
</div>
</div>
)
}
return (
<div className="relative min-h-screen w-full font-sans text-foreground overflow-hidden bg-background selection:bg-primary/30">
<div className="relative min-h-screen w-full flex flex-col items-center justify-center bg-background">
<div className="relative z-10 w-full h-full flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, y: 30, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="flex flex-col md:flex-row w-full max-w-4xl bg-card/70 backdrop-blur-2xl border border-border/50 rounded-3xl overflow-hidden shadow-2xl"
style={{ minHeight: 'auto' }}
>
<PayingInfo orderInfo={orderInfo!} loading={loading} />
<PayingNow
orderInfo={orderInfo}
paying={paying}
payKey={payKey}
currentStep={currentStep}
selectedMethod={selectedMethod}
isOpen={isOpen}
loading={loading}
onPayKeyChange={setPayKey}
onCurrentStepChange={setCurrentStep}
onSelectedMethodChange={setSelectedMethod}
onIsOpenChange={setIsOpen}
onPayOrder={handlePayOrder}
/>
</motion.div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,286 @@
"use client"
import * as React from "react"
import { useState, useCallback } from "react"
import Cropper from "react-easy-crop"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Slider } from "@/components/ui/slider"
import { Crop, RotateCw, X } from "lucide-react"
import type { Area, Point } from "react-easy-crop"
interface ImageCropperProps {
/** 是否打开对话框 */
isOpen: boolean
/** 关闭对话框回调 */
onOpenChange: (open: boolean) => void
/** 裁剪完成回调 */
onCropComplete: (croppedImage: string, coverType: 'cover' | 'heterotypic') => void
/** 封面类型 */
coverType: 'cover' | 'heterotypic'
}
/**
* 创建裁剪后的图片
*/
async function getCroppedImg(
imageSrc: string,
pixelCrop: Area,
rotation = 0
): Promise<string> {
const image = await createImage(imageSrc)
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('无法创建 canvas context')
}
const maxSize = Math.max(image.width, image.height)
const safeArea = 2 * ((maxSize / 2) * Math.sqrt(2))
canvas.width = safeArea
canvas.height = safeArea
ctx.translate(safeArea / 2, safeArea / 2)
ctx.rotate((rotation * Math.PI) / 180)
ctx.translate(-safeArea / 2, -safeArea / 2)
ctx.drawImage(
image,
safeArea / 2 - image.width * 0.5,
safeArea / 2 - image.height * 0.5
)
const data = ctx.getImageData(0, 0, safeArea, safeArea)
canvas.width = pixelCrop.width
canvas.height = pixelCrop.height
ctx.putImageData(
data,
Math.round(0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x),
Math.round(0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y)
)
return canvas.toDataURL('image/webp')
}
/**
* 创建图片对象
*/
function createImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image()
image.addEventListener('load', () => resolve(image))
image.addEventListener('error', (error) => reject(error))
image.setAttribute('crossOrigin', 'anonymous')
image.src = url
})
}
/**
* 图片裁剪组件
* 使用 react-easy-crop 实现专业的图片裁剪功能
*/
export function ImageCropper({ isOpen, onOpenChange, onCropComplete, coverType }: ImageCropperProps) {
const [image, setImage] = useState<string | null>(null)
const [crop, setCrop] = useState<Point>({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
const [rotation, setRotation] = useState(0)
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null)
const fileInputRef = React.useRef<HTMLInputElement>(null)
const resetCropState = () => {
setImage(null)
setCrop({ x: 0, y: 0 })
setZoom(1)
setRotation(0)
setCroppedAreaPixels(null)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
// 裁剪比例配置 - 2:3
const aspect = 2 / 3
const cropShape = 'rect'
const coverLabel = coverType === 'cover' ? '背景封面' : '装饰图片'
// 处理文件选择
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// 验证文件类型
if (!file.type.startsWith('image/')) {
toast.error('请选择图片文件')
return
}
// 验证文件大小 (最大 2MB)
if (file.size > 2 * 1024 * 1024) {
toast.error('图片大小不能超过 2MB')
return
}
const reader = new FileReader()
reader.onload = (e) => {
resetCropState()
setImage(e.target?.result as string)
}
reader.readAsDataURL(file)
}
// 裁剪完成回调
const onCropCompleteHandler = useCallback((_croppedArea: Area, croppedAreaPixels: Area) => {
setCroppedAreaPixels(croppedAreaPixels)
}, [])
// 执行裁剪
const handleCrop = async () => {
if (!image || !croppedAreaPixels) return
try {
const croppedImage = await getCroppedImg(image, croppedAreaPixels, rotation)
onCropComplete(croppedImage, coverType)
handleClose()
} catch (error) {
console.error('裁剪失败:', error)
toast.error('裁剪失败,请重试')
}
}
// 关闭对话框
const handleClose = () => {
resetCropState()
onOpenChange(false)
}
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className={`max-h-[90vh] overflow-y-auto w-[95vw] sm:w-full ${ image ? 'sm:max-w-2xl' : 'sm:max-w-md' }`}>
<DialogHeader>
<DialogTitle className="text-sm">
{image ? `裁剪${ coverLabel }` : `上传${ coverLabel }`}
</DialogTitle>
<DialogDescription className="text-xs">
{image ? '拖拽调整位置,滚轮缩放' : '推荐 2:3 比例,支持 JPG / PNG / WEBP,最大 2MB'}
</DialogDescription>
</DialogHeader>
{!image ? (
/* 上传区域 */
<div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
id="image-upload"
/>
<Label
htmlFor="image-upload"
className="cursor-pointer block rounded-lg border border-dashed border-border hover:border-foreground/25 hover:bg-muted/50 transition-colors p-10 sm:p-14 text-center"
>
<Crop className="size-6 text-muted-foreground/40 mx-auto mb-3" />
<p className="text-xs font-medium text-foreground">点击选择图片</p>
<p className="text-[10px] text-muted-foreground mt-1">
或拖拽图片到此处
</p>
</Label>
</div>
) : (
/* 裁剪区域 */
<div className="space-y-3">
<div className="relative w-full h-[300px] sm:h-[420px] bg-muted/50 rounded-lg overflow-hidden touch-none">
<Cropper
image={image}
crop={crop}
zoom={zoom}
rotation={rotation}
aspect={aspect}
cropShape={cropShape}
showGrid={true}
onCropChange={setCrop}
onCropComplete={onCropCompleteHandler}
onZoomChange={setZoom}
onRotationChange={setRotation}
/>
</div>
<div className="flex items-center gap-4 px-1">
<div className="flex items-center gap-2 flex-1 min-w-0">
<Label className="text-[10px] text-muted-foreground shrink-0">缩放</Label>
<Slider
value={[zoom]}
onValueChange={(value: number[]) => setZoom(value[0])}
min={1}
max={3}
step={0.1}
className="flex-1"
/>
<span className="text-[10px] text-muted-foreground tabular-nums w-7 text-right shrink-0">{Math.round(zoom * 100)}%</span>
</div>
<div className="w-px h-4 bg-border shrink-0" />
<div className="flex items-center gap-2 flex-1 min-w-0">
<Label className="text-[10px] text-muted-foreground shrink-0">旋转</Label>
<Slider
value={[rotation]}
onValueChange={(value: number[]) => setRotation(value[0])}
min={0}
max={360}
step={1}
className="flex-1"
/>
<span className="text-[10px] text-muted-foreground tabular-nums w-6 text-right shrink-0">{rotation}°</span>
</div>
<div className="w-px h-4 bg-border shrink-0" />
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setRotation((prev) => (prev + 90) % 360)}
className="size-7 text-muted-foreground"
title="旋转 90°"
>
<RotateCw className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={resetCropState}
className="size-7 text-muted-foreground"
title="重新选择"
>
<X className="size-3.5" />
</Button>
</div>
</div>
</div>
)}
<DialogFooter>
<Button variant="ghost" onClick={handleClose} className="text-xs h-8">
取消
</Button>
<Button
onClick={handleCrop}
disabled={!image}
className="bg-red-500 hover:bg-red-600 text-xs h-8"
>
确认裁剪
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,134 @@
"use client"
import * as React from "react"
import Image from "next/image"
import { motion } from "motion/react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
export interface RedEnvelopeCardProps {
status: "preview" | "ready" | "opening"
coverImage?: string | null
heterotypicImage?: string | null
greeting?: string
sender?: {
username?: string
avatar_url?: string
}
onOpen?: () => void
amount?: string
className?: string
}
export function RedEnvelopeCard({
status,
coverImage,
greeting,
sender,
onOpen,
className
}: RedEnvelopeCardProps) {
// 预览模式下不播放入场动画
const isPreview = status === "preview"
return (
<motion.div
layoutId={!isPreview ? "red-envelope-card" : undefined}
className={`relative w-full h-full rounded-xl shadow-2xl overflow-hidden z-10 bg-[#F25542] ${ className || "" }`}
initial={!isPreview ? { opacity: 1 } : undefined}
animate={!isPreview ? { opacity: 1 } : undefined}
exit={!isPreview ? { y: -40, opacity: 0, scale: 0.95, transition: { duration: 0.5, ease: "easeInOut" } } : undefined}
>
{/* 上半部分 */}
<div
className="absolute top-0 h-[78%] overflow-hidden z-10 w-full"
style={{
borderRadius: '0 0 100% 100% / 0 0 20% 20%',
backgroundColor: coverImage ? '#f35543' : '#E75240',
boxShadow: '0 2px 2px rgba(0,0,0,0.15)'
}}
>
{/* 自定义背景封面 */}
{coverImage ? (
<div className="absolute inset-0">
<Image
src={coverImage}
alt="红包封面"
fill
className="object-cover"
unoptimized
loading="eager"
/>
{/* 半透明遮罩以确保内容可读 */}
<div className="absolute inset-0 bg-black/30 dark:bg-black/50" />
</div>
) : null}
{/* 用户信息 */}
<div className="absolute top-28 left-0 right-0 flex flex-col items-center justify-center text-[#FAE5AE] z-20 px-6 space-y-4">
<motion.div
initial={!isPreview ? { scale: 0 } : undefined}
animate={!isPreview ? { scale: 1 } : undefined}
transition={{ delay: 0.15, type: "spring", stiffness: 300, damping: 20 }}
className="flex items-center gap-2 opacity-90"
>
<Avatar className="h-8 w-8 rounded-md border border-white/20 shadow-sm">
<AvatarImage src={sender?.avatar_url} alt={sender?.username} />
<AvatarFallback className="bg-[#FAE5AE] text-[#E75240] font-bold text-xs rounded-md">
{sender?.username?.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="text-base font-medium tracking-wide">{sender?.username || '你'} 的红包</span>
</motion.div>
<motion.p
initial={!isPreview ? { opacity: 0, y: 10 } : undefined}
animate={!isPreview ? { opacity: 1, y: 0 } : undefined}
transition={{ delay: 0.3 }}
className="text-xl font-medium tracking-wider text-center opacity-90"
>
{greeting || "新年快乐,恭喜发财"}
</motion.p>
</div>
</div>
{/* 开启按钮 */}
<div className="absolute top-[78%] left-1/2 -translate-x-1/2 -translate-y-1/2 z-20">
{status === "opening" && (
<motion.div
className="absolute inset-0 -m-8"
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: [0.9, 2.0], opacity: [0, 0.6, 0] }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<div
className="w-full h-full rounded-full"
style={{ background: "radial-gradient(circle, rgba(255,232,168,0.8) 0%, rgba(245,205,122,0.4) 35%, transparent 65%)" }}
/>
</motion.div>
)}
<motion.button
className="relative w-20 h-20 rounded-full bg-[#EFC77A] flex items-center justify-center shadow-lg cursor-pointer border-none focus:outline-none overflow-hidden group"
whileHover={status === "ready" || isPreview ? { scale: 1.05 } : {}}
whileTap={status === "ready" || isPreview ? { scale: 0.95 } : {}}
animate={status === "opening" ? {
scale: [1, 0.92, 0],
opacity: [1, 0]
} : {}}
transition={status === "opening" ? {
duration: 0.55,
ease: "easeInOut"
} : {
duration: 0.2
}}
onClick={status === "ready" ? onOpen : undefined}
disabled={status !== "ready" && !isPreview}
>
<span className="relative text-[#4D4753] font-bold text-3xl z-10" style={{ fontFamily: 'serif' }}>
開
</span>
</motion.button>
</div>
</motion.div>
)
}
@@ -0,0 +1,373 @@
"use client"
import * as React from "react"
import { useState, useEffect, useCallback } from "react"
import Image from "next/image"
import { motion, AnimatePresence } from "motion/react"
import { toast } from "sonner"
import { Gift } from "lucide-react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import services from "@/lib/services"
import { formatDateTime } from "@/lib/utils"
import type { RedEnvelopeDetailResponse, RedEnvelopeClaim } from "@/lib/services"
import { getFileUrl } from "@/lib/services/upload/upload.service"
import { RedEnvelopeCard } from "./red-envelope-card"
interface RedEnvelopeClaimProps {
id: string
}
type ClaimState = "loading" | "ready" | "opening" | "opened" | "claimed" | "error"
export function RedEnvelopeClaimPage({ id }: RedEnvelopeClaimProps) {
const [state, setState] = useState<ClaimState>("loading")
const [detail, setDetail] = useState<RedEnvelopeDetailResponse | null>(null)
const [claimedAmount, setClaimedAmount] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const truncateText = (value: string | undefined, maxChars: number) => {
if (!value) return ""
const chars = Array.from(value)
if (chars.length <= maxChars) return value
return `${ chars.slice(0, maxChars).join("") }…`
}
const bestClaimId = React.useMemo(() => {
const claims = detail?.claims
if (!claims || claims.length === 0) return null
let topId: string | null = null
let topAmount = -Infinity
for (const claim of claims) {
const amount = parseFloat(claim.amount)
if (Number.isNaN(amount)) continue
if (amount > topAmount) {
topAmount = amount
topId = claim.id
}
}
return topId
}, [detail?.claims])
const formatClaimedAt = (claimedAt?: string) => {
if (!claimedAt) return "-"
const date = new Date(claimedAt)
if (Number.isNaN(date.getTime())) return "-"
const getShanghaiDateKey = (d: Date) =>
new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit"
}).format(d)
const todayKey = getShanghaiDateKey(new Date())
const claimKey = getShanghaiDateKey(date)
if (claimKey === todayKey) {
return new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
hour: "2-digit",
minute: "2-digit",
hour12: false
}).format(date)
}
return formatDateTime(date)
}
const loadDetail = useCallback(async () => {
try {
const data = await services.redEnvelope.getDetail(id)
console.log('Red envelope data:', data.red_envelope)
setDetail(data)
if (data.user_claimed) {
setClaimedAmount(data.user_claimed.amount)
setState("claimed")
} else if (data.red_envelope.status !== "active") {
setState("opened")
} else {
setState("ready")
}
} catch (err) {
setError(err instanceof Error ? err.message : "加载失败")
setState("error")
}
}, [id])
// 安全验证图片URL (防止XSS)
const sanitizeImageUrl = (url: string | null | undefined): string | undefined => {
if (!url) return undefined
// 允许相对路径且以 /f/ 开头
if (!url.startsWith('/f/')) {
console.warn('Invalid image URL detected:', url)
return undefined
}
// 防止路径遍历
if (url.includes('..') || url.includes('//')) {
console.warn('Path traversal detected in URL:', url)
return undefined
}
return url
}
// 从后端获取封面图片URL并进行安全验证
const coverImage = sanitizeImageUrl(getFileUrl(detail?.red_envelope?.cover_upload_id))
const heterotypicImage = sanitizeImageUrl(getFileUrl(detail?.red_envelope?.heterotypic_upload_id))
useEffect(() => {
loadDetail()
}, [loadDetail])
const handleOpen = async () => {
setState("opening")
try {
const result = await services.redEnvelope.claim({ id })
setClaimedAmount(result.amount)
// Reload the full details to get updated claims list
const updatedDetail = await services.redEnvelope.getDetail(id)
setDetail(updatedDetail)
setTimeout(() => setState("claimed"), 1500)
} catch (err) {
toast.error(err instanceof Error ? err.message : "领取失败")
setState("ready")
}
}
if (state === "loading") {
return (
<div className="relative min-h-screen w-full flex flex-col items-center justify-center bg-background">
<div className="relative z-10 w-full h-full flex items-center justify-center p-4">
<motion.div animate={{ rotate: 360 }} transition={{ repeat: Infinity, duration: 1 }}>
<Gift className="h-12 w-12 text-red-500" />
</motion.div>
</div>
</div>
)
}
if (state === "error") {
return (
<div className="relative min-h-screen w-full flex flex-col items-center justify-center bg-background">
<div className="relative z-10 w-full h-full flex items-center justify-center p-4">
<motion.div
className="relative z-10 flex flex-col items-center justify-center min-h-screen text-center px-4"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
>
<div className="w-full rounded-3xl p-8 max-w-lg">
<div className="w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-6 bg-red-500/10">
<Gift className="h-8 w-8 text-red-500" />
</div>
<h1 className="text-2xl font-bold text-foreground mb-4">
红包加载失败
</h1>
<p className="text-muted-foreground text-sm">
{error}
</p>
</div>
</motion.div>
</div>
</div>
)
}
const envelope = detail?.red_envelope
const claimedCount = detail?.claims?.length ?? 0
const totalCount = envelope?.total_count ?? claimedCount
const totalAmount = parseFloat(envelope?.total_amount || "0").toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
return (
<div className="relative h-screen w-full flex flex-col items-center justify-center bg-background p-2 sm:p-4 overflow-hidden">
<div className="relative z-10 w-full h-full flex items-center justify-center">
{/* 装饰图片容器 */}
<div className="relative w-full max-w-[320px] aspect-[3/5] h-auto flex items-center justify-center">
{/* 异形装饰 */}
{heterotypicImage && (
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="absolute inset-0 z-0 pointer-events-none flex items-center justify-center scale-[1.2]"
aria-hidden="true"
>
<Image
src={heterotypicImage}
alt="红包装饰"
fill
className="object-cover object-center"
unoptimized
loading="eager"
/>
</motion.div>
)}
{/* 统一卡片容器 */}
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.3, ease: "easeOut", delay: 0.1 }}
className="relative w-full h-full mx-auto rounded-xl shadow-2xl overflow-hidden z-10"
>
<AnimatePresence mode="wait">
{(state === "ready" || state === "opening") && (
<RedEnvelopeCard
key="cover"
status={state}
coverImage={coverImage}
greeting={envelope?.greeting}
sender={{
username: envelope?.creator_username,
avatar_url: envelope?.creator_avatar_url
}}
onOpen={handleOpen}
/>
)}
{(state === "claimed" || state === "opened") && (
<motion.div
key="result"
initial={{ y: 30, opacity: 0, scale: 0.98 }}
animate={{ y: 0, opacity: 1, scale: 1 }}
transition={{ duration: 0.5, ease: [0.25, 0.46, 0.45, 0.94] }}
className="absolute inset-0 bg-background flex flex-col"
>
{/* 顶部区域 */}
<div className="relative shrink-0">
<div
className="h-26 w-full bg-[#F25542] relative z-0"
style={{
borderRadius: '0 0 100% 100% / 0 0 80% 80%'
}}
>
{coverImage && (
<div
className="absolute inset-0 overflow-hidden"
style={{ borderRadius: '0 0 100% 100% / 0 0 80% 80%' }}
>
<Image
src={coverImage}
alt="cover"
fill
className="object-cover opacity-50"
style={{ transform: "scale(1.1)", transformOrigin: "center" }}
unoptimized
/>
</div>
)}
</div>
</div>
{/* 个人信息 */}
<div className="flex flex-col items-center justify-center mt-4 shrink-0">
<div className="flex items-center gap-2 mb-2">
<Avatar className="h-6 w-6 rounded-md border border-white/20">
<AvatarImage className="rounded-md" src={envelope?.creator_avatar_url} alt={envelope?.creator_username} />
<AvatarFallback className="bg-[#E75240] text-white text-[10px] rounded-md">
{envelope?.creator_username?.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex items-center gap-1 min-w-0 flex-1">
<span className="text-sm font-medium text-foreground/80 truncate">
{truncateText(envelope?.creator_username, 12)}
</span>
<span className="text-sm font-medium text-foreground/80 whitespace-nowrap">发出的红包</span>
</div>
{envelope?.type === 'random' && (
<span className="bg-[#E1B876] text-white text-[10px] px-1 rounded">拼</span>
)}
</div>
<p className="text-muted-foreground text-xs">{envelope?.greeting || "新年快乐,恭喜发财"}</p>
</div>
{/* 金额区域 */}
<div className="bg-background pt-3 pb-4 px-4 text-center shrink-0">
{claimedAmount ? (
<div>
<div className="text-center">
<div className="relative inline-block text-[#CFB073] leading-none">
<span className="text-5xl font-bold">{parseFloat(claimedAmount).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>
<span className="absolute left-full ml-1 bottom-1 text-sm font-medium">LDC</span>
</div>
</div>
</div>
) : (
<div className="py-2">
<p className="text-2xl font-medium text-foreground/80">手慢了,红包派完了</p>
</div>
)}
</div>
{/* 列表区域 */}
<div className="flex-1 bg-background flex flex-col min-h-0">
<div className="px-4 py-1 bg-background text-xs text-muted-foreground flex items-center justify-between">
<div className="whitespace-nowrap">
共 {totalAmount} LDC
</div>
<div className="flex items-baseline gap-1">
<span className="font-semibold tabular-nums">{claimedCount}</span>
<span>/</span>
<span className="font-semibold tabular-nums">{totalCount}</span>
<span>个</span>
</div>
</div>
<div className="border-t border-border/30" />
<div className="flex-1 bg-background overflow-y-auto overscroll-contain">
<div className="divide-y divide-border/30">
{detail?.claims.map((claim: RedEnvelopeClaim) => (
<div
key={claim.id}
className="flex items-start justify-between px-4 py-2 gap-3"
>
<div className="flex gap-3 min-w-0 flex-1">
<Avatar className="h-9 w-9 rounded-md border border-border/10 mt-0.5">
<AvatarImage className="rounded-md" src={claim.avatar_url} alt={claim.username} />
<AvatarFallback className="text-xs bg-muted rounded-md">
{claim.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col items-start gap-0.5 min-w-0">
<span className="text-[13px] font-medium text-foreground/90 truncate max-w-[160px] sm:max-w-[200px]">
{truncateText(claim.username, 12)}
</span>
<span className="text-[11px] text-muted-foreground">
{formatClaimedAt(claim.claimed_at)}
</span>
</div>
</div>
<div className="flex flex-col items-end gap-0.5 shrink-0 min-w-[96px] text-right">
<span className="text-[13px] font-semibold text-foreground tabular-nums whitespace-nowrap">
{parseFloat(claim.amount).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} LDC
</span>
{/* 最佳手气 */}
{envelope?.type === 'random' && bestClaimId === claim.id && (
<div className="flex items-center gap-1 text-[#E1B876] text-[9px]">
<span>手气最佳</span>
</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,59 @@
"use client"
import { useEffect, useState } from "react"
import { AlertTriangle } from "lucide-react"
import { RiskInfo, RiskInfoBox } from "@/components/common/risk/risk-info-box"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
const RISK_BLOCKED_EVENT = "credit-risk-blocked"
function isRiskInfo(value: unknown): value is RiskInfo {
if (!value || typeof value !== "object") return false
const riskInfo = value as Partial<RiskInfo>
return typeof riskInfo.risk_level === "string" && Array.isArray(riskInfo.risk_labels) && Array.isArray(riskInfo.risks)
}
export function RiskBlockDialog() {
const [riskInfo, setRiskInfo] = useState<RiskInfo | null>(null)
useEffect(() => {
const handleRiskBlocked = (event: Event) => {
const detail = (event as CustomEvent<unknown>).detail
if (!isRiskInfo(detail)) return
setRiskInfo(detail)
}
window.addEventListener(RISK_BLOCKED_EVENT, handleRiskBlocked)
return () => window.removeEventListener(RISK_BLOCKED_EVENT, handleRiskBlocked)
}, [])
return (
<Dialog open={!!riskInfo}>
<DialogContent
showCloseButton={false}
onEscapeKeyDown={(event) => event.preventDefault()}
onPointerDownOutside={(event) => event.preventDefault()}
onInteractOutside={(event) => event.preventDefault()}
className="sm:max-w-md"
>
<DialogHeader className="items-center text-center sm:text-center">
<div className="mb-1 flex size-12 items-center justify-center rounded-full bg-destructive/10">
<AlertTriangle className="size-6 text-destructive" />
</div>
<DialogTitle className="text-center text-lg">账号存在风险</DialogTitle>
<DialogDescription className="text-center text-sm leading-6">
因触发风控,账号暂时无法使用需登录的功能
</DialogDescription>
</DialogHeader>
<RiskInfoBox riskInfo={riskInfo} />
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,56 @@
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
export interface RiskItem {
label: string
value?: string
desc?: string
}
export interface RiskInfo {
risk_level: string
risk_labels: string[]
risks: RiskItem[]
}
export function RiskInfoBox({
riskInfo,
label = "风险等级",
labelClassName,
}: {
riskInfo: RiskInfo | null
label?: string
labelClassName?: string
}) {
return (
<div className="rounded-lg border bg-card p-4 shadow-sm">
<div className="flex flex-wrap items-center gap-2">
<span className={cn("text-sm font-medium text-muted-foreground", labelClassName)}>{label}</span>
<Badge variant="destructive">{riskInfo?.risk_level || "未知"}</Badge>
</div>
<div className="mt-4 space-y-2">
{riskInfo?.risks.length ? (
riskInfo.risks.map(risk => (
<div key={risk.value || risk.label} className="rounded-md border bg-muted/35 px-3 py-2.5">
<span className="text-sm font-medium leading-5 text-foreground">{risk.label}</span>
{risk.desc ? (
<p className="mt-1 text-xs leading-5 text-muted-foreground">{risk.desc}</p>
) : null}
</div>
))
) : riskInfo?.risk_labels.length ? (
<div className="flex flex-wrap gap-2">
{riskInfo.risk_labels.map(label => (
<Badge key={label} variant="secondary">
{label}
</Badge>
))}
</div>
) : (
<span className="text-sm text-muted-foreground">暂无风险标签详情</span>
)}
</div>
</div>
)
}
@@ -0,0 +1,39 @@
import { toast } from "sonner"
import { X } from "lucide-react"
import { RiskInfo, RiskInfoBox } from "@/components/common/risk/risk-info-box"
const RISK_TOAST_ID = "credit-risk-warning"
let isRiskWarningDismissed = false
function dismissRiskWarningToast(id: string | number) {
isRiskWarningDismissed = true
toast.dismiss(id)
}
export function showRiskWarningToast(riskInfo: RiskInfo) {
if (isRiskWarningDismissed) return
toast.custom(
(id) => (
<div className="relative w-[min(calc(100vw-2rem),28rem)]">
<button
type="button"
aria-label="关闭风险提示"
className="absolute right-2 top-2 inline-flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => dismissRiskWarningToast(id)}
>
<X className="size-4" />
</button>
<RiskInfoBox riskInfo={riskInfo} label="账号存在风险提示" labelClassName="font-semibold text-foreground" />
</div>
),
{
id: RISK_TOAST_ID,
duration: Infinity,
dismissible: true,
onDismiss: () => {
isRiskWarningDismissed = true
},
},
)
}
@@ -0,0 +1,224 @@
"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"
function ThemeModeSection() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = React.useState(false)
React.useEffect(() => {
setMounted(true)
}, [])
const modes = [
{ id: "light", name: "明亮", icon: Sun },
{ id: "dark", name: "黑暗", icon: Moon },
{ id: "system", name: "自动", icon: Monitor }
]
if (!mounted) {
return (
<div className="space-y-6">
<div>
<h2 className="font-medium text-sm text-foreground">主题模式</h2>
<p className="text-xs text-muted-foreground">
选择应用程序的整体明暗主题
</p>
</div>
<div className="flex gap-2">
{modes.map((mode) => {
const Icon = mode.icon
return (
<Button
key={mode.id}
variant="secondary"
size="sm"
className="flex-1 text-xs"
>
{Icon && <Icon className="size-3 mr-1" />}
{mode.name}
</Button>
)
})}
</div>
</div>
)
}
return (
<div className="space-y-6">
<div>
<h2 className="font-medium text-sm text-foreground">主题模式</h2>
<p className="text-xs text-muted-foreground">
选择应用程序的整体明暗主题
</p>
</div>
<div className="flex gap-2">
{modes.map((mode) => {
const Icon = mode.icon
const isActive = theme === mode.id
return (
<Button
key={mode.id}
variant={isActive ? "default" : "secondary"}
size="sm"
onClick={() => setTheme(mode.id)}
className="flex-1 text-xs"
>
{Icon && <Icon className="size-3 mr-1" />}
{mode.name}
</Button>
)
})}
</div>
</div>
)
}
function InterfaceAppearanceSection() {
const { themes, currentThemeId, setTheme: setCustomTheme, isLoading } = useCustomTheme()
const { theme: mode } = useTheme()
const [mounted, setMounted] = React.useState(false)
const [switching, setSwitching] = React.useState<string | null>(null)
React.useEffect(() => {
setMounted(true)
}, [])
const handleSetTheme = (themeId: string) => {
if (switching === themeId) return
setSwitching(themeId)
try {
setCustomTheme(themeId)
const themeName = themes.find(t => t.id === themeId)?.name || themeId
toast.success(`外观已切换为 ${ themeName }`)
} catch {
toast.error("切换失败")
} finally {
setSwitching(null)
}
}
const isDark = mounted && (mode === 'dark' || (mode === 'system' && typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches))
return (
<div className="space-y-6">
<div>
<h2 className="font-medium text-sm text-foreground">界面外观</h2>
<p className="text-xs text-muted-foreground">
选择界面的配色方案和视觉风格
</p>
</div>
<div>
{isLoading ? (
<div className="flex items-center justify-center p-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground/50" />
</div>
) : (
<div className="grid grid-cols-2 xs:grid-cols-3 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-6">
{themes.map((theme) => {
const isActive = currentThemeId === theme.id || (!currentThemeId && theme.id === "default.css")
const isSwitching = switching === theme.id
const colors = isDark ? theme.colors.dark : theme.colors.light
return (
<div
key={theme.id}
onClick={() => handleSetTheme(theme.id)}
className={cn(
"group relative cursor-pointer rounded-xl border transition-all overflow-hidden",
isActive
? "border-primary ring-2 ring-primary/20"
: "border-border hover:border-border/50 hover:shadow-md"
)}
>
<div
className="aspect-[4/3] w-full relative"
style={{ backgroundColor: colors.background || colors['background'] }}
>
<div className="flex h-full p-2 gap-2">
<div
className="w-1/4 h-full rounded-sm opacity-80"
style={{ backgroundColor: colors.sidebar || colors['sidebar'] }}
/>
<div className="flex-1 space-y-1.5 pt-1">
<div
className="h-1.5 w-1/3 rounded-full opacity-90"
style={{ backgroundColor: colors.primary || colors['primary'] }}
/>
<div
className="h-8 w-full rounded-sm opacity-40"
style={{ backgroundColor: colors.sidebar || colors['sidebar'] }}
/>
<div
className="h-4 w-1/2 rounded-sm opacity-30"
style={{ backgroundColor: colors.sidebar || colors['sidebar'] }}
/>
</div>
</div>
{isActive && !isSwitching && (
<div className="absolute top-2 right-2 z-10 bg-primary text-primary-foreground rounded-full p-0.5 shadow-sm">
<Check className="h-3 w-3" />
</div>
)}
</div>
<div className="p-2 border-t border-border/50 bg-card">
<div className={cn("text-[10px] font-medium text-center truncate transition-colors", isActive ? "text-primary" : "text-muted-foreground")}>
{theme.name}
</div>
</div>
</div>
)
})}
</div>
)}
</div>
</div>
)
}
export function AppearanceMain() {
return (
<div className="py-6 space-y-6">
<div className="font-semibold">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/settings" className="text-base text-primary">设置</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className="text-base font-semibold">外观设置</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<ThemeModeSection />
<InterfaceAppearanceSection />
<div className="space-y-4 opacity-50 pointer-events-none grayscale">
<h2 className="font-medium text-sm text-muted-foreground">即将推出</h2>
<div className="bg-muted/30 rounded-xl p-6 h-32 flex items-center justify-center border border-border/50">
<span className="text-sm text-muted-foreground">更多自定义选项正在开发中...</span>
</div>
</div>
</div>
)
}
@@ -0,0 +1,60 @@
"use client"
import Link from "next/link"
import { Bell } from "lucide-react"
import { Switch } from "@/components/ui/switch"
import { Label } from "@/components/ui/label"
import { useNotificationSettings } from "@/contexts/notification-settings-context"
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
export function NotificationsMain() {
const { showBell, setShowBell } = useNotificationSettings()
return (
<div className="py-6 space-y-6">
<div className="font-semibold">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/settings" className="text-base text-primary">设置</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className="text-base font-semibold">通知设置</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="space-y-6">
<div>
<h2 className="font-medium text-sm text-foreground">通知显示</h2>
<p className="text-xs text-muted-foreground">
设置通知相关的显示选项
</p>
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="flex items-center gap-3">
<Bell className="size-5 text-primary" />
<div className="space-y-0.5">
<Label htmlFor="show-bell" className="text-sm font-medium cursor-pointer">
显示通知铃铛
</Label>
<p className="text-xs text-muted-foreground">
在顶部导航栏中显示通知铃铛图标
</p>
</div>
</div>
<Switch
id="show-bell"
checked={showBell}
onCheckedChange={setShowBell}
/>
</div>
</div>
</div>
)
}
@@ -0,0 +1,469 @@
"use client"
import * as React from "react"
import Link from "next/link"
import { motion, useAnimation } from "motion/react"
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, type CarouselApi } from "@/components/ui/carousel"
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Info } from "lucide-react"
import { cn } from "@/lib/utils"
import { PayLevel, User } from "@/lib/services/auth"
import { useUser } from "@/contexts/user-context"
import services from "@/lib/services"
import type { UserPayConfig } from "@/lib/services/admin"
/**
* 等级配置
*/
interface LevelConfig {
level: PayLevel
name: string
nameEn: string
minScore: number
maxScore: number | null
gradient: string
textColor: string
metalEffect?: boolean
dailyLimit: number | null
feeRate: number | string
scoreRate: number | string
}
/** 等级视觉样式配置(不包含业务数据) */
const LEVEL_STYLES = [
{
level: PayLevel.Ordinary,
name: "普通会员",
nameEn: "ORDINARY MEMBER",
gradient: "from-[#1e3a8a] via-[#2563eb] to-[#1d4ed8] shadow-lg shadow-blue-900/10",
textColor: "text-white/95",
},
{
level: PayLevel.Gold,
name: "黄金会员",
nameEn: "GOLD MEMBER",
gradient: "from-[#FDFCF0] via-[#FCE38A] to-[#B45309] shadow-lg shadow-amber-500/10",
textColor: "text-[#854D0E]",
metalEffect: true,
},
{
level: PayLevel.WhiteGold,
name: "白金会员",
nameEn: "PLATINUM MEMBER",
gradient: "from-[#F8FAFC] via-[#E2E8F0] to-[#94A3B8] shadow-lg shadow-slate-500/10",
textColor: "text-slate-800",
metalEffect: true,
},
{
level: PayLevel.BlackGold,
name: "黑金会员",
nameEn: "BLACK MEMBER",
gradient: "from-[#020617] via-[#1e293b] to-[#020617] shadow-lg shadow-black/20",
textColor: "text-[#FFE5B4]",
metalEffect: true,
},
] as const
/** 合并 API 数据和视觉样式 */
function mergeLevelConfigs(apiConfigs: UserPayConfig[]): LevelConfig[] {
return LEVEL_STYLES.map(style => {
const apiConfig = apiConfigs.find(c => c.level === style.level)
if (apiConfig) {
return {
...style,
minScore: apiConfig.min_score,
maxScore: apiConfig.max_score,
dailyLimit: apiConfig.daily_limit,
feeRate: apiConfig.fee_rate,
scoreRate: apiConfig.score_rate,
}
}
return {
...style,
minScore: 0,
maxScore: null,
dailyLimit: null,
feeRate: 0,
scoreRate: 0,
}
})
}
function getLevelConfig(score: number, configs: LevelConfig[]): LevelConfig {
if (configs.length === 0) {
return {
level: PayLevel.Ordinary,
name: "加载中...",
nameEn: "LOADING...",
gradient: "from-blue-600 via-blue-500 to-blue-700",
textColor: "text-white",
minScore: 0,
maxScore: null,
dailyLimit: null,
feeRate: 0,
scoreRate: 0,
}
}
for (let i = configs.length - 1; i >= 0; i--) {
if (score >= configs[i].minScore) {
return configs[i]
}
}
return configs[0]
}
function MembershipCard({
config,
user,
score,
currentLevel,
isActive,
levelConfigs,
}: {
config: LevelConfig
user: User
score: number
currentLevel: LevelConfig
isActive: boolean
levelConfigs: LevelConfig[]
}) {
const isAccessible = levelConfigs.findIndex(l => l.level === config.level) <= levelConfigs.findIndex(l => l.level === currentLevel.level)
const isDarkCard = config.level === PayLevel.BlackGold || config.level === PayLevel.Ordinary
return (
<div
className={cn(
"transition-all duration-500",
isActive ? "opacity-100 scale-100" : "opacity-40 scale-95"
)}
>
<div
className={cn(
"relative w-full aspect-[2/1] rounded-xl overflow-hidden bg-gradient-to-br shadow-lg",
config.gradient,
config.metalEffect && "shadow-2xl"
)}
>
<div className="absolute inset-0 opacity-10">
<div className="absolute top-0 right-0 w-40 h-40 bg-white rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
<div className="absolute bottom-0 left-0 w-32 h-32 bg-white rounded-full blur-3xl translate-y-1/2 -translate-x-1/2" />
</div>
<div className="absolute inset-0 opacity-[0.15] bg-[url('https://grainy-gradients.vercel.app/noise.svg')]" />
{config.metalEffect && (
<>
<div className="absolute inset-0 bg-gradient-to-br from-white/5 via-white/2 to-transparent" />
<div className="absolute inset-0 rounded-xl ring-1 ring-inset ring-white/25" />
<div className="absolute top-0 left-0 w-full h-1/4 bg-gradient-to-b from-white/15 via-transparent to-transparent rounded-t-xl" />
</>
)}
<div className={cn("relative h-full flex flex-col justify-between p-3 sm:p-4 md:p-5", config.textColor)}>
<div>
<div className="text-[8px] sm:text-[9px] md:text-[10px] font-medium opacity-70 tracking-wider">
{config.nameEn}
</div>
<div className="text-sm sm:text-base md:text-lg font-bold tracking-tight">
{config.name}
</div>
</div>
<div className="space-y-0.5 sm:space-y-1">
<div className="text-[8px] sm:text-[9px] md:text-[10px] opacity-70 tracking-wide">平台分数</div>
<div className="text-lg sm:text-xl md:text-2xl font-bold tracking-tight tabular-nums">
{score.toLocaleString()}
</div>
</div>
<div className="flex items-end justify-between gap-1">
<div className="text-[8px] sm:text-[9px] md:text-[10px] opacity-70">
LINUX DO <span className="italic font-serif">Credit</span>
</div>
<div className="text-right">
<div className="text-[8px] sm:text-[9px] md:text-[10px] opacity-70">分数范围</div>
<div className="text-[8px] sm:text-[9px] md:text-[10px] font-medium tabular-nums">
{config.minScore < 0 ? "0.00" : config.minScore.toLocaleString()} - {config.maxScore?.toLocaleString() || "∞"}
</div>
</div>
</div>
</div>
<div className="absolute top-3 right-3 sm:top-4 sm:right-4 md:top-5 md:right-5">
{isAccessible ? (
<Avatar className="size-8 sm:size-9 md:size-10 border-2 border-white/20">
<AvatarImage src={user?.avatar_url} alt={user?.nickname} />
<AvatarFallback
className={cn(
"text-xs sm:text-sm font-semibold",
isDarkCard ? "bg-white/20 text-white" : "bg-black/20 text-black"
)}
>
{user?.nickname?.slice(0, 2)}
</AvatarFallback>
</Avatar>
) : (
<div
className={cn(
"px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-full text-[8px] sm:text-[9px] md:text-[10px] font-semibold backdrop-blur-sm",
isDarkCard ? "bg-white/20 text-white" : "bg-black/20 text-black"
)}
>
未获得
</div>
)}
</div>
</div>
</div>
)
}
function getPayLevelLabel(level: PayLevel): string {
switch (level) {
case PayLevel.BlackGold:
return "黑金会员"
case PayLevel.WhiteGold:
return "白金会员"
case PayLevel.Gold:
return "黄金会员"
case PayLevel.Ordinary:
return "普通会员"
default:
return "未知等级"
}
}
export function ProfileMain() {
const { user, loading, getTrustLevelLabel } = useUser()
const [api, setApi] = React.useState<CarouselApi>()
const [current, setCurrent] = React.useState(0)
const [levelConfigs, setLevelConfigs] = React.useState<LevelConfig[]>([])
const controls = useAnimation()
const isAnimatingRef = React.useRef(false)
const handleAvatarClick = () => {
if (isAnimatingRef.current) return
isAnimatingRef.current = true
controls.start({
rotate: [0, -20, 20, -20, 20, 0],
transition: { duration: 0.5, ease: "easeInOut" }
})
setTimeout(() => {
isAnimatingRef.current = false
}, 650)
}
const score = user?.pay_score ?? 0
const currentLevel = getLevelConfig(score, levelConfigs)
React.useEffect(() => {
async function fetchPayConfigs() {
const configs = await services.config.getUserPayConfigs()
setLevelConfigs(mergeLevelConfigs(configs))
}
fetchPayConfigs()
}, [])
React.useEffect(() => {
if (!api) return
setCurrent(api.selectedScrollSnap())
api.on("select", () => setCurrent(api.selectedScrollSnap()))
}, [api])
React.useEffect(() => {
if (!api || levelConfigs.length === 0 || !currentLevel) return
const scrollToCurrentLevel = () => {
const currentLevelIndex = levelConfigs.findIndex(
(config) => config.level === currentLevel.level
)
if (currentLevelIndex >= 0) {
api.scrollTo(currentLevelIndex, true)
}
}
scrollToCurrentLevel()
api.on("reInit", scrollToCurrentLevel)
return () => {
api.off("reInit", scrollToCurrentLevel)
}
}, [api, currentLevel, levelConfigs])
if (loading) {
return (
<div className="py-6 space-y-4">
<div className="border-b border-border pb-4">
<h1 className="text-2xl font-semibold">个人资料</h1>
</div>
</div>
)
}
if (!user) {
return (
<div className="py-6 space-y-6">
<div className="text-sm text-muted-foreground">未找到用户信息</div>
</div>
)
}
return (
<div className="py-6 space-y-6">
<div className="font-semibold">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/settings" className="text-base text-primary">设置</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className="text-base font-semibold">个人资料</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<h2 className="font-medium text-sm text-muted-foreground">会员等级</h2>
<Carousel
setApi={setApi}
opts={{
align: "center",
loop: false,
containScroll: false,
}}
className="w-full relative"
>
<div className="absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-background to-transparent pointer-events-none z-10" />
<div className="absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-background to-transparent pointer-events-none z-10" />
<CarouselContent className="-ml-4">
{levelConfigs.map((config, index) => (
<CarouselItem
key={config.level}
className="pl-4 basis-[85%] sm:basis-[70%] md:basis-[65%] lg:basis-[50%] xl:basis-[40%] 2xl:basis-[35%] cursor-pointer"
onClick={() => api?.scrollTo(index)}
>
<MembershipCard
config={config}
user={user}
score={score}
currentLevel={currentLevel}
isActive={index === current}
levelConfigs={levelConfigs}
/>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="left-0" />
<CarouselNext className="right-0" />
</Carousel>
<div className="space-y-4">
<h2 className="font-medium text-sm text-muted-foreground">基本信息</h2>
<div className="flex flex-col sm:flex-row items-start gap-4 sm:gap-6">
<motion.div
animate={controls}
onClick={handleAvatarClick}
className="cursor-pointer origin-center"
>
<Avatar className="size-14 sm:size-16">
<AvatarImage src={user.avatar_url} alt={user.nickname || user.username} />
<AvatarFallback className="text-lg sm:text-xl">
{(user.nickname || user.username).slice(0, 2)}
</AvatarFallback>
</Avatar>
</motion.div>
<div className="flex-1 w-full">
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
<div className="space-y-1">
<div className="text-xs text-muted-foreground">账户</div>
<div className="text-sm font-medium">@{user.username}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">昵称</div>
<div className="text-sm font-medium">{user.nickname || '未设置'}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">用户ID</div>
<div className="text-sm font-medium">{user.id}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">信任等级</div>
<div className="text-sm font-medium">{getTrustLevelLabel(user.trust_level)}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">平台等级</div>
<div className="text-sm font-medium">{getPayLevelLabel(user.pay_level)}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">平台分数</div>
<div className="text-sm font-medium">{user.pay_score.toLocaleString()}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">每日流转限额</div>
<div className="text-sm font-medium">
{currentLevel.dailyLimit ? `${ currentLevel.dailyLimit.toLocaleString() } 积分` : '无限额'}
</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">集市手续费率</div>
<div className="text-sm font-medium">
{typeof currentLevel.feeRate === 'number'
? `${ (currentLevel.feeRate * 100).toFixed(2) }%`
: `${ (parseFloat(currentLevel.feeRate as string) * 100).toFixed(2) }%`}
</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">平台分数转化率</div>
<div className="text-sm font-medium">
{typeof currentLevel.scoreRate === 'number'
? `${ (currentLevel.scoreRate * 100).toFixed(2) }%`
: `${ (parseFloat(currentLevel.scoreRate as string) * 100).toFixed(2) }%`}
</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground flex items-center gap-1">
积分基准值
<Popover>
<PopoverTrigger>
<Info className="h-3 w-3 cursor-help" />
</PopoverTrigger>
<PopoverContent side="top" className="w-auto max-w-[280px] p-3">
<p className="text-xs">上一次从社区同步的积分基准值</p>
</PopoverContent>
</Popover>
</div>
<div className="text-sm font-medium tabular-nums">
{parseFloat(user.community_balance).toLocaleString()}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,212 @@
"use client"
import * as React from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { motion, AnimatePresence } from "motion/react"
import { ShieldCheck } from "lucide-react"
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
import { Button } from "@/components/ui/button"
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"
import { Spinner } from "@/components/ui/spinner"
import { UserService } from "@/lib/services/user"
export function SecurityMain() {
const router = useRouter()
const [setupStep, setSetupStep] = React.useState<'password' | 'confirm'>('password')
const [payKey, setPayKey] = React.useState("")
const [confirmPayKey, setConfirmPayKey] = React.useState("")
const [isSubmitting, setIsSubmitting] = React.useState(false)
const isPayKeyValid = payKey.length === 6 && /^\d{6}$/.test(payKey)
const isConfirmValid = confirmPayKey.length === 6 && /^\d{6}$/.test(confirmPayKey)
const passwordsMatch = payKey === confirmPayKey
/* 安全密码输入 */
const handlePayKeyChange = (value: string) => {
const numericValue = value.replaceAll(/\D/g, '')
setPayKey(numericValue)
}
/* 确认安全密码 */
const handleConfirmPayKeyChange = (value: string) => {
const numericValue = value.replaceAll(/\D/g, '')
setConfirmPayKey(numericValue)
}
const handlePayKeySubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (setupStep === 'password') {
if (!isPayKeyValid) {
toast.error("安全密码必须为6位数字")
return
}
setSetupStep('confirm')
} else {
if (!isConfirmValid) {
toast.error("确认密码必须为6位数字")
return
}
if (!passwordsMatch) {
toast.error("两次输入的安全密码不一致")
setSetupStep('password')
setConfirmPayKey("")
return
}
setIsSubmitting(true)
try {
await UserService.updatePayKey(payKey)
toast.success("修改成功", {
description: "您的安全密码已成功更新",
})
// 立即退出
router.push('/settings')
} catch (error) {
toast.error("修改失败", {
description: error instanceof Error ? error.message : "更新密码时发生错误,请稍后重试",
})
setSetupStep('password')
setConfirmPayKey("")
} finally {
setIsSubmitting(false)
}
}
}
return (
<div className="py-6 space-y-6">
<div className="font-semibold">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/settings" className="text-base text-primary">设置</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className="text-base font-semibold">安全设置</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="space-y-6">
<div className="font-medium text-sm text-muted-foreground flex items-center gap-2">
<ShieldCheck className="w-4 h-4" />
密码安全
</div>
<div className="mt-8 flex justify-center">
<motion.div
className="w-full max-w-sm space-y-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<AnimatePresence mode="wait">
<motion.div
key={setupStep}
initial={{ opacity: 0, x: setupStep === 'confirm' ? 20 : -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
className="space-y-8"
>
<div className="flex flex-col items-center gap-2 mb-4">
<h3 className="text-xl font-bold tracking-tight text-center">
{setupStep === 'password' ? '设置新密码' : '确认新密码'}
</h3>
<p className="text-sm text-muted-foreground text-center max-w-[320px] mx-auto">
{setupStep === 'password'
? '请输入新的6位数字安全密码'
: '请再次输入密码进行确认'}
</p>
</div>
<form onSubmit={handlePayKeySubmit} className="space-y-6">
<div className="flex justify-center">
{setupStep === 'password' ? (
<InputOTP
maxLength={6}
value={payKey}
onChange={handlePayKeyChange}
disabled={isSubmitting}
autoFocus
>
<InputOTPGroup className="gap-2">
{[0, 1, 2, 3, 4, 5].map((i) => (
<InputOTPSlot key={i} index={i} className="w-10 h-10 sm:w-11 sm:h-11 border-input transition-all ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2" />
))}
</InputOTPGroup>
</InputOTP>
) : (
<div className="space-y-4">
<InputOTP
maxLength={6}
value={confirmPayKey}
onChange={handleConfirmPayKeyChange}
disabled={isSubmitting}
autoFocus
>
<InputOTPGroup className="gap-2">
{[0, 1, 2, 3, 4, 5].map((i) => (
<InputOTPSlot key={i} index={i} className="w-10 h-10 sm:w-11 sm:h-11 text-lg border-input transition-all ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2" />
))}
</InputOTPGroup>
</InputOTP>
{!passwordsMatch && confirmPayKey.length === 6 && (
<motion.p
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="text-xs text-red-500 text-center font-medium"
>
两次输入的密码不一致
</motion.p>
)}
</div>
)}
</div>
<div className="flex gap-4 sm:mx-8">
{setupStep === 'confirm' && (
<Button
type="button"
variant="secondary"
onClick={() => {
setSetupStep('password')
setConfirmPayKey('')
}}
className="flex-1 h-10 rounded-full tracking-wide text-sm font-bold transition-all active:scale-95"
>
返回
</Button>
)}
<Button
type="submit"
className={`flex-1 h-10 rounded-full tracking-wide bg-primary hover:bg-primary/90 text-primary-foreground text-sm font-bold shadow-lg shadow-primary/20 hover:shadow-primary/30 transition-all active:scale-95 ${setupStep === 'password' ? 'w-full' : ''}`}
disabled={
setupStep === 'password'
? !isPayKeyValid
: isSubmitting || !isConfirmValid
}
>
{isSubmitting && <Spinner className="mr-2" />}
{setupStep === 'password' ? '继续' : '保存修改'}
</Button>
</div>
</form>
</motion.div>
</AnimatePresence>
</motion.div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,85 @@
import * as React from "react"
import { TrendingDown, TrendingUp, Users } from "lucide-react"
import { CountingNumber } from "@/components/animate-ui/primitives/texts/counting-number"
import { useUser } from "@/contexts/user-context"
/**
* 安全转换数字,避免 NaN
*/
function safeNumber(value: number): number {
return typeof value === 'number' && !isNaN(value) ? value : 0
}
/**
* 所有活动组件
* 显示用户的所有活动统计数据
*/
export function AllActivity() {
const { user, loading } = useUser()
/* 统计数据 */
const stats = React.useMemo(() => ({
totalReceive: parseFloat(user?.total_receive || '0'),
totalPayment: parseFloat(user?.total_payment || '0'),
totalCommunity: parseFloat(user?.total_community || '0')
}), [user])
/* 功能卡片配置 */
const statCards = React.useMemo(() => [
{
title: "总收益",
value: stats.totalReceive,
icon: TrendingUp,
color: "text-green-600",
bgColor: "bg-green-100 dark:bg-green-900/20"
},
{
title: "总消耗",
value: stats.totalPayment,
icon: TrendingDown,
color: "text-red-600",
bgColor: "bg-red-100 dark:bg-red-900/20"
},
{
title: "总划转",
value: stats.totalCommunity,
icon: Users,
color: "text-purple-600",
bgColor: "bg-purple-100 dark:bg-purple-900/20"
}
], [stats])
return (
<div className="space-y-4">
<h2 className="font-semibold">数据概览</h2>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
{statCards.map((card) => {
const IconComponent = card.icon
return (
<div key={card.title} className="bg-muted/50 rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="text-xs font-medium text-muted-foreground">
{card.title}
</div>
<div className={`p-1.5 rounded-full ${ card.bgColor }`}>
<IconComponent className={`h-3 w-3 ${ card.color }`} />
</div>
</div>
<div className="text-xl md:text-2xl font-bold mt-2">
{loading ? '-' : (
<CountingNumber
number={safeNumber(card.value)}
decimalPlaces={2}
initiallyStable={true}
inView={true}
inViewOnce={true}
/>
)}
</div>
</div>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,25 @@
import * as React from "react"
/**
* 社区积分划转组件
*
* 显示社区积分划转规则
*/
export function Community() {
return (
<div className="space-y-4">
<div className="bg-muted/50 rounded-lg px-6 py-4">
<div className="max-w-2xl">
<h2 className="font-semibold mb-3">LINUX DO 社区积分划转规则</h2>
<div className="text-muted-foreground space-y-2">
<ul className="list-disc list-inside space-y-1 text-sm">
<li>社区积分源于用户在社区内的活动,如解决问题,获赞等。</li>
<li>社区积分无法在每日划转前进行交易、赠予给其他社区用户。</li>
<li>每日划转时间为每日凌晨00:00:00,目前划转无需任何服务手续费。</li>
</ul>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,27 @@
import Link from "next/link"
import { Button } from "@/components/ui/button"
/**
* 积分流转服务组件
*
* 显示积分流转服务功能和管理入口
*/
export function Online() {
return (
<div className="space-y-4">
<div className="bg-muted/50 rounded-lg px-6 py-8">
<div className="max-w-2xl">
<h2 className="text-2xl font-bold mb-4 text-foreground">在线积分流转</h2>
<p className="text-muted-foreground mb-6 leading-relaxed">
创建和管理您的在线积分流转服务,无需编写代码即可快速开启个人服务。
</p>
<Link href="/merchant/online-paying">
<Button className="bg-primary hover:bg-primary/90 font-medium px-6 rounded-md shadow-sm">
前往创建
</Button>
</Link>
</div>
</div>
</div>
)
}
@@ -0,0 +1,24 @@
import * as React from "react"
/**
* 积分消耗组件
*
* 显示积分消耗功能和规则
*/
export function Payment() {
return (
<div className="space-y-4">
<div className="bg-muted/50 rounded-lg px-6 py-4">
<div className="max-w-2xl">
<h2 className="font-semibold mb-3">积分消耗</h2>
<div className="text-muted-foreground space-y-2">
<ul className="list-disc list-inside space-y-1 text-sm">
<li>安全可靠的认证流程,确保您的账户安全。</li>
<li>实时积分消耗状态跟踪,随时了解消耗进展。</li>
</ul>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,94 @@
"use client"
import * as React from "react"
import { useState } from "react"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
/**
* 功能卡片组件
*/
function FeatureCard({ title, description, linkText, href }: { title: string, description: string, linkText: string, href?: string }) {
return (
<Card className="bg-background border border-border shadow-none transition-shadow">
<CardHeader>
<CardTitle className="text-lg font-semibold -mb-4">{title}</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="text-xs text-muted-foreground leading-relaxed">
{description}
</CardDescription>
{href ? (
<Link href={href}>
<Button variant="link" className="px-0 h-auto text-xs text-blue-600 font-normal hover:text-blue-700">
{linkText}
</Button>
</Link>
) : (
<Button variant="link" className="px-0 h-auto text-xs text-muted-foreground font-normal cursor-not-allowed" disabled>
{linkText}
</Button>
)}
</CardContent>
</Card>
)
}
/**
* 积分收益组件
*
* 显示积分收益功能和规则
*/
export function Receive() {
const [isVisible, setIsVisible] = useState(true)
const [isHiding, setIsHiding] = useState(false)
const handleHide = () => {
setIsHiding(true)
setTimeout(() => setIsVisible(false), 300)
}
return (
<div className="space-y-4">
{isVisible && (
<div
className={`bg-muted/50 rounded-lg px-6 py-8 overflow-hidden transition-all duration-300 ease-out ${isHiding ? 'opacity-0 max-h-0 !py-0 !mb-0' : 'opacity-100 max-h-[500px]'}`}
>
<div className="max-w-2xl">
<h2 className="text-2xl font-bold mb-4 text-foreground">获得积分收益</h2>
<p className="text-muted-foreground mb-6 leading-relaxed">
通过无代码选项快速开始探索或使用与我们的 API 集成的可自定义积分服务。
</p>
<Button
className="bg-primary hover:bg-primary/90 font-medium px-6 rounded-md shadow-sm"
onClick={handleHide}
>
开始使用
</Button>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<FeatureCard
title="接入官方 API 接口"
description="使用官方 API 接口(兼容易支付),快速接入积分服务,支持异步通知和同步跳转。"
linkText="查看文档"
href="/docs/api"
/>
<FeatureCard
title="创建在线积分服务"
description="通过我们提供的在线积分服务页面,快速创建,无需编写代码即可开始进行积分流转。"
linkText="前往集市"
href="/merchant"
/>
<FeatureCard
title="面对面服务"
description="通过 LINUX DO Credit 提供的面对面服务功能,扩展您的积分收益渠道,处理面对面服务场景。"
linkText="功能开发中,敬请期待"
/>
</div>
</div>
)
}
@@ -0,0 +1,767 @@
"use client"
import * as React from "react"
import { useState, useEffect } from "react"
import Image from "next/image"
import { toast } from "sonner"
import { Gift, Copy, Check, ExternalLink, Pencil, X, ImagePlus, History } from "lucide-react"
import type { UploadImageResponse } from "@/lib/services"
import { getFileUrl } from "@/lib/services/upload/upload.service"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { PasswordDialog } from "@/components/common/general/password-dialog"
import { ListData } from "@/components/common/general/list-data"
import { ImageCropper } from "@/components/common/redenvelope/image-cropper"
import { motion } from "motion/react"
import { RedEnvelopeCard } from "@/components/common/redenvelope/red-envelope-card"
import { useUser } from "@/contexts/user-context"
import services from "@/lib/services"
import type { RedEnvelopeType, CreateRedEnvelopeRequest, PublicConfigResponse, RedEnvelope, RedEnvelopeListResponse } from "@/lib/services"
interface RedEnvelopeCover {
coverImageUrl: string | null
heterotypicImageUrl: string | null
coverUploadId: string | null
heterotypicUploadId: string | null
}
/**
* 红包组件
*
* 提供红包发送功能,包括 Banner 和创建对话框
*/
export function RedEnvelope({ onSuccess }: { onSuccess?: () => void }) {
const { user } = useUser()
const [isFormOpen, setIsFormOpen] = useState(false)
const [isPasswordOpen, setIsPasswordOpen] = useState(false)
const [isResultOpen, setIsResultOpen] = useState(false)
const [copiedId, setCopiedId] = useState<string | null>(null)
const [isCropperOpen, setIsCropperOpen] = useState(false)
const [cropperType, setCropperType] = useState<'cover' | 'heterotypic'>('cover')
const [showCustomization, setShowCustomization] = useState(false)
const [historyCoverCovers, setHistoryCoverCovers] = useState<UploadImageResponse[]>([])
const [historyHeterotypicCovers, setHistoryHeterotypicCovers] = useState<UploadImageResponse[]>([])
/* 表单状态 */
const [type, setType] = useState<RedEnvelopeType>("random")
const [totalAmount, setTotalAmount] = useState("")
const [totalCount, setTotalCount] = useState("")
const [greeting, setGreeting] = useState("")
const [loading, setLoading] = useState(false)
/* 封面状态 */
const [cover, setCover] = useState<RedEnvelopeCover>({
coverImageUrl: null,
heterotypicImageUrl: null,
coverUploadId: null,
heterotypicUploadId: null
})
/* 结果状态 */
const [resultLink, setResultLink] = useState("")
/* 配置状态 */
const [config, setConfig] = useState<PublicConfigResponse | null>(null)
/* 加载配置 */
const loadConfig = async () => {
try {
const result = await services.config.getPublicConfig()
setConfig(result)
} catch {
toast.error('加载配置失败')
}
}
/* 初始加载 */
useEffect(() => {
loadConfig()
}, [])
/* 验证积分格式 */
const validateAmount = (value: string): boolean => {
const regex = /^\d+(\.\d{1,2})?$/
return regex.test(value) && parseFloat(value) > 0
}
/* 处理表单提交 */
const handleFormSubmit = () => {
if (!totalAmount.trim()) {
toast.error("请设置积分总数")
return
}
if (!validateAmount(totalAmount)) {
toast.error("积分格式不正确,必须大于1且最多2位小数")
return
}
if (!totalCount.trim()) {
toast.error("请设置可领取人数")
return
}
const count = parseInt(totalCount)
if (isNaN(count) || count <= 0) {
toast.error("可领取人数必须大于0")
return
}
const amount = parseFloat(totalAmount)
if (amount < 1) {
toast.error("红包总金额不能低于1 LDC")
return
}
if (config && parseFloat(config.red_envelope_max_amount) > 0) {
if (amount > parseFloat(config.red_envelope_max_amount)) {
toast.error(`红包的积分总数不能超过 ${ config.red_envelope_max_amount } LDC`)
return
}
}
if (count > 10000) {
toast.error("可领取人数不能超过 10000 个")
return
}
if (amount / count < 0.01) {
toast.error("单个红包不可低于 0.01 LDC")
return
}
if (greeting.length > 100) {
toast.error("祝福语最多100字符")
return
}
setIsPasswordOpen(true)
}
/* 提交创建 */
const handleConfirmCreate = async (password: string) => {
setLoading(true)
try {
const data: CreateRedEnvelopeRequest = {
type,
total_amount: parseFloat(totalAmount),
total_count: parseInt(totalCount),
greeting: greeting || "恭喜发财,大吉大利",
pay_key: password,
cover_upload_id: cover.coverUploadId || undefined,
heterotypic_upload_id: cover.heterotypicUploadId || undefined,
}
const result = await services.redEnvelope.create(data)
const link = getEnvelopeLink(result.id)
setResultLink(link)
setIsPasswordOpen(false)
setIsFormOpen(false)
setIsResultOpen(true)
onSuccess?.()
/* 重置表单 */
setType("random")
setTotalAmount("")
setTotalCount("")
setGreeting("")
setCover({
coverImageUrl: null,
heterotypicImageUrl: null,
coverUploadId: null,
heterotypicUploadId: null
})
setShowCustomization(false)
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : '创建失败'
toast.error('创建红包失败', { description: errorMessage })
} finally {
setLoading(false)
}
}
/* 复制链接 */
const handleCopyLink = async (link: string, envelopeId: string) => {
try {
await navigator.clipboard.writeText(link)
setCopiedId(envelopeId)
toast.success("链接已复制")
setTimeout(() => setCopiedId(null), 2000)
} catch {
toast.error("复制失败")
}
}
/* 计算手续费 */
const feeInfo = React.useMemo(() => {
if (!totalAmount || !config) return null
const amount = parseFloat(totalAmount)
if (isNaN(amount)) return null
const feeRate = parseFloat(config.red_envelope_fee_rate)
const fee = (amount * feeRate).toFixed(2)
const total = (amount + parseFloat(fee)).toFixed(2)
return { fee, total, rate: feeRate }
}, [totalAmount, config])
/* 生成红包链接 */
const getEnvelopeLink = (id: string) => {
if (typeof window !== 'undefined') {
return `${ window.location.origin }/redenvelope/${ id }`
}
return `/redenvelope/${ id }`
}
/* 处理封面裁剪完成 */
const handleCropComplete = async (croppedImage: string, coverType: 'cover' | 'heterotypic') => {
const uploadingToast = toast.loading(`正在上传${ coverType === 'cover' ? '背景封面' : '装饰图片' }...`)
try {
const result = await services.upload.uploadBase64Image(
croppedImage,
coverType,
`${ coverType }-${ Date.now() }.png`
)
setCover(prev => ({
...prev,
[coverType === 'cover' ? 'coverImageUrl' : 'heterotypicImageUrl']: getFileUrl(result.id),
[coverType === 'cover' ? 'coverUploadId' : 'heterotypicUploadId']: result.id
}))
toast.dismiss(uploadingToast)
toast.success(`${ coverType === 'cover' ? '背景封面' : '装饰图片' }已设置`)
} catch (error) {
toast.dismiss(uploadingToast)
const errorMessage = error instanceof Error ? error.message : '上传失败'
toast.error(`上传${ coverType === 'cover' ? '背景封面' : '装饰图片' }失败`, {
description: errorMessage
})
}
}
/* 加载历史封面 */
const loadHistoryCovers = async () => {
try {
const [covers, heterotypics] = await Promise.all([
services.redEnvelope.listCovers('cover'),
services.redEnvelope.listCovers('heterotypic'),
])
setHistoryCoverCovers(covers || [])
setHistoryHeterotypicCovers(heterotypics || [])
} catch {
// 静默失败,不影响主功能
}
}
/* 选择历史封面 */
const handleSelectHistoryCover = (item: UploadImageResponse, type: 'cover' | 'heterotypic') => {
// 检查是否已经选中当前项
const currentId = type === 'cover' ? cover.coverUploadId : cover.heterotypicUploadId
if (currentId === item.id) {
// 取消选择
setCover(prev => ({
...prev,
[type === 'cover' ? 'coverImageUrl' : 'heterotypicImageUrl']: null,
[type === 'cover' ? 'coverUploadId' : 'heterotypicUploadId']: null
}))
toast.success(`已取消选择${ type === 'cover' ? '背景封面' : '装饰图片' }`)
return
}
setCover(prev => ({
...prev,
[type === 'cover' ? 'coverImageUrl' : 'heterotypicImageUrl']: getFileUrl(item.id),
[type === 'cover' ? 'coverUploadId' : 'heterotypicUploadId']: item.id
}))
toast.success(`已选择历史${ type === 'cover' ? '背景封面' : '装饰图片' }`)
}
/* 打开裁剪对话框 */
const handleOpenCropper = (type: 'cover' | 'heterotypic') => {
setCropperType(type)
setIsCropperOpen(true)
}
/* 移除封面 */
const handleRemoveCover = (type: 'cover' | 'heterotypic') => {
setCover(prev => ({
...prev,
[type === 'cover' ? 'coverImageUrl' : 'heterotypicImageUrl']: null,
[type === 'cover' ? 'coverUploadId' : 'heterotypicUploadId']: null
}))
toast.success(`${ type === 'cover' ? '背景封面' : '装饰图片' }已移除`)
}
/* 处理图片加载错误 */
const handleImageError = (id: string, type: 'cover' | 'heterotypic') => {
if (type === 'cover') {
setHistoryCoverCovers(prev => prev.filter(item => item.id !== id))
} else {
setHistoryHeterotypicCovers(prev => prev.filter(item => item.id !== id))
}
}
return (
<div className="space-y-6">
<div className="bg-gradient-to-r from-red-500/10 to-orange-500/10 rounded-lg px-6 py-8">
<div className="max-w-2xl">
<h2 className="text-2xl font-bold mb-4 text-foreground">
发红包
</h2>
<p className="text-muted-foreground mb-6 leading-relaxed">
创建积分红包,支持固定金额和拼手气两种模式。
</p>
<Button
onClick={() => setIsFormOpen(true)}
className="bg-red-500 hover:bg-red-600 font-medium px-6 rounded-md shadow-sm"
>
发红包
</Button>
</div>
</div>
<Dialog open={isFormOpen} onOpenChange={setIsFormOpen}>
<DialogContent className="sm:max-w-2xl md:max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center justify-between pr-8">
<div className="flex items-center gap-2">
<Gift className="size-5 text-red-500" />
发红包
</div>
<Button
variant={showCustomization ? "default" : "ghost"}
size="sm"
onClick={() => setShowCustomization(!showCustomization)}
className={`flex items-center gap-1.5 text-xs transition-colors ${ showCustomization ? "bg-red-500 hover:bg-red-600 text-white" : "text-muted-foreground hover:text-foreground" }`}
>
<Pencil className="size-3.5" />
<span>自定义封面</span>
</Button>
</DialogTitle>
<DialogDescription>
创建积分红包,支持固定金额和拼手气两种模式{showCustomization && ",可添加个性化封面"}。
</DialogDescription>
</DialogHeader>
<div className="grid md:grid-cols-5 gap-8 pt-2">
<div className="space-y-4 md:col-span-3">
<div className="grid grid-cols-3 gap-4">
<div className="grid gap-2">
<Label>红包类型 <span className="text-red-500">*</span></Label>
<Select value={type} onValueChange={(v) => setType(v as RedEnvelopeType)}>
<SelectTrigger className="text-xs w-full shadow-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="random" className="text-xs">拼手气红包</SelectItem>
<SelectItem value="fixed" className="text-xs">普通红包</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="totalAmount">积分总数 <span className="text-red-500">*</span></Label>
<Input
id="totalAmount"
type="number"
step="0.01"
min="0.01"
max={config && parseFloat(config.red_envelope_max_amount) > 0 ? config.red_envelope_max_amount : undefined}
placeholder={config && parseFloat(config.red_envelope_max_amount) > 0 ? `最大 ${ config.red_envelope_max_amount }` : "0.00"}
value={totalAmount}
onChange={(e) => setTotalAmount(e.target.value)}
className="font-mono shadow-none"
disabled={loading}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="totalCount">可领取人数 <span className="text-red-500">*</span></Label>
<Input
id="totalCount"
type="number"
placeholder="最多 10000"
value={totalCount}
onChange={(e) => setTotalCount(e.target.value)}
className="font-mono shadow-none"
disabled={loading}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="greeting">祝福语</Label>
<Textarea
id="greeting"
placeholder="恭喜发财,大吉大利"
value={greeting}
onChange={(e) => setGreeting(e.target.value)}
maxLength={100}
rows={2}
className="resize-none shadow-none"
disabled={loading}
/>
</div>
{showCustomization && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="rounded-lg bg-muted/40 p-4"
onAnimationComplete={() => loadHistoryCovers()}
>
<div className="flex items-center justify-between mb-3">
<Label className="text-sm font-medium">红包封面</Label>
<Button
variant="ghost"
size="icon-sm"
onClick={() => {
setShowCustomization(false)
setCover({
coverImageUrl: null,
heterotypicImageUrl: null,
coverUploadId: null,
heterotypicUploadId: null
})
}}
className="size-6 text-muted-foreground hover:text-foreground"
>
<X className="size-3.5" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">背景封面</Label>
{cover.coverImageUrl ? (
<div className="relative group rounded-lg overflow-hidden ring-1 ring-border/50 aspect-[2/3] w-full">
<Image
src={cover.coverImageUrl}
alt="背景封面"
fill
className="object-cover"
unoptimized
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-all duration-200 flex items-center justify-center gap-1.5 opacity-0 group-hover:opacity-100">
<Button
variant="ghost"
size="icon-sm"
className="size-7 rounded-full bg-white/20 backdrop-blur-sm text-white hover:bg-white/30"
onClick={() => handleOpenCropper('cover')}
>
<Pencil className="size-3" />
</Button>
<Button
variant="ghost"
size="icon-sm"
className="size-7 rounded-full bg-white/20 backdrop-blur-sm text-white hover:bg-white/30"
onClick={() => handleRemoveCover('cover')}
>
<X className="size-3" />
</Button>
</div>
</div>
) : (
<button
onClick={() => handleOpenCropper('cover')}
className="w-full aspect-[2/3] rounded-lg border border-dashed border-border hover:border-foreground/25 bg-background hover:bg-muted/50 transition-colors flex flex-col items-center justify-center gap-2 cursor-pointer"
>
<ImagePlus className="size-5 text-muted-foreground/50" />
<span className="text-[11px] text-muted-foreground">上传背景</span>
</button>
)}
</div>
<div className="space-y-1.5">
<Label className="text-[11px] text-muted-foreground">装饰图片</Label>
{cover.heterotypicImageUrl ? (
<div className="relative group rounded-lg overflow-hidden ring-1 ring-border/50 aspect-[2/3] w-full">
<Image
src={cover.heterotypicImageUrl}
alt="装饰图片"
fill
className="object-cover"
unoptimized
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-all duration-200 flex items-center justify-center gap-1.5 opacity-0 group-hover:opacity-100">
<Button
variant="ghost"
size="icon-sm"
className="size-7 rounded-full bg-white/20 backdrop-blur-sm text-white hover:bg-white/30"
onClick={() => handleOpenCropper('heterotypic')}
>
<Pencil className="size-3" />
</Button>
<Button
variant="ghost"
size="icon-sm"
className="size-7 rounded-full bg-white/20 backdrop-blur-sm text-white hover:bg-white/30"
onClick={() => handleRemoveCover('heterotypic')}
>
<X className="size-3" />
</Button>
</div>
</div>
) : (
<button
onClick={() => handleOpenCropper('heterotypic')}
className="w-full aspect-[2/3] rounded-lg border border-dashed border-border hover:border-foreground/25 bg-background hover:bg-muted/50 transition-colors flex flex-col items-center justify-center gap-2 cursor-pointer"
>
<ImagePlus className="size-5 text-muted-foreground/50" />
<span className="text-[11px] text-muted-foreground">上传装饰</span>
</button>
)}
</div>
</div>
<p className="text-[10px] text-muted-foreground/60 mt-2.5 text-center">推荐 2:3 比例,上传后可裁剪</p>
{(historyCoverCovers.length > 0 || historyHeterotypicCovers.length > 0) && (
<div className="mt-3 pt-3 border-t border-border/50">
<div className="flex items-center gap-1.5 mb-2.5">
<History className="size-3 text-muted-foreground/60" />
<span className="text-[11px] text-muted-foreground/60">历史封面</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
{historyCoverCovers.length > 0 && (
<div className="flex gap-2 overflow-x-auto p-1 no-scrollbar">
{historyCoverCovers.map((item: UploadImageResponse) => (
<button
key={item.id}
onClick={() => handleSelectHistoryCover(item, 'cover')}
className={`relative shrink-0 size-12 rounded-md overflow-hidden ring-1 transition-all ${ cover.coverUploadId === item.id
? 'ring-2 ring-primary scale-105'
: 'ring-border/50 hover:ring-foreground/25'
}`}
>
<Image
src={getFileUrl(item.id) || ''}
alt="背景"
fill
className="object-cover"
unoptimized
onError={() => handleImageError(item.id, 'cover')}
/>
</button>
))}
</div>
)}
</div>
<div>
{historyHeterotypicCovers.length > 0 && (
<div className="flex gap-2 overflow-x-auto p-1 no-scrollbar">
{historyHeterotypicCovers.map((item: UploadImageResponse) => (
<button
key={item.id}
onClick={() => handleSelectHistoryCover(item, 'heterotypic')}
className={`relative shrink-0 size-12 rounded-md overflow-hidden ring-1 transition-all ${ cover.heterotypicUploadId === item.id
? 'ring-2 ring-primary scale-105'
: 'ring-border/50 hover:ring-foreground/25'
}`}
>
<Image
src={getFileUrl(item.id) || ''}
alt="装饰"
fill
className="object-cover"
unoptimized
onError={() => handleImageError(item.id, 'heterotypic')}
/>
</button>
))}
</div>
)}
</div>
</div>
</div>
)}
</motion.div>
)}
{feeInfo && parseFloat(feeInfo.fee) > 0 && (
<div className="text-xs space-y-1.5 p-3 rounded-lg bg-muted/50">
<div className="flex justify-between">
<span className="text-muted-foreground">积分</span>
<span className="font-mono text-muted-foreground">{totalAmount} LDC</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">
费率 ({(feeInfo.rate * 100).toFixed(2)}%)
</span>
<span className="font-mono text-muted-foreground">+{feeInfo.fee} LDC</span>
</div>
<div className="border-t border-border/50 my-0.5" />
<div className="flex justify-between items-center">
<span className="text-foreground font-medium">总计</span>
<span className="font-mono font-semibold text-red-500">{feeInfo.total} LDC</span>
</div>
</div>
)}
</div>
<div className="md:col-span-2 flex items-center justify-center md:justify-end md:pr-8 my-6 md:my-0">
<div className="relative w-52 h-80">
{cover.heterotypicImageUrl && (
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="absolute inset-0 z-0 pointer-events-none flex items-center justify-center"
>
<Image
src={cover.heterotypicImageUrl}
alt="装饰"
fill
className="object-cover"
style={{
transform: 'scale(1.2)',
transformOrigin: 'center center'
}}
unoptimized
/>
</motion.div>
)}
{/* 缩放容器:将 320px 宽的卡片等比缩放以适应 208px (w-52) 的容器 */}
<div className="absolute inset-0 overflow-hidden bg-[#F25542] rounded-lg">
<div className="absolute top-0 left-0 origin-top-left" style={{ width: 320, height: 533, transform: 'scale(0.65)' }}>
<RedEnvelopeCard
status="preview"
coverImage={cover.coverImageUrl}
greeting={greeting || "新年快乐,恭喜发财"}
sender={{
username: user?.username,
avatar_url: user?.avatar_url
}}
className="rounded-xl w-full h-full"
/>
</div>
</div>
</div>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button variant="ghost" onClick={() => setIsFormOpen(false)} disabled={loading} className="h-8 text-xs">
取消
</Button>
<Button
onClick={handleFormSubmit}
disabled={!totalAmount || !totalCount || loading || !!(config && config.red_envelope_max_recipients > 0 && parseInt(totalCount) > config.red_envelope_max_recipients)}
className="bg-red-500 hover:bg-red-600 h-8 text-xs"
>
下一步
</Button>
</div>
</DialogContent>
</Dialog>
<PasswordDialog
isOpen={isPasswordOpen}
onOpenChange={(open) => {
setIsPasswordOpen(open)
if (!open) setIsFormOpen(true)
}}
onConfirm={handleConfirmCreate}
loading={loading}
title="密码验证"
description={
feeInfo && parseFloat(feeInfo.fee) > 0
? `正在创建 ${ totalAmount } LDC 的积分红包,可领取人数 ${ totalCount } 个,服务费率 ${ feeInfo.fee } LDC,总计需要 ${ feeInfo.total } LDC`
: `正在创建 ${ totalAmount } LDC 的积分红包,可领取人数 ${ totalCount } 个`
}
/>
<Dialog open={isResultOpen} onOpenChange={setIsResultOpen}>
<DialogContent className="w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-green-600">
创建成功
</DialogTitle>
<DialogDescription>
复制此链接分享给好友领取红包
</DialogDescription>
</DialogHeader>
<div className="py-2">
<div className="flex gap-2">
<Input
value={resultLink}
readOnly
className="text-xs font-mono shadow-none border-none bg-muted"
/>
<Button
variant="secondary"
size="sm"
onClick={() => handleCopyLink(resultLink, "result")}
>
{copiedId === "result" ? <Check className="size-3" /> : <Copy className="size-3" />}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => window.open(resultLink, "_blank")}
>
<ExternalLink className="size-3" />
</Button>
</div>
</div>
<div className="flex justify-end">
<Button onClick={() => setIsResultOpen(false)} className="h-8 text-xs">
完成
</Button>
</div>
</DialogContent>
</Dialog>
<ImageCropper
isOpen={isCropperOpen}
onOpenChange={setIsCropperOpen}
onCropComplete={handleCropComplete}
coverType={cropperType}
/>
</div>
)
}
/**
* 红包列表组件
*
* 使用通用 ListData 组件展示红包记录
*/
export function RedEnvelopeList({ refreshTrigger }: { refreshTrigger?: number }) {
const fetchEnvelopes = async (params: { page: number, page_size: number, type: string }) => {
const result: RedEnvelopeListResponse = await services.redEnvelope.getList({
page: params.page,
page_size: params.page_size,
type: params.type as 'sent' | 'received',
})
return { list: result.red_envelopes, total: result.total }
}
return (
<ListData
fetchData={fetchEnvelopes}
tabs={[
{ value: 'sent', label: '我发出的', color: 'bg-primary/10 text-primary' },
{ value: 'received', label: '我收到的', color: 'bg-primary/10 text-primary' }
]}
defaultTab="received"
refreshTrigger={refreshTrigger}
/>
)
}
@@ -0,0 +1,138 @@
"use client"
import * as React from "react"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { AllActivity } from "@/components/common/trade/all-activity"
import { Community } from "@/components/common/trade/community"
import { Payment } from "@/components/common/trade/payment"
import { Receive } from "@/components/common/trade/receive"
import { TradeTable } from "@/components/common/trade/trade-table"
import { Online } from "@/components/common/trade/online"
import { RedEnvelope, RedEnvelopeList } from "@/components/common/trade/red-envelope"
import { TransactionProvider } from "@/contexts/transaction-context"
import services from "@/lib/services"
/** 标签触发器样式 */
const TAB_TRIGGER_STYLES =
"data-[state=active]:bg-transparent " +
"data-[state=active]:shadow-none " +
"data-[state=active]:border-0 " +
"data-[state=active]:border-b-2 " +
"data-[state=active]:border-indigo-500 " +
"bg-transparent " +
"rounded-none " +
"border-0 " +
"border-b-2 " +
"border-transparent " +
"px-0 " +
"text-sm " +
"font-bold " +
"text-muted-foreground " +
"data-[state=active]:text-indigo-500 " +
"-mb-[2px] " +
"relative " +
"hover:text-foreground " +
"transition-colors " +
"flex-none"
/** 标签值类型 */
type TabValue = 'receive' | 'payment' | 'community' | 'online' | 'all' | 'redenvelope'
/** 页面组件映射表 */
const PAGE_COMPONENTS: Record<TabValue, React.ComponentType> = {
receive: Receive,
payment: Payment,
community: Community,
online: Online,
redenvelope: RedEnvelope,
all: AllActivity,
}
/**
* 活动交易主页面组件
*
* 负责组装活动交易中心的各个子组件
*/
export function TradeMain() {
const [activeTab, setActiveTab] = React.useState<TabValue>('receive')
const [mounted, setMounted] = React.useState(false)
const [refreshKey, setRefreshKey] = React.useState(0)
React.useEffect(() => {
setMounted(true)
}, [])
const [redEnvelopeEnabled, setRedEnvelopeEnabled] = React.useState(false)
React.useEffect(() => {
/* 获取红包状态 */
services.config.getPublicConfig()
.then(res => setRedEnvelopeEnabled(res.red_envelope_enabled))
.catch(() => setRedEnvelopeEnabled(false))
}, [])
/* 获取活动类型 */
const getOrderType = (tab: TabValue) => {
if (tab === 'all' || tab === 'redenvelope') return undefined
return tab
}
/* 渲染活动页面内容 */
const renderPageContent = () => {
if (activeTab === 'redenvelope') {
return <RedEnvelope onSuccess={() => setRefreshKey(prev => prev + 1)} />
}
const Component = PAGE_COMPONENTS[activeTab]
return Component ? <Component /> : null
}
if (!mounted) {
return (
<div className="py-6">
<h1 className="text-2xl font-semibold mb-4">积分活动</h1>
<div className="animate-pulse space-y-4">
<div className="h-10 bg-muted rounded" />
<div className="h-64 bg-muted rounded" />
</div>
</div>
)
}
return (
<TransactionProvider defaultParams={{ page_size: 20 }}>
<div className="py-6">
<h1 className="text-2xl font-semibold mb-4">积分活动</h1>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as TabValue)}
className="w-full"
>
<TabsList className="flex p-0 gap-4 rounded-none w-full bg-transparent justify-start border-b border-border overflow-x-auto overflow-y-hidden">
<TabsTrigger value="receive" className={TAB_TRIGGER_STYLES}>积分收益</TabsTrigger>
<TabsTrigger value="payment" className={TAB_TRIGGER_STYLES}>积分消耗</TabsTrigger>
<TabsTrigger value="community" className={TAB_TRIGGER_STYLES}>社区划转</TabsTrigger>
<TabsTrigger value="online" className={TAB_TRIGGER_STYLES}>在线流转</TabsTrigger>
{redEnvelopeEnabled && (
<TabsTrigger value="redenvelope" className={TAB_TRIGGER_STYLES}>积分红包</TabsTrigger>
)}
<TabsTrigger value="all" className={TAB_TRIGGER_STYLES}>所有活动</TabsTrigger>
</TabsList>
<div className="pt-2 space-y-8">
{renderPageContent()}
<div className="space-y-4">
<h2 className="font-semibold">活动记录</h2>
{activeTab === 'redenvelope' ? (
<RedEnvelopeList refreshTrigger={refreshKey} />
) : (
<TradeTable key={activeTab} type={getOrderType(activeTab)} />
)}
</div>
</div>
</Tabs>
</div>
</TransactionProvider>
)
}
@@ -0,0 +1,148 @@
import * as React from "react"
import { TransactionTableList } from "@/components/common/general/table-data"
import { TableFilter, mapDisplayStatusToQuery, type DisplayOrderStatus, type SearchValues } from "@/components/common/general/table-filter"
import { TransactionProvider, useTransaction } from "@/contexts/transaction-context"
import { DEFAULT_ORDER_TYPES, type OrderType, type TransactionQueryParams } from "@/lib/services"
import { formatLocalDate } from "@/lib/utils"
/**
* 获取默认日期范围(最近30天)
*/
function getDefaultDateRange() {
const today = new Date()
today.setHours(0, 0, 0, 0)
const from = new Date(today)
from.setDate(from.getDate() - 29)
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
return { from, to: tomorrow }
}
/**
* 活动表格组件
*
* 支持类型、状态、时间范围筛选的活动记录显示(支持分页)
*/
export function TradeTable({ type }: { type?: OrderType }) {
/* 获取时间范围 */
const { from, to: tomorrow } = getDefaultDateRange()
const startTime = formatLocalDate(from)
const endTime = formatLocalDate(tomorrow)
return (
<TransactionProvider defaultParams={{ page_size: 20, startTime, endTime }}>
<TransactionList initialType={type} />
</TransactionProvider>
)
}
/**
* 活动列表组件
*
* 显示交用户活动记录
*/
function TransactionList({ initialType }: { initialType?: OrderType }) {
const {
transactions,
total,
currentPage,
totalPages,
pageSize,
loading,
error,
fetchTransactions,
goToPage,
setPageSize: setPageSizeHandler,
clearCacheAndRefresh,
} = useTransaction()
const [selectedTypes, setSelectedTypes] = React.useState<OrderType[]>(initialType ? [initialType] : [])
const [selectedStatus, setSelectedStatus] = React.useState<DisplayOrderStatus | null>(null)
const [selectedQuickSelection, setSelectedQuickSelection] = React.useState<string | null>("最近 1 个月")
const [dateRange, setDateRange] = React.useState<{ from: Date; to: Date } | null>(getDefaultDateRange)
const [selectedSearch, setSelectedSearch] = React.useState<SearchValues>({})
const buildQueryParams = React.useCallback((page: number): TransactionQueryParams => {
const stateQuery = mapDisplayStatusToQuery(selectedStatus)
return {
page,
page_size: pageSize,
types: selectedTypes.length > 0 ? selectedTypes : DEFAULT_ORDER_TYPES,
...stateQuery,
startTime: dateRange ? formatLocalDate(dateRange.from) : undefined,
endTime: dateRange ? (() => {
const endDate = new Date(dateRange.to)
endDate.setDate(endDate.getDate() + 1)
return formatLocalDate(endDate)
})() : undefined,
id: selectedSearch.id || undefined,
order_name: selectedSearch.order_name || undefined,
payer_username: selectedSearch.payer_username || undefined,
payee_username: selectedSearch.payee_username || undefined,
}
}, [dateRange, pageSize, selectedSearch, selectedStatus, selectedTypes])
/* 清空所有筛选 */
const clearAllFilters = () => {
setSelectedTypes(initialType ? [initialType] : [])
setSelectedStatus(null)
setDateRange(null)
setSelectedQuickSelection(null)
setSelectedSearch({})
}
/* 当筛选条件改变时,重新加载数据 */
React.useEffect(() => {
fetchTransactions(buildQueryParams(1))
}, [buildQueryParams, fetchTransactions])
/* 当initialType改变时,更新筛选状态 */
React.useEffect(() => {
if (initialType) {
setSelectedTypes([initialType])
} else {
setSelectedTypes([])
}
}, [initialType])
return (
<div className="flex flex-col space-y-4">
<TableFilter
enabledFilters={{
type: initialType === undefined,
status: true,
timeRange: true,
search: true
}}
selectedTypes={selectedTypes}
selectedStatus={selectedStatus}
selectedTimeRange={dateRange}
selectedQuickSelection={selectedQuickSelection}
onTypeChange={setSelectedTypes}
onStatusChange={setSelectedStatus}
onTimeRangeChange={setDateRange}
onQuickSelectionChange={setSelectedQuickSelection}
onSearch={setSelectedSearch}
searchValues={selectedSearch}
onClearAll={clearAllFilters}
enablePagination={true}
currentPage={currentPage}
totalPages={totalPages}
pageSize={pageSize}
total={total}
onPageChange={goToPage}
onPageSizeChange={setPageSizeHandler}
onRefresh={clearCacheAndRefresh}
loading={loading}
/>
<TransactionTableList
loading={loading}
error={error}
transactions={transactions}
onRetry={() => fetchTransactions(buildQueryParams(1))}
/>
</div>
)
}
@@ -0,0 +1,146 @@
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, Key, Terminal } from "lucide-react";
export interface DeveloperSectionProps {
className?: string;
}
/**
* Developer Section - 开发者体验展示
*/
export const DeveloperSection = React.memo(function DeveloperSection({ className }: DeveloperSectionProps) {
const [copied, setCopied] = React.useState(false);
const codeContent = `curl https://credit.linux.do/epay/submit.php \\
-u sk_live_...: \\
-d amount=1000 \\
-d currency="cny" \\
-d description="Pro Plan"`;
const onCopy = async () => {
try {
await navigator.clipboard.writeText(codeContent);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback for older browsers
const textArea = document.createElement("textarea");
textArea.value = codeContent;
textArea.style.position = "fixed";
textArea.style.left = "-9999px";
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
return (
<section className={cn("relative z-10 w-full min-h-screen flex items-center justify-center px-6 overflow-hidden", className)}>
<div className="absolute inset-0 pointer-events-none">
<div className="absolute inset-0 [mask-image:linear-gradient(to_bottom,transparent,black_20%,black_80%,transparent)]">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] max-w-[90vw] max-h-[90vh] bg-purple-500/10 rounded-full blur-[120px] animate-pulse" />
</div>
</div>
<div className="container mx-auto max-w-7xl grid lg:grid-cols-2 gap-12 lg:gap-20 items-center relative z-10">
<motion.div
initial={{ opacity: 0, x: -30 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8 }}
className="order-2 lg:order-1 relative"
>
<div className="relative overflow-hidden rounded-xl border border-white/20 bg-black backdrop-blur-xl shadow-2xl max-w-full">
<div className="flex items-center px-4 py-3 border-b border-white/20 w-full">
<div className="flex-1 flex gap-2">
<div className="w-3 h-3 rounded-full bg-[#ff5f56]" />
<div className="w-3 h-3 rounded-full bg-[#ffbd2e]" />
<div className="w-3 h-3 rounded-full bg-[#27c93f]" />
</div>
<div className="flex-none text-xs text-muted-foreground font-mono flex items-center gap-1">
<Terminal className="w-3 h-3" />
bash
</div>
<div className="flex-1" />
</div>
<div className="p-6 overflow-x-auto relative group">
<div className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" className="h-8 w-8 text-neutral-400 hover:text-white" onClick={onCopy}>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</Button>
</div>
<pre className="text-xs sm:text-sm font-mono text-neutral-300 leading-relaxed whitespace-pre-wrap break-all sm:whitespace-pre sm:break-normal overflow-x-auto">
<code className="block">
<span className="text-purple-400">curl</span> <span className="text-green-400">https://credit.linux.do/epay/submit.php</span> \{'\n'}
{' '}-u <span className="text-yellow-400">sk_live_...:</span> \{'\n'}
{' '}-d <span className="text-blue-400">amount</span>=<span className="text-orange-400">1000</span> \{'\n'}
{' '}-d <span className="text-blue-400">currency</span>=<span className="text-green-400">&quot;cny&quot;</span> \{'\n'}
{' '}-d <span className="text-blue-400">description</span>=<span className="text-green-400">&quot;Pro Plan&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" />
</motion.div>
<div className="flex flex-col justify-center space-y-8 order-1 lg:order-2">
<motion.div
initial={{ opacity: 0, x: 30 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8 }}
>
<h2 className="text-3xl md:text-5xl font-bold tracking-tight text-foreground leading-[1.1] mb-6">
简单直接,<br />
开发者快速集成
</h2>
<p className="text-muted-foreground text-lg leading-relaxed mb-8">
标准化的 RESTful API 接口,清晰的错误提示,完善的调试工具。无论您使用什么编程语言,只需几行代码,即可完成积分服务功能接入。
</p>
<ul className="space-y-4 mb-8">
{[
"RESTful API 接口,语义清晰",
"完善的 Webhook 回调通知",
"简单调试,开发测试零成本",
"完善的文档和示例代码"
].map((item, i) => (
<li key={i} className="flex items-center gap-3 text-sm text-foreground/80">
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center text-primary">
<Check className="w-3.5 h-3.5" />
</div>
{item}
</li>
))}
</ul>
<div className="flex flex-wrap gap-4">
<Link href="/docs/api">
<Button variant="secondary" className="rounded-full text-xs hover:bg-muted-foreground/10">
<Book className="w-3 h-3" />
API 文档
</Button>
</Link>
<Link href="/merchant">
<Button variant="default" className="rounded-full text-xs hover:bg-primary/70">
<Key className="w-3 h-3" />
获取 API Key
</Button>
</Link>
</div>
</motion.div>
</div>
</div>
</section>
);
});
@@ -0,0 +1,99 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import Link from "next/link";
import { Github, LucideIcon } from "lucide-react";
export interface FooterSectionProps {
className?: string;
}
/**
* Footer Section - 页脚
*/
export const FooterSection = React.memo(function FooterSection({ className }: FooterSectionProps) {
return (
<footer className={cn("relative z-10 w-full bg-transparent border-t border-white/10 mt-0 backdrop-blur-sm", className)}>
<div className="container mx-auto max-w-7xl px-6 py-20 lg:py-32">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-12 lg:gap-8 mb-20">
<div className="lg:col-span-2 space-y-6">
<Link href="/" className="flex items-center gap-2">
<div className="w-10 h-10 p-2 rounded bg-primary text-sm text-primary-foreground flex items-center justify-center font-bold">
LDC
</div>
<span className="text-2xl font-bold tracking-tight text-foreground">LINUX DO Credit</span>
</Link>
<p className="text-muted-foreground text-base leading-relaxed max-w-sm">
专为 LINUX DO 社区打造的积分服务基础服务设施,快速集成、全球覆盖、安全可靠,致力于打造积分生态,助您轻松流通积分。
</p>
<div className="flex gap-4 pt-2">
<SocialLink icon={Github} href="https://github.com/linux-do/credit/" />
</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="https://github.com/linux-do/credit/commits/master/">更新日志</FooterLink></li>
<li><FooterLink href="https://github.com/linux-do/credit/graphs/contributors">开源贡献</FooterLink></li>
<li><FooterLink href="http://github.com/linux-do/credit/releases">发行版本</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://linux.do/t/topic/1365853">积分脚本</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://linux.do/">LINUX DO</FooterLink></li>
<li><FooterLink href="https://wiki.linux.do/">LINUX DO Wiki</FooterLink></li>
<li><FooterLink href="https://cdk.linux.do/">LINUX DO CDK</FooterLink></li>
</ul>
</div>
</div>
<div className="pt-8 border-t border-border flex flex-col md:flex-row justify-between items-center gap-4 text-sm text-muted-foreground">
<p>© 2026 LINUX DO Credit. All rights reserved.</p>
<div className="flex gap-8">
<Link href="/docs/privacy-policy" className="hover:text-foreground transition-colors">隐私政策</Link>
<Link href="/docs/terms-of-service" className="hover:text-foreground transition-colors">服务条款</Link>
</div>
</div>
</div>
<div className="absolute bottom-0 left-0 w-full overflow-hidden pointer-events-none opacity-[0.02]">
<div className="text-[12vw] 2xl:text-[180px] font-black leading-none text-foreground whitespace-nowrap select-none text-center transform translate-y-1/3 transition-all duration-700">
LINUX DO Credit
</div>
</div>
</footer>
);
});
function SocialLink({ icon: Icon, href }: { icon: LucideIcon, href: string }) {
return (
<Link href={href} className="w-10 h-10 rounded-full bg-muted/50 flex items-center justify-center text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-all duration-300">
<Icon className="w-5 h-5" />
</Link>
)
}
function FooterLink({ href, children }: { href: string, children: React.ReactNode }) {
return (
<Link href={href} className="hover:text-foreground transition-colors flex items-center group">
<span className="relative">
{children}
<span className="absolute left-0 -bottom-0.5 w-0 h-px bg-foreground transition-all duration-300 group-hover:w-full" />
</span>
</Link>
)
}
+193
View File
@@ -0,0 +1,193 @@
import * as React from "react";
import Link from "next/link";
import { motion } from "motion/react";
import { ArrowRight, Zap, Shield, Globe, CreditCard, Wallet } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { CountingNumber } from "@/components/animate-ui/primitives/texts/counting-number";
export interface HeroSectionProps {
className?: string;
}
/**
* Hero Section - 首页 Hero 展示
*/
export const HeroSection = React.memo(function HeroSection({ className }: HeroSectionProps) {
const [balance, setBalance] = React.useState(12450.0);
const [income, setIncome] = React.useState(240.0);
React.useEffect(() => {
const interval = setInterval(() => {
setBalance(Math.random() * (65123.45 - (-65)) + (-65));
setIncome(Math.random() * (656.56 - (-65.65)) + (-65.65));
}, 6565);
return () => clearInterval(interval);
}, []);
return (
<section className={cn("w-full", className)}>
<motion.div
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{
delay: 0.3,
duration: 0.8,
ease: "easeInOut",
}}
className="relative z-10 w-full h-screen flex flex-col justify-center px-6"
>
<div className="container mx-auto max-w-7xl grid lg:grid-cols-2 gap-12 lg:gap-20 items-center">
<div className="max-w-5xl pt-20 lg:pt-0">
<motion.h1
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.1, ease: [0.16, 1, 0.3, 1] }}
className="text-4xl md:text-5xl lg:text-6xl font-extrabold tracking-tight leading-[1.1] mb-6 text-foreground"
>
LINUX DO Credit <br />
<span className="bg-clip-text text-primary">
让积分流通更简单
</span>
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="text-sm md:text-base text-muted-foreground max-w-xl leading-relaxed mb-10"
>
专为 LINUX DO 社区打造的积分流通基础设施
<br className="hidden md:block" />
快速集成、全球覆盖、安全可靠,助您轻松流通积分。
</motion.p>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.3, ease: [0.16, 1, 0.3, 1] }}
className="flex flex-col sm:flex-row items-center gap-4"
>
<Link href="/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"
>
立即开始
<ArrowRight className="size-4" />
</Button>
</Link>
<Link href="/docs/how-to-use" className="w-full sm:w-auto">
<Button
variant="secondary"
size="lg"
className="w-full rounded-full font-medium active:scale-95"
>
了解更多
</Button>
</Link>
</motion.div>
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: 0.5 }}
className="mt-16 flex flex-wrap gap-8 text-sm font-medium text-muted-foreground border-t border-border/50 pt-8"
>
<div className="flex items-center gap-2">
<Zap className="w-5 h-5 text-yellow-500" />
<span>极速到账</span>
</div>
<div className="flex items-center gap-2">
<Globe className="w-5 h-5 text-blue-500" />
<span>全球覆盖</span>
</div>
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-green-500" />
<span>安全加密</span>
</div>
</motion.div>
</div>
<div className="hidden lg:block relative h-full min-h-[500px] w-full">
<motion.div
initial={{ opacity: 0, x: 50, rotate: -5 }}
whileInView={{ opacity: 1, x: 0, rotate: 0 }}
viewport={{ once: true }}
transition={{ duration: 1, delay: 0.2, ease: "easeOut" }}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md aspect-square"
>
<div className="absolute top-0 right-0 w-64 h-64 bg-purple-500/30 rounded-full blur-3xl animate-pulse" />
<div className="absolute bottom-0 left-0 w-64 h-64 bg-blue-500/30 rounded-full blur-3xl animate-pulse delay-75" />
<div className="relative z-10 w-full h-64 bg-background/40 backdrop-blur-2xl border border-white/10 rounded-3xl shadow-2xl p-6 flex flex-col justify-between transform transition-transform hover:scale-[1.02] duration-500">
<div className="flex gap-4 items-center">
<CreditCard className="size-6" />
<span>LINUX DO Credit</span>
</div>
<div className="space-y-4">
<div className="space-y-2">
<div className="h-2 w-24 bg-white/10 rounded-full" />
<div className="h-2 w-16 bg-white/10 rounded-full" />
</div>
<div className="flex justify-between items-end">
<div>
<p className="text-sm text-muted-foreground mb-1">Total Balance</p>
<div className="text-3xl font-bold tracking-tight text-foreground flex items-center gap-1">
<span>LDC</span>
<CountingNumber
number={balance}
decimalPlaces={2}
thousandSeparator=","
initiallyStable={true}
/>
</div>
</div>
<div className="h-8 w-8 rounded-full bg-primary" />
</div>
</div>
</div>
<motion.div
animate={{ y: [0, -20, 0] }}
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}
className="absolute -top-6 -right-6 z-20 bg-background/60 backdrop-blur-xl border border-white/20 p-4 rounded-2xl shadow-xl"
>
<div className="flex items-center gap-3">
<div className={cn(
"w-10 h-10 rounded-full flex items-center justify-center transition-colors duration-500",
income < 0 ? "bg-red-500/20 text-red-500" : "bg-green-500/20 text-green-500"
)}>
<Wallet className="size-5" />
</div>
<div>
<p className="text-xs text-muted-foreground">Income</p>
<div className="text-sm font-bold flex items-center gap-1">
<span>{income < 0 ? "-" : "+"} LDC</span>
<CountingNumber
number={Math.abs(income)}
decimalPlaces={2}
thousandSeparator=","
initiallyStable={true}
/>
</div>
</div>
</div>
</motion.div>
</motion.div>
</div>
</div>
</motion.div>
</section>
);
});
@@ -0,0 +1,97 @@
/**
* 圣诞节主题装饰组件归档
* 包含圣诞树、圣诞帽图标以及下雪效果的集成组件
*
* 使用示例:
* ```tsx
* import { ChristmasDecorations } from "@/components/layout/avater-style/Christmas"
*
* function MyComponent() {
* const [showChristmas, setShowChristmas] = useState(false)
*
* return (
* <>
* {showChristmas && <ChristmasDecorations.SnowEffect />}
* <div onClick={() => setShowChristmas(!showChristmas)}>
* <ChristmasDecorations.Hat className="w-8 h-8" />
* <ChristmasDecorations.Tree
* className="w-12 h-12"
* show={showChristmas}
* />
* </div>
* </>
* )
* }
* ```
*/
import { cn } from "@/lib/utils"
import { SnowEffect } from "@/components/ui/snow-effect"
/**
* 圣诞树图标组件
*/
export const ChristmasTree = ({
className,
show = true
}: {
className?: string
show?: boolean
}) => (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={cn(
"transition-all duration-500 ease-out",
show ? "opacity-90 scale-100 translate-y-0" : "opacity-0 scale-50 translate-y-4",
className
)}
>
<rect x="10" y="18" width="4" height="4" fill="#92400E" rx="0.5" />
<path d="M12 4L8 9H16L12 4Z" fill="#15803D" />
<path d="M12 8L7 14H17L12 8Z" fill="#16A34A" />
<path d="M12 12L6 19H18L12 12Z" fill="#22C55E" />
<circle cx="10" cy="10" r="0.8" fill="#EF4444" />
<circle cx="14" cy="11" r="0.8" fill="#F59E0B" />
<circle cx="9" cy="15" r="0.8" fill="#3B82F6" />
<circle cx="15" cy="16" r="0.8" fill="#EF4444" />
<circle cx="12" cy="14" r="0.8" fill="#F59E0B" />
<path d="M12 2L12.5 3.5L14 4L12.5 4.5L12 6L11.5 4.5L10 4L11.5 3.5L12 2Z" fill="#FBBF24" />
</svg>
)
/**
* 圣诞帽图标组件
*/
export const ChristmasHat = ({ className }: { className?: string }) => (
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M12 3C12 3 5 10 5 15C5 17.5 7 19 8 19H16C17 19 19 17.5 19 15C19 10 12 3 12 3Z"
fill="#EF4444"
stroke="#B91C1C"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="12" cy="3" r="2.5" fill="white" stroke="#E5E7EB" strokeWidth="1.5" />
<rect x="4" y="16" width="16" height="5" rx="2.5" fill="white" stroke="#E5E7EB" strokeWidth="1.5" />
</svg>
)
/**
* 圣诞节装饰集合
* 包含所有圣诞节主题组件的命名空间
*/
export const ChristmasDecorations = {
Tree: ChristmasTree,
Hat: ChristmasHat,
SnowEffect: SnowEffect,
}
export default ChristmasDecorations
@@ -0,0 +1,121 @@
/**
* 元旦/新年主题装饰组件归档
* 包含灯笼、福字以及烟花效果的集成组件
*
* 使用示例:
* ```tsx
* import { NewYearDecorations } from "@/components/layout/avater-style/NewYear"
*
* function MyComponent() {
* const [showNewYear, setShowNewYear] = useState(false)
*
* return (
* <>
* {showNewYear && <NewYearDecorations.FireworksEffect />}
* <div onClick={() => setShowNewYear(!showNewYear)}>
* <NewYearDecorations.Lantern className="w-8 h-8" />
* <NewYearDecorations.Fu
* className="w-12 h-12"
* show={showNewYear}
* />
* </div>
* </>
* )
* }
* ```
*/
import { cn } from "@/lib/utils"
import { FireworksEffect } from "@/components/ui/fireworks-effect"
/**
* 红灯笼组件 - 默认头像装饰
*/
export const Lantern = ({ className }: { className?: string }) => (
<svg
viewBox="0 0 1024 1024"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M512 80V160" stroke="#FFD700" strokeWidth="32" strokeLinecap="round" />
<ellipse cx="512" cy="420" rx="300" ry="260" fill="#D92424" />
<ellipse cx="512" cy="420" rx="300" ry="260" stroke="#961C1C" strokeWidth="10" />
<path d="M340 180H684C700 180 712 192 712 208V220H312V208C312 192 324 180 340 180Z" fill="#FFD700" />
<path d="M340 620H684C700 620 712 632 712 648V660H312V648C312 632 324 620 340 620Z" fill="#FFD700" />
<path d="M512 160C512 160 620 250 620 420C620 590 512 680 512 680" stroke="#FFD700" strokeWidth="20" strokeLinecap="round" strokeOpacity="0.4" />
<path d="M512 160C512 160 404 250 404 420C404 590 512 680 512 680" stroke="#FFD700" strokeWidth="20" strokeLinecap="round" strokeOpacity="0.4" />
<path d="M512 160V680" stroke="#FFD700" strokeWidth="20" strokeOpacity="0.4" />
<circle cx="512" cy="420" r="80" fill="#FFD700" fillOpacity="0.9" />
<text x="512" y="450" fontSize="80" fill="#D92424" textAnchor="middle" fontWeight="bold" fontFamily="serif">福</text>
<path d="M512 660V720" stroke="#FFD700" strokeWidth="20" />
<path d="M480 720H544" stroke="#D92424" strokeWidth="40" strokeLinecap="round" />
<path d="M492 740V900" stroke="#D92424" strokeWidth="16" strokeLinecap="round" />
<path d="M512 740V940" stroke="#D92424" strokeWidth="16" strokeLinecap="round" />
<path d="M532 740V900" stroke="#D92424" strokeWidth="16" strokeLinecap="round" />
</svg>
)
/**
* "福"字挂件 - 点击出现的装饰
* 一个菱形的福字牌,使用文字渲染确保准确
*/
export const FuCharacter = ({
className,
show = true
}: {
className?: string
show?: boolean
}) => (
<svg
viewBox="0 0 100 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={cn(
"transition-all duration-500 ease-out origin-center",
show ? "opacity-100 scale-100 rotate-0" : "opacity-0 scale-50 rotate-[-15deg]",
className
)}
>
<path d="M50 0V15" stroke="#F59E0B" strokeWidth="2" />
<g transform="rotate(25 50 50)">
<rect x="20" y="20" width="60" height="60" rx="2" fill="#D92424" stroke="#F59E0B" strokeWidth="1.5" />
<rect x="23" y="23" width="54" height="54" rx="1" stroke="#F59E0B" strokeWidth="1" strokeOpacity="0.5" strokeDasharray="4 2" fill="none" />
<text
x="50"
y="60"
fontSize="32"
fill="#FFD700"
textAnchor="middle"
fontWeight="bold"
fontFamily="'YouYuan', 'Yuanti SC', 'STYuanti', 'HanyiYuanti', 'Comic Sans MS', sans-serif"
style={{ letterSpacing: "0px" }}
transform="rotate(130 50 50)"
>
福
</text>
</g>
<path d="M50 85V95" stroke="#D92424" strokeWidth="3" />
<circle cx="50" cy="95" r="2" fill="#F59E0B" />
<path d="M50 95L47 100M50 95L53 100" stroke="#D92424" strokeWidth="1" />
</svg>
)
/**
* 元旦/新年装饰集合
*/
export const NewYearDecorations = {
Lantern,
Fu: FuCharacter,
FireworksEffect,
}
export default NewYearDecorations
@@ -0,0 +1,48 @@
import { AvatarDecorationTheme } from "./types"
import { ChristmasDecorations } from "./Christmas"
import { NewYearDecorations } from "./NewYear"
export const DecorationThemes: Record<string, AvatarDecorationTheme> = {
christmas: {
key: "christmas",
MainDecoration: ChristmasDecorations.Hat,
InteractionDecoration: ChristmasDecorations.Tree,
BackgroundEffect: ChristmasDecorations.SnowEffect,
smallMainDecorationConfig: {
className: "absolute -top-1 -left-1 w-3 h-3 z-20 rotate-[-20deg] drop-shadow-sm"
},
largeMainDecorationConfig: {
className: "absolute -top-2 -left-1 w-5 h-5 z-20 rotate-[-20deg] drop-shadow-sm"
},
interactionDecorationConfig: {
className: "absolute -top-2 right-2 w-12 h-12 z-0 rotate-[10deg] transition-all duration-500 ease-out"
}
},
newyear: {
key: "newyear",
MainDecoration: NewYearDecorations.Lantern,
InteractionDecoration: NewYearDecorations.Fu,
BackgroundEffect: NewYearDecorations.FireworksEffect,
smallMainDecorationConfig: {
className: "absolute -top-2 -right-1 w-8 h-8 z-20 rotate-[10deg] drop-shadow-md pointer-events-none"
},
largeMainDecorationConfig: {
className: "absolute -top-3 -right-3 w-8 h-8 z-20 rotate-[10deg] drop-shadow-md pointer-events-none"
},
interactionDecorationConfig: {
className: "absolute top-6 left-4 w-10 h-10 z-20 -rotate-[5deg] transition-all duration-500 ease-out"
}
},
none: {
key: "none",
MainDecoration: () => null,
InteractionDecoration: () => null,
}
}
/* 可选值: 'christmas', 'newyear', 'none' */
export const CURRENT_THEME_KEY: string = "none"
export const getCurrentTheme = (): AvatarDecorationTheme => {
return DecorationThemes[CURRENT_THEME_KEY] || DecorationThemes.none
}
@@ -0,0 +1,17 @@
import { ComponentType } from "react"
export interface AvatarDecorationTheme {
key: string
MainDecoration: ComponentType<{ className?: string }>
InteractionDecoration: ComponentType<{ className?: string; show?: boolean }>
BackgroundEffect?: ComponentType
smallMainDecorationConfig?: {
className?: string
}
largeMainDecorationConfig?: {
className?: string
}
interactionDecorationConfig?: {
className?: string
}
}
+143
View File
@@ -0,0 +1,143 @@
import * as React from "react"
import { FileTextIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
/**
* 空状态展示组件属性
* 用于统一显示无数据、无内容等空状态
*/
export interface EmptyStateProps extends React.ComponentProps<"div"> {
title?: string
description?: string
icon?: React.ComponentType<{ className?: string }>
actionText?: string
onAction?: () => void
iconSize?: "sm" | "md" | "lg"
}
/**
* 空状态展示组件
* 用于统一显示无数据、无内容等空状态
*
* @example
* ```tsx
* // 基础用法
* <EmptyState
* title="暂无数据"
* description="当前没有任何记录"
* />
*
* // 带操作按钮
* <EmptyState
* title="暂无交易记录"
* description="还没有任何交易"
* actionText="创建交易"
* onAction={() => router.push('/create')}
* />
*
* // 自定义图标
* <EmptyState
* icon={BarChartIcon}
* description="未发现提现记录"
* />
* ```
* @param {EmptyStateProps} props - 空状态展示组件属性
* @returns {React.ReactNode} 空状态展示组件
*/
export function EmptyState({
title,
description,
icon: Icon = FileTextIcon,
actionText,
onAction,
className,
iconSize = "md",
}: EmptyStateProps) {
const iconSizes = { sm: "size-10", md: "size-12", lg: "size-16" }
const iconInnerSizes = { sm: "size-5", md: "size-6", lg: "size-8" }
return (
<div className={cn("flex flex-col items-center justify-center py-12 text-center", className)}>
<div className={cn(
"rounded-full bg-muted flex items-center justify-center mb-4",
iconSizes[iconSize],
)}>
<Icon className={cn("text-muted-foreground", iconInnerSizes[iconSize])} />
</div>
{title && (
<h3 className="text-base font-medium mb-1">
{title}
</h3>
)}
{description && (
<p className="text-sm text-muted-foreground max-w-md">
{description}
</p>
)}
{onAction && actionText && (
<Button
onClick={onAction}
variant="outline"
className="mt-4"
>
{actionText}
</Button>
)}
</div>
)
}
/**
* 带边框的空状态组件
* 适合在卡片或容器内使用
*
* @example
* ```tsx
* <EmptyStateWithBorder
* title="暂无数据"
* description="当前没有任何记录"
* />
* ```
* @param {EmptyStateProps} props - 空状态展示组件属性
* @returns {React.ReactNode} 带边框的空状态组件
*/
export function EmptyStateWithBorder(props: EmptyStateProps) {
return (
<div className="border border-dashed rounded-lg">
<EmptyState {...props} />
</div>
)
}
/**
* 简化版空状态组件(内联使用)
* 适合在较小的区域显示
*
* @example
* ```tsx
* <EmptyInline
* message="暂无数据"
* icon={FileTextIcon}
* />
* ```
* @param {Pick<EmptyStateProps, 'icon' | 'className'> & { message?: string }} props - 简化版空状态组件属性
* @returns {React.ReactNode} 简化版空状态组件
*/
export function EmptyInline({
message = "暂无数据",
icon: Icon = FileTextIcon,
className,
}: Pick<EmptyStateProps, 'icon' | 'className'> & { message?: string }) {
return (
<div className={cn("flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground", className)}>
<Icon className="size-4" />
<span>{message}</span>
</div>
)
}
+159
View File
@@ -0,0 +1,159 @@
import * as React from "react"
import { AlertCircle } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
/**
* 错误展示组件属性
* 用于统一显示加载失败、请求失败等错误状态
*/
export interface ErrorDisplayProps {
title?: string
message?: string
error?: Error | null
onRetry?: () => void
retryText?: string
icon?: React.ComponentType<{ className?: string }>
className?: string
showStack?: boolean
}
/**
* 错误展示组件
* 用于统一显示加载失败、请求失败等错误状态
*
* @example
* ```tsx
* // 基础用法
* <ErrorDisplay
* title="加载失败"
* message="无法获取数据,请稍后重试"
* />
*
* // 带重试按钮
* <ErrorDisplay
* error={error}
* onRetry={() => refetch()}
* />
*
* // 自定义样式
* <ErrorDisplay
* title="网络错误"
* message="请检查您的网络连接"
* className="min-h-[400px]"
* />
* ```
* @param {ErrorDisplayProps} props - 错误展示组件属性
* @returns {React.ReactNode} 错误展示组件
*/
export function ErrorDisplay({
title = "加载失败",
message,
error,
onRetry,
retryText = "重试",
icon: Icon = AlertCircle,
className,
showStack = false,
}: ErrorDisplayProps) {
const errorMessage = message || error?.message || "发生未知错误,请稍后重试"
return (
<div className={cn("flex flex-col items-center justify-center py-12 text-center", className)}>
<div className="size-12 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center mb-4">
<Icon className="size-6 text-red-600 dark:text-red-400" />
</div>
<h3 className="text-lg font-semibold mb-2">{title}</h3>
<p className="text-sm text-muted-foreground max-w-md mb-4">{errorMessage}</p>
{onRetry && (
<Button
onClick={onRetry}
variant="outline"
className="mt-2"
>
{retryText}
</Button>
)}
{showStack && error?.stack && process.env.NODE_ENV === 'development' && (
<details className="mt-6 text-left max-w-2xl w-full">
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">
查看详细错误信息
</summary>
<pre className="mt-2 p-4 bg-muted rounded-lg text-xs overflow-auto">
{error.stack}
</pre>
</details>
)}
</div>
)
}
/**
* 区域级错误组件
* 适合在较小的区域显示错误
*
* @example
* ```tsx
* <ErrorInline
* message="发生错误"
* error={error}
* onRetry={() => refetch()}
* />
* ```
*/
export function ErrorInline({
message,
error,
onRetry,
className,
}: Omit<ErrorDisplayProps, 'title' | 'icon' | 'showStack'>) {
const errorMessage = message || error?.message || "发生未知错误"
return (
<div className={cn("flex items-center gap-2 text-sm text-red-600 dark:text-red-400", className)}>
<AlertCircle className="size-3 shrink-0 text-destructive" />
<span className="flex-1 text-xs">{errorMessage}</span>
{onRetry && (
<Button
onClick={onRetry}
variant="secondary"
size="sm"
className="h-6 px-4 text-xs"
>
重试
</Button>
)}
</div>
)
}
/**
* 页面级错误组件
* 适合作为页面主要内容的错误状态
*
* @example
* ```tsx
* <ErrorPage
* title="加载失败"
* message="无法获取数据,请稍后重试"
* error={error}
* onRetry={() => refetch()}
* />
* ```
*/
export function ErrorPage(props: ErrorDisplayProps) {
return (
<div className="flex items-center justify-center min-h-[calc(100vh-8rem)]">
<ErrorDisplay
{...props}
showStack={props.showStack ?? process.env.NODE_ENV === 'development'}
/>
</div>
)
}
+201
View File
@@ -0,0 +1,201 @@
"use client"
import { useState, useEffect, memo } from "react"
import { AnimatePresence, motion } from "motion/react"
import { Button } from "@/components/ui/button"
import { Bell, Plus, Settings, Search, Moon, Sun, Maximize2, Minimize2 } from "lucide-react"
import { useUser } from "@/contexts/user-context"
import { useBellRing } from "@/contexts/bell-ring-context"
import { useNotificationSettings } from "@/contexts/notification-settings-context"
import { SidebarTrigger } from "@/components/ui/sidebar"
import { useTheme } from "next-themes"
import { useRouter } from "next/navigation"
import { SearchDialog } from "@/components/layout/search-dialog"
import { Kbd } from "@/components/ui/kbd"
/**
* 铃铛按钮组件
*/
const BellButton = memo(function BellButton() {
const { isRinging } = useBellRing()
const { showBell, isMounted } = useNotificationSettings()
const [isClickAnimating, setIsClickAnimating] = useState(false)
if (!isMounted) return null
if (!showBell) return null
const handleClick = () => {
setIsClickAnimating(true)
setTimeout(() => setIsClickAnimating(false), 600)
}
return (
<Button
variant="ghost"
size="icon"
className="size-9 text-muted-foreground hover:text-foreground"
onClick={handleClick}
>
<Bell
className="size-[18px]"
style={(isRinging || isClickAnimating) ? { animation: 'var(--animate-bell-ring)', transformOrigin: 'top center' } : undefined}
/>
<span className="sr-only">通知</span>
</Button>
)
})
/**
* 站点头部组件
* 用于显示站点头部
*
* @example
* ```tsx
* <SiteHeader />
* ```
*/
export function SiteHeader({ isFullWidth = false, onToggleFullWidth }: { isFullWidth?: boolean, onToggleFullWidth?: (value: boolean) => void }) {
const { user } = useUser()
const { setTheme, resolvedTheme } = useTheme()
const router = useRouter()
const [mounted, setMounted] = useState(false)
const [searchOpen, setSearchOpen] = useState(false)
const [metaKey, setMetaKey] = useState("⌘")
useEffect(() => {
setMounted(true)
if (typeof navigator !== 'undefined' && !navigator.userAgent?.includes("Mac")) {
setMetaKey("Ctrl")
}
}, [])
return (
<div className="flex flex-col w-full">
<AppBanner />
<header className="flex h-(--header-height) shrink-0 items-center bg-background px-4 md:px-0">
<div className="flex w-full items-center justify-between md:hidden">
<div className="flex items-center gap-2">
<SidebarTrigger />
<span className="text-sm font-medium truncate max-w-[120px]">
{user?.nickname || user?.username || "Guest"}
</span>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => setSearchOpen(true)}>
<Search className="size-[18px]" />
<span className="sr-only">搜索</span>
</Button>
<BellButton />
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => router.push('/settings')}>
<Settings className="size-[18px]" />
<span className="sr-only">设置</span>
</Button>
</div>
</div>
<div className={`hidden md:flex w-full items-center gap-4 transition-all duration-300 ease-in-out ${!isFullWidth ? "max-w-[1320px]" : "max-w-full"} mx-auto md:px-8 lg:px-12`}>
<div className="relative w-64 cursor-pointer" onClick={() => setSearchOpen(true)}>
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<div className="flex h-8 items-center rounded-md border border-border/60 bg-muted/70 pl-10 pr-2.5 text-sm text-muted-foreground transition-colors hover:border-border hover:bg-muted">
<span>搜索</span>
<Kbd className="ml-auto gap-0.5 font-mono">
<span>{metaKey}</span>
<span>K</span>
</Kbd>
</div>
</div>
<div className="ml-auto flex items-center gap-1">
<BellButton />
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => router.push('/settings')}>
<Settings className="size-[18px]" />
<span className="sr-only">设置</span>
</Button>
<Button className="mx-1 size-7 rounded-full bg-primary text-primary-foreground hover:bg-primary/90" onClick={() => router.push('/merchant')}>
<Plus className="size-4" />
<span className="sr-only">新建</span>
</Button>
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => onToggleFullWidth?.(!isFullWidth)}>
{isFullWidth ? <Minimize2 className="size-[18px]" /> : <Maximize2 className="size-[18px]" />}
<span className="sr-only">切换全宽</span>
</Button>
<Button variant="ghost" size="icon" className="size-9 text-muted-foreground hover:text-foreground" onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}>
{mounted ? (resolvedTheme === 'dark' ? <Sun className="size-[18px]" /> : <Moon className="size-[18px]" />) : <Moon className="size-[18px]" />}
<span className="sr-only">主题切换</span>
</Button>
</div>
</div>
{mounted && <SearchDialog open={searchOpen} onOpenChange={setSearchOpen} />}
</header>
</div>
)
}
function AppBanner() {
const [isVisible, setIsVisible] = useState(false)
const bannerKey = 'ldc-banner-dismissed-script'
useEffect(() => {
const dismissed = localStorage.getItem(bannerKey)
if (!dismissed) {
setIsVisible(true)
}
}, [])
const handleDismiss = () => {
localStorage.setItem(bannerKey, 'true')
setIsVisible(false)
}
return (
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
className="relative flex w-full items-center justify-center bg-muted/50 overflow-hidden"
>
<div className="flex w-full items-center justify-center px-4 pr-10 py-2 text-xs md:text-[13px] font-medium text-muted-foreground">
<p className="flex flex-wrap items-center justify-center gap-x-2 text-center">
<span className="text-foreground">「LINUX DO Credit」实时积分收入脚本已发布</span>
<a href="https://linux.do/t/topic/1365853" target="_blank" className="underline underline-offset-4 hover:text-foreground">
查看详情
</a>
</p>
<Button
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 size-6 -translate-y-1/2 text-muted-foreground hover:text-foreground shrink-0"
onClick={handleDismiss}
>
<span className="sr-only">Dismiss</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-x"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
)
}
+227
View File
@@ -0,0 +1,227 @@
import * as React from "react"
import { motion } from "motion/react"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { FileTextIcon } from "lucide-react"
/**
* 加载页面组件
*
* 用于统一显示加载状态
* @example
* ```tsx
* <LoadingPage text="系统" badgeText="系统" />
* ```
* @param {string} text - 显示的文本内容,默认为"系统"
* @param {string} badgeText - 显示的徽章文本,默认为"系统"
* @returns {React.ReactNode} 加载页面组件
*/
function LoadingCard() {
return (
<div className="relative w-[240px] aspect-[1.586] mx-auto perspective-1200 group">
<div className={cn("absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[120%] h-[120%] blur-[80px] pointer-events-none transition-colors duration-1000 bg-primary/10")} />
<motion.div
initial={{ rotateY: 0, rotateX: 0, y: 0 }}
animate={{
rotateY: [-10, 10, -10],
rotateX: [4, -4, 4],
y: [-6, 6, -6],
}}
transition={{
duration: 8,
repeat: Infinity,
ease: "easeInOut",
}}
style={{ transformStyle: "preserve-3d" }}
className="relative w-full h-full"
>
<div
className="absolute -bottom-10 left-1/2 -translate-x-1/2 w-[85%] h-4 bg-black/40 blur-xl rounded-[100%] transition-all duration-500"
style={{ transform: 'translateZ(-20px)' }}
/>
<div className={cn("absolute inset-0 rounded-2xl bg-[#1a1a1a] shadow-2xl border border-white/10 overflow-hidden backdrop-blur-xl")}>
<div className="absolute inset-0 bg-gradient-to-br from-white/5 to-transparent pointer-events-none" />
<div className="absolute inset-0 opacity-[0.05] bg-[url('https://grainy-gradients.vercel.app/noise.svg')] mix-blend-overlay" />
<div className="relative h-full p-6 flex flex-col justify-between z-10">
<div className="flex justify-between items-start">
<div className="relative w-11 h-8 rounded-md bg-[#d4d4d8] shadow-inner overflow-hidden flex items-center justify-center">
<div className="absolute inset-0 bg-[linear-gradient(135deg,transparent_0%,rgba(0,0,0,0.1)_50%,transparent_100%)]" />
<div className="w-full h-[1px] bg-black/10 absolute top-1/2" />
<div className="h-full w-[1px] bg-black/10 absolute left-1/3" />
<div className="h-full w-[1px] bg-black/10 absolute right-1/3" />
</div>
<div className="flex flex-col items-end gap-[2px]">
<div className="w-1.5 h-1.5 rounded-full bg-white/20" />
<div className="w-1.5 h-1.5 rounded-full bg-white/40" />
<div className="w-1.5 h-1.5 rounded-full bg-white/80" />
</div>
</div>
<div className="space-y-3 mt-4">
<div className="flex gap-2">
<div className="h-1 w-12 rounded-full bg-white/10" />
<div className="h-1 w-8 rounded-full bg-white/10" />
</div>
</div>
<div className="flex justify-between items-end">
<div className="space-y-1.5">
<div className="h-1.5 w-20 rounded-full bg-white/20" />
<div className="h-1.5 w-12 rounded-full bg-white/20" />
</div>
<div className="font-serif italic text-xl font-bold tracking-widest text-white drop-shadow-md">
Credit
</div>
</div>
</div>
<motion.div
className="absolute -inset-[200%] bg-gradient-to-r from-transparent via-white/5 to-transparent -skew-x-12"
animate={{
x: ['-100%', '200%'],
}}
transition={{
duration: 3,
repeat: Infinity,
ease: "easeInOut",
repeatDelay: 2
}}
style={{ mixBlendMode: 'overlay' }}
/>
</div>
<div
className={cn("absolute inset-0 rounded-2xl translate-z-[-2px] border border-white/5 bg-[#0f0f0f]")}
style={{ transform: 'translateZ(-2px)' }}
/>
<div
className={cn("absolute inset-0 rounded-2xl translate-z-[-4px] border border-white/5 bg-[#0f0f0f]")}
style={{ transform: 'translateZ(-4px)' }}
/>
<div
className="absolute inset-0 rounded-2xl bg-black/80 translate-z-[-10px] blur-xl"
style={{ transform: 'translateZ(-15px)' }}
/>
</motion.div>
</div>
)
}
/**
* 加载页面组件
*
* 用于统一显示加载状态
*/
export function LoadingPage({ text = "系统", badgeText = "系统" }: { text?: string, badgeText?: string }) {
return (
<div className="absolute inset-0 z-50 overflow-hidden font-sans bg-background/80 backdrop-blur-md text-foreground flex items-center justify-center">
<div className="w-full max-w-md mx-auto text-center px-4 flex flex-col items-center gap-8">
<LoadingCard />
<div className="space-y-3 w-full flex flex-col items-center">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="relative flex items-center justify-center gap-2"
>
<span className="text-3xl font-bold tracking-tight text-foreground">LINUX DO</span>
<span className="text-4xl font-serif font-bold italic text-primary">Credit</span>
{badgeText && (
<div className="absolute left-full top-1/3 -translate-y-1/2 ml-2">
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-5 border-foreground/20 text-muted-foreground whitespace-nowrap">
{badgeText}
</Badge>
</div>
)}
</motion.div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
className="flex flex-col items-center justify-center gap-4 text-sm text-muted-foreground w-full max-w-[200px]"
>
<div className="relative h-1 w-full overflow-hidden rounded-full bg-muted">
<motion.div
className="absolute inset-y-0 left-0 h-full w-1/3 rounded-full bg-primary"
animate={{
x: ["-100%", "300%"],
}}
transition={{
duration: 1.2,
repeat: Infinity,
ease: "easeInOut",
}}
/>
</div>
<span className="font-mono text-xs tracking-wider opacity-60">{text.toUpperCase()}</span>
</motion.div>
</div>
</div>
</div>
)
}
interface LoadingStateProps extends React.ComponentProps<"div"> {
title?: string
description?: string
icon?: React.ComponentType<{ className?: string }>
iconSize?: "sm" | "md" | "lg"
}
/**
* 加载状态展示组件
* 用于统一显示加载中的状态
*/
export function LoadingState({
title = "加载中",
description = "正在获取活动数据...",
icon: Icon = FileTextIcon,
className,
iconSize = "md",
}: LoadingStateProps) {
const iconSizes = { sm: "size-8", md: "size-10", lg: "size-14" }
const iconInnerSizes = { sm: "size-4", md: "size-5", lg: "size-7" }
return (
<div className={cn("flex flex-col items-center justify-center py-12 text-center", className)}>
<div className={cn(
"rounded-full bg-muted flex items-center justify-center mb-2 animate-pulse",
iconSizes[iconSize]
)}>
<Icon className={cn("text-muted-foreground", iconInnerSizes[iconSize])} />
</div>
{title && (
<h3 className="text-sm font-medium mb-1 animate-pulse">
{title}
</h3>
)}
{description && (
<p className="text-xs text-muted-foreground max-w-md animate-pulse">
{description}
</p>
)}
</div>
)
}
/**
* 带边框的加载状态组件
*/
export function LoadingStateWithBorder(props: LoadingStateProps) {
return (
<div className="border border-dashed rounded-lg">
<LoadingState {...props} />
</div>
)
}
@@ -0,0 +1,272 @@
"use client"
import { useEffect, useState, useCallback } from "react"
import { useRouter } from "next/navigation"
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import { Kbd, KbdGroup } from "@/components/ui/kbd"
import { searchItems, type SearchItem } from "@/lib/utils/search-data"
import { Home, Settings, FileText, Shield } from "lucide-react"
import { useUser } from "@/contexts/user-context"
interface SearchDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
const categoryIcons = {
page: Home,
feature: FileText,
setting: Settings,
admin: Shield,
}
const categoryLabels = {
page: '页面',
feature: '功能',
setting: '设置',
admin: '管理',
}
const getTips = (metaKey: string) => [
(
<>
<span className="text-muted-foreground/80 lowercase">Tips: 还可以使用</span>
<Kbd className="mx-1">/</Kbd>
<span className="text-muted-foreground/80 lowercase">来打开此界面</span>
</>
),
(
<>
<span className="text-muted-foreground/80 lowercase">Tips: 使用</span>
<Kbd className="mx-1">↑</Kbd>
<Kbd className="mx-1">↓</Kbd>
<span className="text-muted-foreground/80 lowercase">来切换选中项</span>
</>
),
(
<>
<span className="text-muted-foreground/80 lowercase">Tips: 按住</span>
<Kbd className="mx-1">{metaKey}</Kbd>
<span className="text-muted-foreground/80 lowercase">+</span>
<Kbd className="mx-1">↵</Kbd>
<span className="text-muted-foreground/80 lowercase">在新标签页打开</span>
</>
),
(
<>
<span className="text-muted-foreground/80 lowercase">你知道吗:搜索功能还在持续升级中</span>
</>
),
(
<>
<span className="text-muted-foreground/80 lowercase">你知道吗:积分会在每天 0 点更新</span>
</>
),
(
<>
<span className="text-muted-foreground/80 lowercase">你知道吗:新用户前三天享有划转掉分保护</span>
</>
),
(
<>
<span className="text-muted-foreground/80 lowercase">想实时查看积分变化?试试这个</span>
<a
href="https://linux.do/t/topic/1365853"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline mx-1"
>
脚本
</a>
</>
),
(
<>
<span className="text-muted-foreground/80 lowercase">遇到问题?欢迎提交</span>
<a
href="https://github.com/linux-do/credit/issues"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline mx-1"
>
Issue
</a>
<span className="text-muted-foreground/80 lowercase">反馈</span>
</>
)
]
export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
const router = useRouter()
const { user } = useUser()
const [search, setSearch] = useState('')
const [currentTip, setCurrentTip] = useState<React.ReactNode>(null)
const [results, setResults] = useState<SearchItem[]>([])
const [metaKey, setMetaKey] = useState("⌘")
const [isCtrlPressed, setIsCtrlPressed] = useState(false)
useEffect(() => {
if (typeof navigator !== 'undefined' && !navigator.userAgent?.includes("Mac")) {
setMetaKey("Ctrl")
}
}, [])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Control' || e.key === 'Meta') {
setIsCtrlPressed(true)
}
}
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Control' || e.key === 'Meta') {
setIsCtrlPressed(false)
}
}
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
}
}, [])
useEffect(() => {
if (open) {
const tips = getTips(metaKey)
const randomTip = tips[Math.floor(Math.random() * tips.length)]
setCurrentTip(randomTip)
}
}, [open, metaKey])
useEffect(() => {
const items = searchItems(search, user?.is_admin)
setResults(items)
}, [search, user?.is_admin])
const handleSelect = useCallback((item: SearchItem, openInNewTab = false) => {
onOpenChange(false)
if (openInNewTab) {
window.open(item.url, '_blank')
} else {
router.push(item.url)
}
setSearch('')
}, [onOpenChange, router])
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
onOpenChange(!open)
}
if (e.key === '/'&& !e.ctrlKey && !e.metaKey && !e.altKey) {
const target = e.target as HTMLElement
const isEditing = target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable ||
target.closest('[contenteditable="true"]')
if (!isEditing) {
e.preventDefault()
onOpenChange(true)
}
}
}
document.addEventListener('keydown', down)
return () => document.removeEventListener('keydown', down)
}, [open, onOpenChange])
// Group results by category
const groupedResults = results.reduce((acc, item) => {
if (!acc[item.category]) {
acc[item.category] = []
}
acc[item.category].push(item)
return acc
}, {} as Record<string, SearchItem[]>)
return (
<CommandDialog
open={open}
onOpenChange={onOpenChange}
shouldFilter={false}
className="max-w-[calc(100%-1.5rem)] sm:max-w-[500px] md:max-w-[540px]"
>
<CommandInput
placeholder="搜索页面和功能..."
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandEmpty>没有找到相关内容,换个词试试?</CommandEmpty>
{Object.entries(groupedResults).map(([category, items]) => {
const Icon = categoryIcons[category as keyof typeof categoryIcons]
return (
<CommandGroup key={category} heading={categoryLabels[category as keyof typeof categoryLabels]}>
{items.map((item) => (
<CommandItem
key={item.id}
value={item.title}
onSelect={() => handleSelect(item, isCtrlPressed)}
className="flex items-center justify-between"
>
<div className="flex min-w-0 items-center gap-3">
<div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted/70">
<Icon className="size-4" />
</div>
<div className="flex min-w-0 flex-col">
<span>
{item.matchRange ? (
<>
{item.title.substring(0, item.matchRange[0])}
<span className="text-primary font-bold">
{item.title.substring(item.matchRange[0], item.matchRange[1] + 1)}
</span>
{item.title.substring(item.matchRange[1] + 1)}
</>
) : (
item.title
)}
</span>
<span className="truncate text-[11px] leading-4 text-muted-foreground">{item.description}</span>
</div>
</div>
<span className="ml-3 shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-[10px] leading-none font-medium text-muted-foreground">
{categoryLabels[item.category as keyof typeof categoryLabels]}
</span>
</CommandItem>
))}
</CommandGroup>
)
})}
</CommandList>
<div className="hidden border-t bg-muted/20 px-4 py-2.5 md:flex items-center gap-4 text-[10px] text-muted-foreground uppercase tracking-wider font-medium select-none">
<div className="flex items-center gap-1">
<KbdGroup>
{isCtrlPressed && <Kbd>{metaKey}</Kbd>}
<Kbd>↵</Kbd>
</KbdGroup>
<span>{isCtrlPressed ? '在新标签页打开' : '打开'}</span>
</div>
<div className="flex items-center gap-1">
<Kbd>Esc</Kbd>
<span>关闭搜索界面</span>
</div>
<div className="ml-auto flex items-center gap-1">
{currentTip}
</div>
</div>
</CommandDialog>
)
}
+392
View File
@@ -0,0 +1,392 @@
"use client"
import * as React from "react"
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
import packageJson from "../../package.json"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { AnimateIcon } from "@/components/animate-ui/icons/icon"
import { ChevronLeft } from "@/components/animate-ui/icons/chevron-left"
import { ChevronRight } from "@/components/animate-ui/icons/chevron-right"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"
import { getCurrentTheme } from "@/components/layout/avater-style/registry"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
Home,
CreditCard,
Settings,
Wallet,
FileText,
CircleDollarSign,
LogOut,
Store,
ChevronDown,
UserRound,
FileQuestionMark,
ShieldCheck,
Globe,
Layers,
Trophy,
ArrowUpRight,
} from "lucide-react"
import { useUser } from "@/contexts/user-context"
/* 导航数据 */
const data = {
navMain: [
{ title: "首页", url: "/home", icon: Home },
{ title: "集市", url: "/merchant", icon: Store },
{ title: "积分", url: "/balance", icon: Wallet },
{ title: "活动", url: "/trade", icon: CircleDollarSign },
{ title: "排行榜", url: "/leaderboard", icon: Trophy },
],
admin: [
{ title: "系统配置", url: "/admin/system", icon: ShieldCheck },
{ title: "积分配置", url: "/admin/credit", icon: Settings },
{ title: "用户管理", url: "/admin/users", icon: UserRound },
{ title: "任务管理", url: "/admin/tasks", icon: Layers },
],
document: [
{ title: "接口文档", url: "/docs/api", icon: CreditCard },
{ title: "使用文档", url: "/docs/how-to-use", icon: FileText },
],
products: [
{ title: "在线流转", url: "/merchant/online-paying", icon: Globe },
],
}
/**
* 应用侧边栏组件
* 显示应用侧边栏
*
* @example
* ```tsx
* <AppSidebar />
* ```
* @returns {React.ReactNode} 应用侧边栏组件
*/
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { toggleSidebar, state, isMobile, setOpenMobile } = useSidebar()
const { user, getTrustLevelLabel, logout } = useUser()
const [showLogoutDialog, setShowLogoutDialog] = React.useState(false)
const [isLoggingOut, setIsLoggingOut] = React.useState(false)
const pathname = usePathname()
const router = useRouter()
const currentTheme = getCurrentTheme()
const {
MainDecoration,
InteractionDecoration,
BackgroundEffect,
smallMainDecorationConfig,
largeMainDecorationConfig,
interactionDecorationConfig
} = currentTheme
const hasAvatarDecoration = currentTheme.key !== "none"
const [showEffect, setShowEffect] = React.useState(false)
const [userMenuOpen, setUserMenuOpen] = React.useState(false)
const userMenuLastToggle = React.useRef(0)
const handleUserMenuOpenChange = React.useCallback((open: boolean) => {
const now = Date.now()
if (now - userMenuLastToggle.current < 65) return
userMenuLastToggle.current = now
setUserMenuOpen(open)
}, [])
const handleCloseSidebar = React.useCallback(() => {
if (isMobile) {
setOpenMobile(false)
}
}, [isMobile, setOpenMobile])
const handleLogout = async () => {
setIsLoggingOut(true)
try {
await logout()
setShowLogoutDialog(false)
} catch (error) {
toast.error("登出失败", {
description: error instanceof Error ? error.message : "登出时发生错误,请重试"
})
setIsLoggingOut(false)
}
}
return (
<>
{hasAvatarDecoration && showEffect && BackgroundEffect && <BackgroundEffect />}
<Sidebar collapsible="icon" {...props} className="px-2 relative border-r border-border/40 group-data-[collapsible=icon]">
<Button
onClick={toggleSidebar}
variant="ghost"
size="icon"
className="absolute top-1/2 -right-6 w-2 h-4 text-muted-foreground hover:bg-background hidden md:flex"
>
{state === "expanded" ? (
<AnimateIcon animateOnHover>
<ChevronLeft className="size-6" />
</AnimateIcon>
) : (
<AnimateIcon animateOnHover>
<ChevronRight className="size-6" />
</AnimateIcon>
)}
</Button>
<SidebarHeader className="py-4 md:-ml-2">
<DropdownMenu open={userMenuOpen} onOpenChange={handleUserMenuOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="flex h-12 hover:bg-accent group-data-[collapsible=icon]:ml-2"
suppressHydrationWarning
>
<div className="relative overflow-visible">
{hasAvatarDecoration && <MainDecoration className={smallMainDecorationConfig?.className} />}
<Avatar className="size-6 rounded relative z-10">
<AvatarImage
src={user?.avatar_url}
alt={user?.nickname}
/>
<AvatarFallback className="rounded bg-muted text-sm">
{user?.nickname?.charAt(0)?.toUpperCase() || "L"}
</AvatarFallback>
</Avatar>
</div>
<div className="flex flex-col items-start flex-1 text-left group-data-[collapsible=icon]:hidden">
<span className="text-xs font-medium truncate w-full text-left ml-2">
{user?.nickname || user?.username || "Unknown User"}
</span>
<span className="text-[11px] font-medium text-muted-foreground/100 truncate w-full text-left ml-2">
{user ? getTrustLevelLabel(user.trust_level) : "Trust Level Unknown"}
</span>
</div>
<ChevronDown className="size-4 text-muted-foreground ml-auto group-data-[collapsible=icon]:hidden" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className={isMobile || state === "collapsed" ? "ml-1 w-68 z-[200]" : "w-64 z-[200]"}
align="start"
side={isMobile || state === "collapsed" ? "bottom" : "right"}
sideOffset={4}
>
<DropdownMenuLabel>
<div className="flex flex-col items-center mb-4 gap-1 pt-4">
<div
className={`relative w-32 h-16 flex items-center justify-center overflow-visible select-none transition-transform ${hasAvatarDecoration ? "cursor-pointer active:scale-95" : ""}`}
onClick={hasAvatarDecoration ? () => setShowEffect(!showEffect) : undefined}
>
{hasAvatarDecoration && (
<InteractionDecoration
show={showEffect}
className={interactionDecorationConfig?.className}
/>
)}
<div className="relative z-10">
{hasAvatarDecoration && <MainDecoration className={largeMainDecorationConfig?.className} />}
<Avatar className="size-14 rounded-full pointer-events-none">
<AvatarImage
src={user?.avatar_url}
alt={user?.nickname}
/>
<AvatarFallback className="rounded-full bg-transparent text-lg">
{user?.nickname?.charAt(0)?.toUpperCase() || "L"}
</AvatarFallback>
</Avatar>
</div>
</div>
<span className="text-base font-semibold truncate mt-2">
{user?.nickname || user?.username || "Unknown User"}
</span>
<span className="text-xs font-base text-muted-foreground">
{user ? getTrustLevelLabel(user.trust_level) : "Trust Level Unknown"}
</span>
</div>
</DropdownMenuLabel>
<DropdownMenuItem onClick={() => {
router.push("/settings/profile")
handleCloseSidebar()
}}>
<UserRound className="mr-2 size-4" />
<span>我的资料</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => {
router.push("/settings")
handleCloseSidebar()
}}>
<Settings className="mr-2 size-4" />
<span>设置</span>
</DropdownMenuItem>
<DropdownMenuSeparator className="my-2" />
<DropdownMenuItem onClick={() => {
router.push("/docs/how-to-use")
handleCloseSidebar()
}}>
<FileQuestionMark className="mr-2 size-4" />
<span>使用帮助</span>
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive hover:bg-destructive/50" onClick={() => setShowLogoutDialog(true)}>
<LogOut className="mr-2 size-4 text-destructive" />
<span>退出登录</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarHeader>
<SidebarContent className="group-data-[collapsible=icon]">
<SidebarGroup className="py-0">
<SidebarGroupContent className="py-1">
<SidebarMenu className="gap-1">
{data.navMain.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
isActive={pathname === item.url}
asChild
>
<Link href={item.url} onClick={handleCloseSidebar}>
{item.icon && <item.icon />}
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{user?.is_admin && (
<SidebarGroup className="py-0 pt-4">
<SidebarGroupLabel className="text-xs font-normal text-muted-foreground">
管理
</SidebarGroupLabel>
<SidebarGroupContent className="py-1">
<SidebarMenu className="gap-1">
{data.admin.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
isActive={pathname === item.url}
asChild
>
<Link href={item.url} onClick={handleCloseSidebar}>
{item.icon && <item.icon />}
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)}
<SidebarGroup className="py-0 pt-4">
<SidebarGroupLabel className="text-xs font-normal text-muted-foreground">
文档库
</SidebarGroupLabel>
<SidebarGroupContent className="py-1">
<SidebarMenu className="gap-1">
{data.document.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
asChild
>
<Link href={item.url} target="_blank" rel="noopener noreferrer" onClick={handleCloseSidebar}>
{item.icon && <item.icon />}
<span className="flex-1">{item.title}</span>
<ArrowUpRight className="size-3 text-muted-foreground" />
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup className="py-0 pt-4">
<SidebarGroupLabel className="text-xs font-normal text-muted-foreground">
服务
</SidebarGroupLabel>
<SidebarGroupContent className="py-1">
<SidebarMenu className="gap-1">
{data.products.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
asChild
>
<Link href={item.url} onClick={handleCloseSidebar}>
{item.icon && <item.icon />}
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="mt-auto px-3 py-3 group-data-[collapsible=icon]:hidden">
<div className="border-t border-border/60 pt-3 text-[11px] leading-5 text-muted-foreground">
<div>Version {packageJson.version}</div>
<div>Build At {packageJson.buildDate}</div>
</div>
</SidebarFooter>
</Sidebar>
<AlertDialog open={showLogoutDialog} onOpenChange={(open) => !isLoggingOut && setShowLogoutDialog(open)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认登出</AlertDialogTitle>
<AlertDialogDescription>
{isLoggingOut ? '正在登出,请稍候...' : '您确定要登出当前账户吗?'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoggingOut}>取消</AlertDialogCancel>
<AlertDialogAction onClick={handleLogout} disabled={isLoggingOut}>
{isLoggingOut && <Spinner className="mr-2" />}
{isLoggingOut ? '登出中...' : '确认登出'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
@@ -0,0 +1,11 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
+30
View File
@@ -0,0 +1,30 @@
import { motion } from "motion/react"
/**
* 页面过渡组件
* 用于统一显示页面过渡效果
*
* @example
* ```tsx
* <PageTransition>
* <div>内容</div>
* </PageTransition>
* ```
* @param {React.ReactNode} children - 页面过渡组件的子元素
* @returns {React.ReactNode} 页面过渡组件
*/
export function PageTransition({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0.6,
ease: "easeOut",
}}
>
{children}
</motion.div>
)
}
+66
View File
@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+157
View File
@@ -0,0 +1,157 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants({ variant: "destructive", size: "sm"}), "text-xs", className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "text-xs", className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
@@ -0,0 +1,83 @@
"use client";
import { cn } from "@/lib/utils";
import React, { ReactNode, useEffect, useState } from "react";
interface AuroraBackgroundProps extends React.HTMLProps<HTMLDivElement> {
children: ReactNode;
showRadialGradient?: boolean;
}
export const AuroraBackground = ({
className,
children,
showRadialGradient = true,
...props
}: AuroraBackgroundProps) => {
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
const handleVisibilityChange = () => {
setIsVisible(!document.hidden);
};
// Add event listener for page visibility changes
document.addEventListener("visibilitychange", handleVisibilityChange);
// Cleanup
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []);
return (
<main>
<div
className={cn(
"transition-bg relative flex h-[100vh] flex-col items-center justify-center bg-zinc-50 text-slate-950 dark:bg-zinc-900",
className,
)}
{...props}
>
<div
className="absolute inset-0 overflow-hidden"
style={
{
"--aurora":
"repeating-linear-gradient(100deg,#3b82f6_10%,#a5b4fc_15%,#93c5fd_20%,#ddd6fe_25%,#60a5fa_30%)",
"--dark-gradient":
"repeating-linear-gradient(100deg,#000_0%,#000_7%,transparent_10%,transparent_12%,#000_16%)",
"--white-gradient":
"repeating-linear-gradient(100deg,#fff_0%,#fff_7%,transparent_10%,transparent_12%,#fff_16%)",
"--blue-300": "#93c5fd",
"--blue-400": "#60a5fa",
"--blue-500": "#3b82f6",
"--indigo-300": "#a5b4fc",
"--violet-200": "#ddd6fe",
"--black": "#000",
"--white": "#fff",
"--transparent": "transparent",
// Hide container when page is not visible
visibility: isVisible ? "visible" : "hidden",
} as React.CSSProperties
}
>
<div
// I'm sorry but this is what peak developer performance looks like // trigger warning
className={cn(
`after:animate-aurora pointer-events-none absolute -inset-[10px] [background-image:var(--white-gradient),var(--aurora)] [background-size:300%,_200%] [background-position:50%_50%,50%_50%] opacity-50 blur-[10px] invert filter will-change-transform [--aurora:repeating-linear-gradient(100deg,var(--blue-500)_10%,var(--indigo-300)_15%,var(--blue-300)_20%,var(--violet-200)_25%,var(--blue-400)_30%)] [--dark-gradient:repeating-linear-gradient(100deg,var(--black)_0%,var(--black)_7%,var(--transparent)_10%,var(--transparent)_12%,var(--black)_16%)] [--white-gradient:repeating-linear-gradient(100deg,var(--white)_0%,var(--white)_7%,var(--transparent)_10%,var(--transparent)_12%,var(--white)_16%)] after:absolute after:inset-0 after:[background-image:var(--white-gradient),var(--aurora)] after:[background-size:200%,_100%] after:[background-attachment:fixed] after:mix-blend-difference after:content-[""] dark:[background-image:var(--dark-gradient),var(--aurora)] dark:invert-0 after:dark:[background-image:var(--dark-gradient),var(--aurora)]`,
showRadialGradient &&
`[mask-image:radial-gradient(ellipse_at_100%_0%,black_10%,var(--transparent)_70%)]`,
)}
style={{
// Pause animation when page is not visible
animationPlayState: isVisible ? "running" : "paused",
}}
></div>
</div>
{children}
</div>
</main>
);
};
+53
View File
@@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }
+46
View File
@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+109
View File
@@ -0,0 +1,109 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
+60
View File
@@ -0,0 +1,60 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+216
View File
@@ -0,0 +1,216 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-4 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-4 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+241
View File
@@ -0,0 +1,241 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

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