深度优化 ui
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
import { useRef, useState, type ComponentProps, type ReactNode } from "react";
|
||||
import { useFormStatus } from "react-dom";
|
||||
|
||||
function FormFields({ children }: { children: ReactNode }) {
|
||||
const { pending } = useFormStatus();
|
||||
return (
|
||||
<fieldset disabled={pending} style={{ display: "contents" }}>
|
||||
{children}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
/** Keeps action errors beside the submitted form, without losing entered values. */
|
||||
export function ActionForm({
|
||||
action,
|
||||
children,
|
||||
...props
|
||||
}: Omit<ComponentProps<"form">, "action"> & {
|
||||
action: (data: FormData) => Promise<void>;
|
||||
}) {
|
||||
const preserveValues = useRef(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [failed, setFailed] = useState(false);
|
||||
return (
|
||||
<form
|
||||
{...props}
|
||||
onReset={(event) => {
|
||||
if (preserveValues.current) event.preventDefault();
|
||||
}}
|
||||
onChange={() => setMessage("")}
|
||||
onKeyDown={(event) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const button = event.currentTarget.querySelector<HTMLButtonElement>(
|
||||
'button[type="submit"]',
|
||||
);
|
||||
if (button && !button.disabled) button.click();
|
||||
}
|
||||
}}
|
||||
action={async (data) => {
|
||||
setMessage("");
|
||||
preserveValues.current = false;
|
||||
try {
|
||||
await action(data);
|
||||
setFailed(false);
|
||||
setMessage("已保存");
|
||||
} catch {
|
||||
preserveValues.current = true;
|
||||
setFailed(true);
|
||||
setMessage("操作失败,请检查输入后重试。填写内容已保留。");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FormFields>{children}</FormFields>
|
||||
{message && (
|
||||
<p
|
||||
role={failed ? "alert" : "status"}
|
||||
className={`full-span ${failed ? "error-text" : "muted"}`}
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
export function CopyButton({ value }: { value: string }) {
|
||||
const [status, setStatus] = useState<"idle" | "copied" | "error">("idle");
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setStatus("copied");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{status === "copied" ? <Check /> : <Copy />}
|
||||
{status === "copied" ? "已复制" : "复制"}
|
||||
</Button>
|
||||
<span role="status" className="muted">
|
||||
{status === "error" ? "复制失败,请选中链接手动复制" : ""}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
import { useId, useState, type ReactNode } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type Item = {
|
||||
id: string | number;
|
||||
text: string;
|
||||
status: string;
|
||||
content: ReactNode;
|
||||
};
|
||||
export function FilterList({
|
||||
items,
|
||||
header,
|
||||
className,
|
||||
placeholder,
|
||||
statuses,
|
||||
}: {
|
||||
items: Item[];
|
||||
header: ReactNode;
|
||||
className: string;
|
||||
placeholder: string;
|
||||
statuses: { value: string; label: string }[];
|
||||
}) {
|
||||
const id = useId();
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const visible = items.filter(
|
||||
(item) =>
|
||||
(!status || item.status === status) &&
|
||||
item.text.toLowerCase().includes(query.trim().toLowerCase()),
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<div className="list-toolbar">
|
||||
<div className="list-search">
|
||||
<Search size={16} />
|
||||
<Input
|
||||
type="search"
|
||||
aria-label={placeholder}
|
||||
placeholder={placeholder}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
aria-controls={id}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
aria-label="筛选状态"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
{statuses.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="muted" role="status">
|
||||
{visible.length} / {items.length} 条
|
||||
</span>
|
||||
{(query || status) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setStatus("");
|
||||
}}
|
||||
>
|
||||
清除筛选
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mobile-table-hint">
|
||||
左右滑动查看完整信息
|
||||
{className.includes("audits") ? "" : " · 点击行展开编辑"}
|
||||
</p>
|
||||
<div id={id} className={className}>
|
||||
{header}
|
||||
{items.map((item) => (
|
||||
<div key={item.id} hidden={!visible.includes(item)}>
|
||||
{item.content}
|
||||
</div>
|
||||
))}
|
||||
{!visible.length && (
|
||||
<p className="empty-state">
|
||||
{items.length
|
||||
? "没有匹配的结果,试试其他关键词或清除筛选。"
|
||||
: "暂无记录。"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, type ComponentProps } from "react";
|
||||
import { useFormStatus } from "react-dom";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
export function SubmitButton({
|
||||
children,
|
||||
confirm,
|
||||
...props
|
||||
}: ComponentProps<typeof Button> & { confirm?: string }) {
|
||||
const { pending } = useFormStatus();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
{...props}
|
||||
ref={ref}
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={pending || props.disabled}
|
||||
onClick={(event) => {
|
||||
if (confirm) {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{pending && <Loader2 className="animate-spin" aria-hidden="true" />}
|
||||
{pending ? "处理中…" : children}
|
||||
</Button>
|
||||
{confirm && (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认{children}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>{confirm}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => ref.current?.form?.requestSubmit(ref.current)}
|
||||
>
|
||||
确认{children}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
|
||||
|
||||
import { Button } 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(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
|
||||
size?: "default" | "sm";
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/alert-dialog-content 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 bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn(
|
||||
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-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 sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogMedia({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn(
|
||||
"mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Action
|
||||
data-slot="alert-dialog-action"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Cancel
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Slot } from "radix-ui";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
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:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"border bg-background hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 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",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-sm transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
|
||||
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
Reference in New Issue
Block a user