+
{children}
- )
+ );
}
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',
-}
+ 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' }
+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 (
-
- {busy ? (busyText || '处理中…') : <>{icon ? : null}{children}>}
+
+ {busy ? (
+ busyText || "处理中…"
+ ) : (
+ <>
+ {icon ? : null}
+ {children}
+ >
+ )}
- )
+ );
}
export function Card({ className, ...props }) {
- return
+ return (
+
+ );
}
export function CardContent({ className, ...props }) {
- return
+ return ;
}
export function PageHeader({ icon, title, description, children }) {
@@ -95,89 +160,241 @@ export function PageHeader({ icon, title, description, children }) {
{icon ? : null}
{title}
- {description ? {description}
: null}
+ {description ? (
+ {description}
+ ) : null}
- {children ?
+
{control}
- {error ?
{error}
: helper ?
{helper}
: null}
+ {error ? (
+
+ {error}
+
+ ) : helper ? (
+
+ {helper}
+
+ ) : null}
- )
+ );
}
export function Input({ invalid, className, ...props }) {
- return
+ return (
+
+ );
}
export function Textarea({ invalid, className, ...props }) {
- return
+ return (
+
+ );
}
-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 ? [{ value: emptyOption.value, label: emptyOption.label }, ...options] : 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
+/**
+ * TAG 标签输入:回车或逗号确认一个胶囊,可退格删除;label 通过 id 关联内层输入框。
+ */
+export function TagInput({
+ id,
+ value,
+ onChange,
+ placeholder = "回车确认",
+ invalid,
+ className,
+ ...props
+}) {
+ const [draft, setDraft] = useState("");
+ const commit = (raw) => {
+ const tag = raw.trim();
+ setDraft("");
+ if (tag)
+ onChange(
+ value
+ .concat(tag)
+ .filter((item, index, all) => all.indexOf(item) === index),
+ );
+ };
+ const handleKeyDown = (event) => {
+ if (event.key === "Enter" || event.key === ",") {
+ event.preventDefault();
+ commit(draft);
+ } else if (event.key === "Backspace" && !draft && value.length > 0) {
+ event.preventDefault();
+ onChange(value.slice(0, -1));
}
- 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 (
-
+
+ {value.map((tag) => (
+
+ {tag}
+
+
+ ))}
+ setDraft(event.target.value)}
+ onKeyDown={handleKeyDown}
+ 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"
+ {...props}
+ />
+
+ );
+}
+
+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
+ ? [{ value: emptyOption.value, label: emptyOption.label }, ...options]
+ : 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 (
+
{open ? (
-
+
{items.map((item, index) => (
- - commit(item)}>{item.label}
+ - commit(item)}
+ >
+ {item.label}
+
))}
) : null}
- )
+ );
}
/**
* Modal:radix Dialog 风格的轻实现。
*/
-export function Modal({ open, onClose, title, children, footer, labelledBy, size = 'md' }) {
- const restoreFocus = useRef(null)
+export function Modal({
+ open,
+ onClose,
+ title,
+ children,
+ footer,
+ labelledBy,
+ size = "md",
+}) {
+ const restoreFocus = useRef(null);
useEffect(() => {
if (open) {
- restoreFocus.current = document.activeElement
- return
+ 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])
+ 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' }
+ 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(
-
{ if (event.target === event.currentTarget) onClose?.() }}>
-
+
{
+ if (event.target === event.currentTarget) onClose?.();
+ }}
+ >
+
-
{title}
-
+
+ {title}
+
+
{children}
- {footer ?
{footer}
: null}
+ {footer ? (
+
+ {footer}
+
+ ) : null}
,
document.body,
- )
+ );
}
-export function ConfirmDialog({ open, onClose, onConfirm, title, body, confirmLabel = '确认', busy }) {
+export function ConfirmDialog({
+ open,
+ onClose,
+ onConfirm,
+ title,
+ body,
+ confirmLabel = "确认",
+ busy,
+}) {
return (
-
-
-
- >}>
+
+
+
+ >
+ }
+ >
{body}
- )
+ );
}
export function Copyable({ value, className }) {
- const copy = () => navigator.clipboard?.writeText(value)
+ const copy = () => navigator.clipboard?.writeText(value);
return (
-
+
{value}
-
+
- )
+ );
}
export function Checkbox({ id, checked, onCheckedChange, disabled, children }) {
return (
-