231c5040a5
Expose ledger-backed cross-device purchase history while shipping the tested React admin redesign in the same reproducible deployment revision.
309 lines
9.6 KiB
TypeScript
309 lines
9.6 KiB
TypeScript
import { Dialog as BaseDialog } from "@base-ui/react/dialog";
|
|
import { cva, type VariantProps } from "class-variance-authority";
|
|
import {
|
|
AlertTriangle,
|
|
Inbox,
|
|
LoaderCircle,
|
|
RefreshCw,
|
|
X,
|
|
type LucideIcon,
|
|
} from "lucide-react";
|
|
import {
|
|
forwardRef,
|
|
type ButtonHTMLAttributes,
|
|
type HTMLAttributes,
|
|
type InputHTMLAttributes,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { cn } from "../lib/utils";
|
|
|
|
const buttonVariants = cva(
|
|
"inline-flex min-h-10 items-center justify-center gap-2 rounded-xl px-4 text-sm font-semibold transition-all duration-200 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]",
|
|
{
|
|
variants: {
|
|
variant: {
|
|
primary:
|
|
"bg-primary text-primary-foreground shadow-[0_8px_24px_-10px_var(--primary)] hover:-translate-y-0.5 hover:bg-primary/90",
|
|
secondary:
|
|
"border border-border bg-surface text-foreground shadow-sm hover:border-border-strong hover:bg-surface-muted",
|
|
ghost: "text-muted hover:bg-surface-muted hover:text-foreground",
|
|
danger:
|
|
"bg-danger text-white shadow-[0_8px_24px_-10px_var(--danger)] hover:-translate-y-0.5 hover:bg-danger/90",
|
|
},
|
|
size: {
|
|
sm: "min-h-8 rounded-lg px-3 text-xs",
|
|
md: "min-h-10 px-4",
|
|
lg: "min-h-12 px-5",
|
|
icon: "size-10 px-0",
|
|
},
|
|
},
|
|
defaultVariants: {
|
|
variant: "primary",
|
|
size: "md",
|
|
},
|
|
},
|
|
);
|
|
|
|
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
|
|
VariantProps<typeof buttonVariants> & {
|
|
loading?: boolean;
|
|
};
|
|
|
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
({ className, variant, size, loading, children, disabled, ...props }, ref) => (
|
|
<button
|
|
ref={ref}
|
|
className={cn(buttonVariants({ variant, size }), className)}
|
|
disabled={disabled || loading}
|
|
{...props}
|
|
>
|
|
{loading ? <LoaderCircle className="size-4 animate-spin" aria-hidden /> : null}
|
|
{children}
|
|
</button>
|
|
),
|
|
);
|
|
Button.displayName = "Button";
|
|
|
|
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
|
|
({ className, ...props }, ref) => (
|
|
<input
|
|
ref={ref}
|
|
className={cn(
|
|
"h-11 w-full rounded-xl border border-border bg-input px-3.5 text-sm text-foreground shadow-sm outline-none transition placeholder:text-muted/70 focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:bg-surface-muted disabled:text-muted",
|
|
className,
|
|
)}
|
|
{...props}
|
|
/>
|
|
),
|
|
);
|
|
Input.displayName = "Input";
|
|
|
|
export const Textarea = forwardRef<
|
|
HTMLTextAreaElement,
|
|
React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
|
>(({ className, ...props }, ref) => (
|
|
<textarea
|
|
ref={ref}
|
|
className={cn(
|
|
"min-h-24 w-full resize-y rounded-xl border border-border bg-input px-3.5 py-3 text-sm text-foreground shadow-sm outline-none transition placeholder:text-muted/70 focus:border-primary focus:ring-4 focus:ring-primary/10",
|
|
className,
|
|
)}
|
|
{...props}
|
|
/>
|
|
));
|
|
Textarea.displayName = "Textarea";
|
|
|
|
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"rounded-2xl border border-border bg-surface shadow-[0_1px_2px_rgb(15_23_42/0.02),0_10px_35px_rgb(15_23_42/0.035)]",
|
|
className,
|
|
)}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const badgeVariants = cva(
|
|
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold",
|
|
{
|
|
variants: {
|
|
tone: {
|
|
neutral: "bg-surface-muted text-muted",
|
|
success: "bg-success-soft text-success",
|
|
danger: "bg-danger-soft text-danger",
|
|
warning: "bg-warning-soft text-warning",
|
|
info: "bg-primary-soft text-primary",
|
|
violet: "bg-violet-soft text-violet",
|
|
},
|
|
},
|
|
defaultVariants: { tone: "neutral" },
|
|
},
|
|
);
|
|
|
|
export function Badge({
|
|
className,
|
|
tone,
|
|
...props
|
|
}: HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>) {
|
|
return <span className={cn(badgeVariants({ tone }), className)} {...props} />;
|
|
}
|
|
|
|
export function PageHeader({
|
|
eyebrow,
|
|
title,
|
|
description,
|
|
actions,
|
|
}: {
|
|
eyebrow: string;
|
|
title: string;
|
|
description: string;
|
|
actions?: ReactNode;
|
|
}) {
|
|
return (
|
|
<header className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
|
|
<div>
|
|
<p className="mb-2 text-xs font-bold uppercase tracking-[0.18em] text-primary">
|
|
{eyebrow}
|
|
</p>
|
|
<h1 className="text-balance text-3xl font-bold tracking-[-0.045em] text-foreground sm:text-4xl">
|
|
{title}
|
|
</h1>
|
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted">{description}</p>
|
|
</div>
|
|
{actions ? <div className="shrink-0">{actions}</div> : null}
|
|
</header>
|
|
);
|
|
}
|
|
|
|
export function LoadingState({ label = "正在加载" }: { label?: string }) {
|
|
return (
|
|
<div className="grid min-h-72 place-items-center" role="status" aria-live="polite">
|
|
<div className="flex flex-col items-center gap-3 text-sm text-muted">
|
|
<span className="grid size-11 place-items-center rounded-2xl bg-primary-soft text-primary">
|
|
<LoaderCircle className="size-5 animate-spin" aria-hidden />
|
|
</span>
|
|
{label}…
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function EmptyState({
|
|
title = "暂无数据",
|
|
description,
|
|
}: {
|
|
title?: string;
|
|
description?: string;
|
|
}) {
|
|
return (
|
|
<div className="grid min-h-56 place-items-center px-6 text-center">
|
|
<div>
|
|
<span className="mx-auto mb-4 grid size-11 place-items-center rounded-2xl bg-surface-muted text-muted">
|
|
<Inbox className="size-5" aria-hidden />
|
|
</span>
|
|
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
|
|
{description ? <p className="mt-1 text-sm text-muted">{description}</p> : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ErrorState({
|
|
error,
|
|
retry,
|
|
}: {
|
|
error: unknown;
|
|
retry?: () => void;
|
|
}) {
|
|
const message = error instanceof Error ? error.message : "出现未知错误,请稍后重试";
|
|
return (
|
|
<Card className="mx-auto grid min-h-72 max-w-lg place-items-center border-danger/20 p-8 text-center">
|
|
<div>
|
|
<span className="mx-auto mb-4 grid size-11 place-items-center rounded-2xl bg-danger-soft text-danger">
|
|
<AlertTriangle className="size-5" aria-hidden />
|
|
</span>
|
|
<h2 className="font-semibold text-foreground">无法加载数据</h2>
|
|
<p className="mt-2 text-sm text-muted">{message}</p>
|
|
{retry ? (
|
|
<Button className="mt-5" variant="secondary" onClick={retry}>
|
|
<RefreshCw className="size-4" aria-hidden />
|
|
重试
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export function Dialog({
|
|
open,
|
|
onOpenChange,
|
|
title,
|
|
description,
|
|
children,
|
|
preventClose = false,
|
|
className,
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
title: string;
|
|
description?: string;
|
|
children: ReactNode;
|
|
preventClose?: boolean;
|
|
className?: string;
|
|
}) {
|
|
return (
|
|
<BaseDialog.Root
|
|
open={open}
|
|
onOpenChange={(nextOpen) => {
|
|
if (!nextOpen && preventClose) return;
|
|
onOpenChange(nextOpen);
|
|
}}
|
|
>
|
|
<BaseDialog.Portal>
|
|
<BaseDialog.Backdrop className="fixed inset-0 z-50 bg-slate-950/45 backdrop-blur-[3px] transition-opacity data-ending-style:opacity-0 data-starting-style:opacity-0" />
|
|
<BaseDialog.Viewport className="fixed inset-0 z-50 grid place-items-center overflow-y-auto p-4">
|
|
<BaseDialog.Popup
|
|
className={cn(
|
|
"relative my-8 w-full max-w-lg rounded-3xl border border-white/10 bg-surface-elevated p-6 shadow-2xl outline-none transition-all data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0 sm:p-8",
|
|
className,
|
|
)}
|
|
>
|
|
{!preventClose ? (
|
|
<BaseDialog.Close
|
|
className="absolute right-4 top-4 grid size-9 place-items-center rounded-xl text-muted transition hover:bg-surface-muted hover:text-foreground"
|
|
aria-label="关闭"
|
|
>
|
|
<X className="size-4" aria-hidden />
|
|
</BaseDialog.Close>
|
|
) : null}
|
|
<BaseDialog.Title className="pr-10 text-xl font-bold tracking-tight text-foreground">
|
|
{title}
|
|
</BaseDialog.Title>
|
|
{description ? (
|
|
<BaseDialog.Description className="mt-2 text-sm leading-6 text-muted">
|
|
{description}
|
|
</BaseDialog.Description>
|
|
) : null}
|
|
<div className="mt-6">{children}</div>
|
|
</BaseDialog.Popup>
|
|
</BaseDialog.Viewport>
|
|
</BaseDialog.Portal>
|
|
</BaseDialog.Root>
|
|
);
|
|
}
|
|
|
|
export function StatCard({
|
|
label,
|
|
value,
|
|
hint,
|
|
icon: Icon,
|
|
tone = "primary",
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
hint: string;
|
|
icon: LucideIcon;
|
|
tone?: "primary" | "success" | "violet" | "warning";
|
|
}) {
|
|
return (
|
|
<Card className="group relative overflow-hidden p-5 sm:p-6">
|
|
<div className={`stat-glow stat-glow--${tone}`} aria-hidden />
|
|
<div className="relative">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<p className="text-sm font-medium text-muted">{label}</p>
|
|
<span className={`stat-icon stat-icon--${tone}`}>
|
|
<Icon className="size-4" aria-hidden />
|
|
</span>
|
|
</div>
|
|
<strong className="mt-5 block text-3xl font-bold tracking-[-0.05em] text-foreground tabular-nums">
|
|
{value}
|
|
</strong>
|
|
<p className="mt-2 text-xs text-muted">{hint}</p>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|