feat: add file
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface IProps {
|
||||
title?: string | React.ReactNode;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const CollapsibleCard: React.FC<IProps> = (props) => {
|
||||
const { title = '', className = '', children } = props;
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={`border border-gray-200 dark:border-gray-600 rounded-lg p-4 ${className}`}>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
{
|
||||
typeof title === 'string' ? <h3 className="text-sm text-gray-900 dark:text-white font-medium mb-4">{title}</h3> : title
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
{
|
||||
isOpen ? <ChevronUp /> : <ChevronDown />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
{
|
||||
isOpen && children
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CollapsibleCard;
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState } from "react";
|
||||
import { debounce } from "lodash";
|
||||
|
||||
interface IProps {
|
||||
isPassword?: boolean;
|
||||
value?: string;
|
||||
setValue?: (value: string) => void;
|
||||
setIsValueValid?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Input: React.FC<IProps> = (props) => {
|
||||
const { isPassword = false, value = '', setValue = () => {}, setIsValueValid, placeholder = '', className = '' } = props;
|
||||
const [showPassword, setShowPassword] = useState(!isPassword);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(e.target.value);
|
||||
setIsValueValid?.(e.target.value);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
className="w-full px-4 py-3 border border-gray-200 dark:border-gray-600 rounded-lg
|
||||
bg-gray-50 dark:bg-gray-700
|
||||
text-gray-900 dark:text-white
|
||||
placeholder-gray-500 dark:placeholder-gray-400
|
||||
focus:border-blue-500 dark:focus:border-blue-400
|
||||
focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20
|
||||
focus:outline-none"
|
||||
/>
|
||||
{
|
||||
isPassword && <button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-4 flex items-center text-gray-500 dark:text-gray-400
|
||||
hover:text-gray-700 dark:hover:text-gray-200 transition-colors bg-transparent border-none"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Input;
|
||||
@@ -0,0 +1,12 @@
|
||||
const LoadingIcon = () => {
|
||||
return (
|
||||
<span>
|
||||
<svg className="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingIcon;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { X } from "lucide-react";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
className?: string; // 可选:自定义内容区样式
|
||||
overlayClassName?: string; // 可选:自定义遮罩样式
|
||||
autoClose?: boolean; // 可选:是否自动关闭
|
||||
}
|
||||
|
||||
const Modal: React.FC<ModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
className = "",
|
||||
overlayClassName = "",
|
||||
autoClose = true,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
if (open && autoClose) {
|
||||
timer = setTimeout(() => {
|
||||
onClose();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [open, autoClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 z-50 flex items-center justify-center bg-black/40 ${overlayClassName}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={`relative bg-white rounded-lg shadow-lg p-6 min-w-[300px] max-w-full ${className}`}
|
||||
onClick={e => e.stopPropagation()} // 阻止冒泡,点击内容区不关闭
|
||||
>
|
||||
<button
|
||||
className="absolute top-2 right-4 bg-transparent border-none text-gray-400 hover:text-gray-600"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={16}/>
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
Reference in New Issue
Block a user