refactor: use original dashboard UI primitives
This commit is contained in:
+71
-540
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user