refactor: use original dashboard UI primitives

This commit is contained in:
2026-09-20 17:14:13 +08:00
parent a25e84ac14
commit 59f170c191
32 changed files with 1981 additions and 2179 deletions
+71 -540
View File
@@ -1,11 +1,21 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { createPortal } from "react-dom";
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import { Alert } from "../../components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "../../components/ui/alert-dialog";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { Card, CardContent } from "../../components/ui/card";
import { cn } from "../../lib/utils";
export function cn(...inputs) {
return twMerge(clsx(inputs));
}
export { cn };
export function apiMessage(error) {
return error?.message || "操作失败";
@@ -21,9 +31,6 @@ export function conflictMessage(error, fallback) {
export const dateTime = (value) => new Date(value).toLocaleString("zh-CN");
/**
* 页面加载/错误/空态的统一外壳。
*/
export function PageState({
pending,
error,
@@ -40,7 +47,7 @@ export function PageState({
role="status"
aria-label="正在加载"
>
<i className="ri-loader-4-line animate-spin text-2xl text-muted" />
<i className="ri-loader-4-line animate-spin text-2xl text-muted-foreground" />
</div>
);
}
@@ -64,97 +71,13 @@ export function EmptyState({ text, children }) {
return (
<Card>
<CardContent className="py-14 text-center">
<p className="text-sm text-muted">{text}</p>
<p className="text-sm text-muted-foreground">{text}</p>
{children}
</CardContent>
</Card>
);
}
export function Alert({ variant = "info", className, children, ...props }) {
const styles = {
info: "text-muted bg-[#f5f7fa] border-hairline",
success: "text-success bg-[#f0f9f4] border-[#b6dfc8]",
warning: "text-warning bg-[#fdf6ec] border-[#eedcbb]",
destructive: "text-danger bg-[#fdf1f0] border-[#f1c7c2]",
}[variant];
return (
<div
role="alert"
className={cn(
"flex flex-wrap items-center gap-3 rounded-lg border px-4 py-3 text-sm",
styles,
className,
)}
{...props}
>
{children}
</div>
);
}
const buttonVariants = {
primary: "bg-primary text-white hover:bg-primary-dark disabled:bg-primary/50",
outline:
"border border-[#c4ccd9] bg-white text-ink hover:bg-[#f5f7fa] disabled:text-muted/60 disabled:border-hairline disabled:bg-white",
ghost: "text-primary hover:bg-primary/10 disabled:text-primary/50",
danger:
"border border-[#e4b7b2] bg-white text-danger hover:bg-[#fdf1f0] disabled:text-danger/50",
};
export function Button({
variant = "outline",
size = "md",
type = "button",
icon,
as: Component = "button",
className,
busy,
busyText,
children,
...props
}) {
const sizes = { sm: "h-8 px-3 text-xs", md: "h-10 px-4 text-sm" };
return (
<Component
type={Component === "button" ? type : undefined}
className={cn(
"inline-flex shrink-0 items-center justify-center gap-1.5 rounded-md font-medium no-underline transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:pointer-events-none",
sizes[size],
buttonVariants[variant],
className,
)}
{...props}
disabled={busy || props.disabled}
>
{busy ? (
busyText || "处理中…"
) : (
<>
{icon ? <i className={icon} aria-hidden="true" /> : null}
{children}
</>
)}
</Component>
);
}
export function Card({ className, ...props }) {
return (
<div
className={cn(
"min-w-0 rounded-lg border border-hairline bg-white",
className,
)}
{...props}
/>
);
}
export function CardContent({ className, ...props }) {
return <div className={cn("p-5", className)} {...props} />;
}
export function PageHeader({ icon, title, description, children }) {
return (
<header className="mb-7 flex flex-wrap items-start justify-between gap-4">
@@ -164,7 +87,7 @@ export function PageHeader({ icon, title, description, children }) {
{title}
</h1>
{description ? (
<p className="mt-1.5 text-sm text-muted">{description}</p>
<p className="mt-1.5 text-sm text-muted-foreground">{description}</p>
) : null}
</div>
{children ? (
@@ -174,9 +97,6 @@ export function PageHeader({ icon, title, description, children }) {
);
}
/**
* 受控 Input / Select / Textarea。label 关联控件 id;helper 与 error 文案同槽位。
*/
export function Field({
id,
label,
@@ -187,24 +107,23 @@ export function Field({
className,
}) {
const helpId = helper || error ? `${id}-help` : undefined;
const control = children;
return (
<div className={cn("min-w-0 space-y-1.5", className)}>
<label htmlFor={id} className="block text-sm font-medium text-ink">
<label htmlFor={id} className="block text-sm font-medium text-foreground">
{label}{" "}
{required ? (
<span className="text-danger" aria-hidden="true">
<span className="text-destructive" aria-hidden="true">
*
</span>
) : null}
</label>
{control}
{children}
{error ? (
<p id={`${id}-error`} className="text-xs text-danger">
<p id={`${id}-error`} className="text-xs text-destructive">
{error}
</p>
) : helper ? (
<p id={helpId} className="text-xs text-muted">
<p id={helpId} className="text-xs text-muted-foreground">
{helper}
</p>
) : null}
@@ -212,37 +131,6 @@ export function Field({
);
}
export function Input({ invalid, className, ...props }) {
return (
<input
className={cn(
"h-10 w-full rounded-md border bg-white px-3 text-sm text-ink placeholder:text-muted/60 focus:border-primary focus:outline-none disabled:bg-[#f5f7fa] disabled:text-muted",
invalid ? "border-danger" : "border-[#c4ccd9]",
className,
)}
aria-invalid={invalid || undefined}
{...props}
/>
);
}
export function Textarea({ invalid, className, ...props }) {
return (
<textarea
className={cn(
"min-h-[96px] w-full rounded-md border bg-white px-3 py-2 text-sm text-ink placeholder:text-muted/60 focus:border-primary focus:outline-none disabled:bg-[#f5f7fa] disabled:text-muted",
invalid ? "border-danger" : "border-[#c4ccd9]",
className,
)}
aria-invalid={invalid || undefined}
{...props}
/>
);
}
/**
* TAG 标签输入:回车或逗号确认一个胶囊,可退格删除;label 通过 id 关联内层输入框。
*/
export function TagInput({
id,
value,
@@ -273,25 +161,26 @@ export function TagInput({
return (
<div
className={cn(
"flex min-h-10 w-full flex-wrap items-center gap-1.5 rounded-md border bg-white px-2 py-1.5 text-sm focus-within:border-primary",
invalid ? "border-danger" : "border-[#c4ccd9]",
"flex min-h-10 w-full flex-wrap items-center gap-1.5 rounded-md border bg-card px-2 py-1.5 text-sm focus-within:border-ring",
invalid ? "border-destructive" : "border-input",
className,
)}
>
{value.map((tag) => (
<span
key={tag}
className="inline-flex max-w-full items-center gap-1 rounded bg-[#eef5ff] pl-2 pr-1 py-0.5 text-xs text-primary"
className="inline-flex max-w-full items-center gap-1 rounded bg-secondary pl-2 pr-1 py-0.5 text-xs text-secondary-foreground"
>
<span className="anywhere">{tag}</span>
<button
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={`移除标签 ${tag}`}
onClick={() => onChange(value.filter((item) => item !== tag))}
className="shrink-0 rounded p-0.5 hover:bg-primary/10"
>
<i className="ri-close-line" aria-hidden="true" />
</button>
</Button>
</span>
))}
<input
@@ -302,234 +191,13 @@ export function TagInput({
onBlur={() => commit(draft)}
placeholder={value.length === 0 ? placeholder : undefined}
aria-invalid={invalid || undefined}
className="min-w-12 flex-1 border-0 bg-transparent p-1 text-sm text-ink placeholder:text-muted/60 focus:outline-none"
className="min-w-12 flex-1 border-0 bg-transparent p-1 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
{...props}
/>
</div>
);
}
export function Select({
id,
value,
onChange,
options,
placeholder = "请选择",
emptyOption,
disabled,
invalid,
className,
}) {
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const root = useRef(null);
const selected =
options.find((option) => option.value === value) ||
(value === "" ? null : { value, label: value });
const label = selected ? selected.label : placeholder;
const items =
emptyOption === undefined
? options
: [{ value: emptyOption.value, label: emptyOption.label }, ...options];
const close = () => {
setOpen(false);
setActive(-1);
};
const commit = (item) => {
onChange({ target: { value: item.value } });
close();
root.current?.querySelector("button")?.focus();
};
useEffect(() => {
if (!open) return;
const onDocClick = (event) => {
if (!root.current?.contains(event.target)) close();
};
document.addEventListener("mousedown", onDocClick);
return () => document.removeEventListener("mousedown", onDocClick);
}, [open]);
const handleKeyDown = (event) => {
if (event.key === "Escape") {
event.stopPropagation();
close();
return;
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (!open) {
setOpen(true);
setActive(items.findIndex((item) => item.value === value));
return;
}
setActive((current) => {
const next =
event.key === "ArrowDown"
? Math.min(current + 1, items.length - 1)
: Math.max(current - 1, 0);
return next;
});
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (open) {
const item = items[Math.max(active, 0)];
if (item) commit(item);
} else {
setOpen(true);
setActive(
Math.max(
items.findIndex((item) => item.value === value),
0,
),
);
}
return;
}
if (event.key === "Home") setActive(0);
if (event.key === "End") setActive(items.length - 1);
};
return (
<div
className={cn("relative", className)}
ref={root}
onKeyDown={handleKeyDown}
>
<button
type="button"
id={id}
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={`${id}-listbox`}
aria-activedescendant={
open && active >= 0 ? `${id}-option-${active}` : undefined
}
aria-invalid={invalid || undefined}
disabled={disabled}
onClick={() => {
setOpen((value) => !value);
setActive(items.findIndex((item) => item.value === value));
}}
className={cn(
"flex h-10 w-full items-center justify-between gap-2 rounded-md border bg-white px-3 text-left text-sm focus:border-primary focus:outline-none disabled:bg-[#f5f7fa] disabled:text-muted",
invalid ? "border-danger" : "border-[#c4ccd9]",
label === placeholder && "text-muted",
)}
>
<span className="truncate">{label}</span>
<i
className="ri-arrow-down-s-line shrink-0 text-muted"
aria-hidden="true"
/>
</button>
{open ? (
<ul
role="listbox"
id={`${id}-listbox`}
aria-labelledby={id}
className="absolute z-20 mt-1 max-h-64 w-full overflow-auto rounded-md border border-hairline bg-white py-1 shadow-lg"
>
{items.map((item, index) => (
<li
key={item.value}
id={`${id}-option-${index}`}
role="option"
data-value={item.value}
aria-selected={value === item.value}
className={cn(
"cursor-pointer px-3 py-1.5 text-sm",
value === item.value
? "bg-primary/10 font-medium"
: "hover:bg-[#f5f7fa]",
active === index && "bg-primary/10",
)}
onClick={() => commit(item)}
>
{item.label}
</li>
))}
</ul>
) : null}
</div>
);
}
/**
* Modal:radix Dialog 风格的轻实现。
*/
export function Modal({
open,
onClose,
title,
children,
footer,
labelledBy,
size = "md",
}) {
const restoreFocus = useRef(null);
useEffect(() => {
if (open) {
restoreFocus.current = document.activeElement;
return;
}
// 关闭时把焦点还给触发元素(与 MUI/Radix Dialog 行为一致,键盘用户不会丢失位置)
const trigger = restoreFocus.current;
restoreFocus.current = null;
if (trigger && trigger.isConnected && typeof trigger.focus === "function")
trigger.focus();
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (event) => {
if (event.key === "Escape") onClose?.();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
const widths = { sm: "max-w-md", md: "max-w-lg", lg: "max-w-2xl" };
return createPortal(
<div
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 p-4 sm:items-center"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose?.();
}}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby={labelledBy}
className={cn(
"relative w-full rounded-lg border border-hairline bg-white shadow-xl",
widths[size],
)}
>
<div className="flex items-start justify-between border-b border-hairline px-5 py-4">
<h2 id={labelledBy} className="min-w-0 text-base font-semibold">
{title}
</h2>
<button
type="button"
aria-label="关闭"
onClick={onClose}
className="rounded-md p-1 text-muted hover:bg-[#f5f7fa]"
>
<i className="ri-close-line" aria-hidden="true" />
</button>
</div>
<div className="max-h-[70vh] overflow-y-auto px-5 py-4">{children}</div>
{footer ? (
<div className="flex justify-end gap-3 border-t border-hairline px-5 py-3.5">
{footer}
</div>
) : null}
</div>
</div>,
document.body,
);
}
export function ConfirmDialog({
open,
onClose,
@@ -540,124 +208,58 @@ export function ConfirmDialog({
busy,
}) {
return (
<Modal
<AlertDialog
open={open}
onClose={busy ? undefined : onClose}
title={title}
size="sm"
labelledBy="confirm-title"
footer={
<>
<Button onClick={onClose} disabled={busy}>
取消
</Button>
<Button variant="primary" busy={busy} onClick={onConfirm}>
{confirmLabel}
</Button>
</>
}
onOpenChange={(nextOpen) => {
if (!nextOpen && !busy) onClose?.();
}}
>
<p className="text-sm text-ink">{body}</p>
</Modal>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{body}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy} onClick={onClose}>
取消
</AlertDialogCancel>
<AlertDialogAction disabled={busy} onClick={onConfirm}>
{busy ? "处理中…" : confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
export function Copyable({ value, className }) {
const copy = () => navigator.clipboard?.writeText(value);
return (
<span
className={cn(
"inline-flex min-w-0 items-center gap-1 text-sm",
className,
)}
>
<span className={cn("inline-flex min-w-0 items-center gap-1 text-sm", className)}>
<code className="anywhere font-mono text-[13px]">{value}</code>
<button
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={`复制 ${value}`}
onClick={copy}
className="shrink-0 rounded p-0.5 text-muted hover:text-primary"
onClick={() => navigator.clipboard?.writeText(value)}
>
<i className="ri-file-copy-line text-[13px]" aria-hidden="true" />
</button>
<i className="ri-file-copy-line" aria-hidden="true" />
</Button>
</span>
);
}
export function Checkbox({ id, checked, onCheckedChange, disabled, children }) {
return (
<label
htmlFor={id}
className="flex cursor-pointer items-start gap-2 text-sm"
>
<input
type="checkbox"
id={id}
checked={checked}
disabled={disabled}
onChange={(event) => onCheckedChange(event.target.checked)}
className="mt-0.5 size-4 accent-primary"
/>
<span className="min-w-0">{children}</span>
</label>
);
}
export function Switch({
id,
checked,
onCheckedChange,
disabled,
"aria-label": ariaLabel,
}) {
return (
<label
htmlFor={id}
className="inline-flex cursor-pointer items-center gap-2 text-sm"
>
<input
type="checkbox"
id={id}
role="switch"
aria-label={ariaLabel}
checked={checked}
disabled={disabled}
onChange={(event) => onCheckedChange(event.target.checked)}
className="sr-only"
/>
<span
className={cn(
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
checked ? "bg-primary" : "bg-[#c4ccd9]",
)}
>
<span
className={cn(
"absolute left-0.5 size-4 rounded-full bg-white transition-transform",
checked && "translate-x-4",
)}
/>
</span>
</label>
);
}
// 枚举胶囊:运行状态、健康状态、任务状态等。
export function StatusPill({ tone, label, sub }) {
const tones = {
success: "text-success bg-[#f0f9f4]",
info: "text-primary bg-[#eef5ff]",
warning: "text-warning bg-[#fdf6ec]",
danger: "text-danger bg-[#fdf1f0]",
neutral: "text-muted bg-[#f5f7fa]",
}[tone];
const variant =
tone === "danger"
? "destructive"
: tone === "neutral"
? "outline"
: tone === "info"
? "secondary"
: "default";
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium",
tones,
)}
>
<Badge variant={variant}>
<i
className={
tone === "success"
@@ -671,79 +273,8 @@ export function StatusPill({ tone, label, sub }) {
aria-hidden="true"
/>
{label}
{sub ? (
<span className="text-[10px] font-normal opacity-75">{sub}</span>
) : null}
</span>
);
}
/**
* 紧凑表格:列头 + 行渲染。资源域信息密度高,适合 table。列过宽时横向滚动。
*/
export function Table({
columns,
rows,
rowKey,
minWidth = "900px",
onRowClick,
className,
}) {
return (
<div
className={cn(
"overflow-x-auto rounded-lg border border-hairline bg-white",
className,
)}
>
<table className={cn("w-full table-fixed text-sm")} style={{ minWidth }}>
<thead>
<tr className="border-b border-hairline text-left text-xs text-muted">
{columns.map((column) => (
<th
key={column.header}
style={{ width: column.width }}
className={cn(
"px-4 py-2.5 font-medium",
column.align === "right" && "text-right",
)}
>
{column.header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => {
const clickable = onRowClick
? "cursor-pointer hover:bg-[#f7f8fa]"
: "";
return (
<tr
key={rowKey(row)}
className={cn(
"border-b border-hairline last:border-0",
clickable,
)}
onClick={onRowClick ? () => onRowClick(row) : undefined}
>
{columns.map((column) => (
<td
key={column.header}
className={cn(
"px-4 py-3",
column.align === "right" && "text-right",
)}
>
{column.render(row)}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
{sub ? <span className="text-[10px] font-normal opacity-75">{sub}</span> : null}
</Badge>
);
}
@@ -752,7 +283,7 @@ export function DetailList({ rows }) {
<dl className="space-y-2.5 text-sm">
{rows.map(([term, value]) => (
<div key={term} className="flex min-w-0 flex-col gap-0.5">
<dt className="text-muted">{term}</dt>
<dt className="text-muted-foreground">{term}</dt>
<dd className="anywhere">{value ?? "—"}</dd>
</div>
))}