feat: redesign responsive console and fix restore regressions

This commit is contained in:
2026-10-15 01:08:55 +08:00
parent 3bf197206a
commit 56c8f76e32
49 changed files with 3455 additions and 447 deletions
+71
View File
@@ -0,0 +1,71 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { DialogRoot, DialogContent, DialogFooter } from './ui/dialog';
import { Input } from './ui/input';
import { buttonVariants } from './ui/button';
import { cn } from '../lib/utils';
export interface ConfirmOptions {
title: string;
body?: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
danger?: boolean;
/** When set, the confirm button stays disabled until this exact text is typed. */
requireText?: string;
requireTextLabel?: string;
}
interface ConfirmRequest {
options: ConfirmOptions;
resolve: (ok: boolean) => void;
}
export function ConfirmDialog({request, onClose}: {request: ConfirmRequest | undefined; onClose: (ok: boolean) => void}) {
const [typed, setTyped] = useState('');
useEffect(() => { setTyped(''); }, [request]);
const options = request?.options;
const needsText = !!options?.requireText;
const confirmed = !needsText || typed === options?.requireText;
return <DialogRoot open={!!request} onOpenChange={open => { if (!open) onClose(false); }}>
{options && <DialogContent title={options.title} onOpenAutoFocus={e => { if (!needsText) return; e.preventDefault(); }}>
{options.body && <div className="mb-4 text-[13px] leading-relaxed text-muted-foreground wrap-anywhere">{options.body}</div>}
{needsText && <label className="mb-1 grid gap-1.5 text-[13px] font-medium">
{options.requireTextLabel || '输入完整标识以确认'}
<Input value={typed} onChange={e => setTyped(e.target.value)} autoFocus autoComplete="off" spellCheck={false} className="font-mono" placeholder={options.requireText}/>
</label>}
<DialogFooter>
<button type="button" className={cn(buttonVariants({variant: 'outline'}))} onClick={() => onClose(false)}>{options.cancelLabel || '取消'}</button>
<button type="button" disabled={!confirmed} className={cn(buttonVariants({variant: options.danger ? 'destructive' : 'default'}))} onClick={() => onClose(true)}>{options.confirmLabel || '确认'}</button>
</DialogFooter>
</DialogContent>}
</DialogRoot>;
}
/**
* Promise-based replacement for window.confirm / window.prompt(confirm-id).
* Render {confirm.element} once near the page root; ask() resolves true/false.
* Plain functions (no useCallback) keep it usable in bare vnode-tree tests.
*/
export function useConfirm() {
const [request, setRequest] = useState<ConfirmRequest>();
// A ref tracks the live request so back-to-back ask() calls (e.g. a fast
// double submit before re-render) cancel the previous promise instead of
// leaking it. Direct value sets keep bare vnode-tree tests working.
const current = useRef<ConfirmRequest | undefined>(undefined);
useEffect(() => () => {
current.current?.resolve(false);
current.current = undefined;
}, []);
const ask = (options: ConfirmOptions) => new Promise<boolean>(resolve => {
current.current?.resolve(false);
const next: ConfirmRequest = {options, resolve};
current.current = next;
setRequest(next);
});
const close = (ok: boolean) => {
current.current?.resolve(ok);
current.current = undefined;
setRequest(undefined);
};
return {ask, element: <ConfirmDialog request={request} onClose={close}/>};
}
+31
View File
@@ -0,0 +1,31 @@
import { cva, type VariantProps } from "class-variance-authority";
import { AlertCircle, Info, type LucideIcon } from "lucide-react";
import type { HTMLAttributes } from "react";
import { cn } from "../../lib/utils";
const alertVariants = cva("flex items-start gap-2.5 rounded-lg border px-3.5 py-3 text-[13px] leading-relaxed [&_svg]:mt-0.5 [&_svg]:size-4 [&_svg]:shrink-0", {
variants: {
variant: {
destructive: "border-destructive/30 bg-destructive-muted text-destructive",
info: "border-primary/25 bg-secondary/60 text-secondary-foreground",
neutral: "border-border bg-muted/70 text-muted-foreground",
},
},
defaultVariants: { variant: "neutral" },
});
const icons: Record<string, LucideIcon> = { destructive: AlertCircle, info: Info, neutral: Info };
export interface AlertProps extends HTMLAttributes<HTMLDivElement>, VariantProps<typeof alertVariants> {
hideIcon?: boolean;
}
export function AlertBox({ className, variant = "neutral", hideIcon = false, children, ...props }: AlertProps) {
const Icon = icons[variant || "neutral"];
return (
<div role={variant === "destructive" ? "alert" : "status"} className={cn(alertVariants({ variant }), className)} {...props}>
{!hideIcon && <Icon aria-hidden />}
<div className="min-w-0 flex-1 wrap-anywhere">{children}</div>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { cva, type VariantProps } from "class-variance-authority";
import type { HTMLAttributes } from "react";
import { cn } from "../../lib/utils";
const badgeVariants = cva(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium whitespace-nowrap before:size-1.5 before:rounded-full before:bg-current",
{
variants: {
variant: {
default: "bg-muted text-muted-foreground",
success: "bg-success-muted text-success",
danger: "bg-destructive-muted text-destructive",
warning: "bg-warning-muted text-warning",
},
},
defaultVariants: { variant: "default" },
},
);
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {}
export function Badge({ className, variant, ...props }: BadgeProps) {
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
}
+43
View File
@@ -0,0 +1,43 @@
import { forwardRef, type ButtonHTMLAttributes } 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(
"inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md font-medium transition-colors cursor-pointer select-none disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary-hover",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/70",
outline: "border border-border bg-card text-foreground shadow-sm hover:bg-muted",
ghost: "text-foreground hover:bg-muted",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
"destructive-ghost": "text-destructive border border-border bg-card hover:bg-destructive-muted hover:border-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
// Pixel sizes remain correct with the application's 14px root font.
default: "h-[44px] md:h-[36px] px-4 text-sm",
sm: "h-[44px] md:h-[32px] px-3 text-[13px]",
lg: "h-[44px] px-5 text-[15px]",
icon: "h-[44px] w-[44px] md:h-[36px] md:w-[36px] p-0",
},
},
defaultVariants: { variant: "default", size: "default" },
},
);
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ className, variant, size, asChild = false, ...props },
ref,
) {
const Comp = asChild ? Slot : "button";
return <Comp ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props} />;
});
export { buttonVariants };
+26
View File
@@ -0,0 +1,26 @@
import type { HTMLAttributes } from "react";
import { cn } from "../../lib/utils";
export function Card({ className, ...props }: HTMLAttributes<HTMLElement>) {
return <section className={cn("rounded-xl border border-border bg-card text-card-foreground shadow-card", className)} {...props} />;
}
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-wrap items-center justify-between gap-3 px-4 pt-4 pb-3 sm:px-5", className)} {...props} />;
}
export function CardTitle({ className, ...props }: HTMLAttributes<HTMLHeadingElement>) {
return <h2 className={cn("text-[15px] font-semibold leading-none", className)} {...props} />;
}
export function CardDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn("text-[13px] text-muted-foreground", className)} {...props} />;
}
export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("px-4 pb-4 sm:px-5 sm:pb-5", className)} {...props} />;
}
export function CardFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex items-center gap-2 border-t border-border px-4 py-3 sm:px-5", className)} {...props} />;
}
+56
View File
@@ -0,0 +1,56 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { useRef, type ComponentProps, type ReactNode } from "react";
import { cn } from "../../lib/utils";
export const DialogRoot = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger;
export const DialogClose = DialogPrimitive.Close;
export function DialogContent({
className,
children,
title,
onOpenAutoFocus,
onCloseAutoFocus,
...props
}: ComponentProps<typeof DialogPrimitive.Content> & { title: string; children: ReactNode }) {
const opener = useRef<HTMLElement | null>(null);
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-40 bg-nav/60 backdrop-blur-[2px] data-[state=open]:animate-[overlay-in_0.15s_ease-out]" />
<DialogPrimitive.Content
aria-describedby={undefined}
onOpenAutoFocus={event => {
opener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
onOpenAutoFocus?.(event);
}}
onCloseAutoFocus={event => {
onCloseAutoFocus?.(event);
if (!event.defaultPrevented && opener.current?.isConnected) {
event.preventDefault();
opener.current.focus();
}
}}
aria-busy={props["aria-busy"]}
className={cn(
"fixed left-1/2 top-1/2 z-50 w-[min(30rem,calc(100vw-1.5rem))] max-h-[calc(100dvh-1.5rem)] -translate-x-1/2 -translate-y-1/2 overflow-auto rounded-xl border border-border bg-card p-5 text-card-foreground shadow-dialog data-[state=open]:animate-[dialog-in_0.18s_ease-out]",
className,
)}
{...props}
>
<div className="mb-4 flex items-start justify-between gap-4">
<DialogPrimitive.Title className="text-base font-semibold wrap-anywhere">{title}</DialogPrimitive.Title>
<DialogPrimitive.Close disabled={props['aria-busy'] === true} className="grid size-[44px] shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring" aria-label="关闭对话框">
<X className="size-4" aria-hidden />
</DialogPrimitive.Close>
</div>
{children}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
);
}
export function DialogFooter({ className, ...props }: ComponentProps<"div">) {
return <div className={cn("mt-5 flex flex-col-reverse gap-2 border-t border-border pt-4 sm:flex-row sm:justify-end", className)} {...props} />;
}
+30
View File
@@ -0,0 +1,30 @@
import * as DropdownPrimitive from "@radix-ui/react-dropdown-menu";
import type { ComponentProps } from "react";
import { cn } from "../../lib/utils";
export const DropdownMenu = DropdownPrimitive.Root;
export const DropdownMenuTrigger = DropdownPrimitive.Trigger;
export function DropdownMenuContent({ className, ...props }: ComponentProps<typeof DropdownPrimitive.Content>) {
return (
<DropdownPrimitive.Portal>
<DropdownPrimitive.Content
sideOffset={6}
className={cn("z-50 min-w-44 rounded-lg border border-border bg-card p-1 text-card-foreground shadow-dialog data-[state=open]:animate-[overlay-in_0.12s_ease-out]", className)}
{...props}
/>
</DropdownPrimitive.Portal>
);
}
export function DropdownMenuItem({ className, ...props }: ComponentProps<typeof DropdownPrimitive.Item>) {
return (
<DropdownPrimitive.Item
className={cn(
"flex cursor-pointer select-none items-center gap-2 rounded-md px-2.5 py-2.5 sm:py-2 text-[13px] outline-none transition-colors data-[highlighted]:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4 [&_svg]:text-muted-foreground",
className,
)}
{...props}
/>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { forwardRef, type InputHTMLAttributes, type TextareaHTMLAttributes, type SelectHTMLAttributes, type CSSProperties } from "react";
import { cn } from "../../lib/utils";
const fieldClass =
"w-full min-w-0 rounded-md border border-input bg-card px-3 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground/70 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-60 aria-invalid:border-destructive aria-invalid:outline-destructive";
/** Class constants for raw <input>/<select>/<textarea> elements.
* Pages prefer these over the wrapper components so vnode-tree tests keep
* matching native element types. */
export const inputClass = cn(fieldClass, "h-[44px] md:h-[36px]");
export const textareaClass = cn(fieldClass, "py-2 min-h-20");
export const selectClass = cn(fieldClass, "h-[44px] md:h-[36px] appearance-none pr-8 bg-no-repeat bg-[right_0.6rem_center] bg-[length:14px]");
export const selectStyle: CSSProperties = {
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23647d88' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E\")",
};
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(function Input({ className, ...props }, ref) {
return <input ref={ref} className={cn(inputClass, className)} {...props} />;
});
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement>>(function Textarea({ className, ...props }, ref) {
return <textarea ref={ref} className={cn(textareaClass, className)} {...props} />;
});
/** Native select: keeps the OS picker on phones, styled to match inputs. */
export const Select = forwardRef<HTMLSelectElement, SelectHTMLAttributes<HTMLSelectElement>>(function Select({ className, style, ...props }, ref) {
return <select ref={ref} className={cn(selectClass, className)} style={{...selectStyle, ...style}} {...props} />;
});
+10
View File
@@ -0,0 +1,10 @@
import type { LabelHTMLAttributes, ReactNode } from "react";
import { cn } from "../../lib/utils";
export function FieldLabel({ className, children, ...props }: LabelHTMLAttributes<HTMLLabelElement> & { children: ReactNode }) {
return (
<label className={cn("grid gap-1.5 text-[13px] font-medium text-foreground", className)} {...props}>
{children}
</label>
);
}
+30
View File
@@ -0,0 +1,30 @@
import * as TabsPrimitive from "@radix-ui/react-tabs";
import type { ComponentProps } from "react";
import { cn } from "../../lib/utils";
export const Tabs = TabsPrimitive.Root;
export function TabsList({ className, ...props }: ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
className={cn("inline-flex w-full max-w-full gap-1 overflow-x-auto rounded-lg bg-muted p-1", className)}
{...props}
/>
);
}
export function TabsTrigger({ className, ...props }: ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
className={cn(
"shrink-0 cursor-pointer rounded-md px-3 py-2 sm:py-1.5 text-[13px] font-medium whitespace-nowrap text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className,
)}
{...props}
/>
);
}
export function TabsContent({ className, ...props }: ComponentProps<typeof TabsPrimitive.Content>) {
return <TabsPrimitive.Content className={cn("mt-4 outline-none", className)} {...props} />;
}