Files
OSGAccountServer/admin-web/src/components/primitives.tsx
T
Rocky 231c5040a5
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled
Add StoreKit history and modernize admin console
Expose ledger-backed cross-device purchase history while shipping the tested React admin redesign in the same reproducible deployment revision.
2026-08-19 22:13:13 +08:00

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>
);
}