feat: Complete Modal and Tab UI/UX optimization

Enhanced Modal Components:
- Optimized DeleteConfirmDialog with beautiful gradients and improved layout
- Enhanced MaterialEditDialog with modern design and better information hierarchy
- Improved TemplateDetailModal with elegant header and refined tab navigation
- Enhanced AiClassificationFormDialog with modern styling

 Unified Tab System:
- Created reusable TabNavigation component with multiple variants (default, pills, underline)
- Implemented consistent tab design across ProjectDetails and other pages
- Added support for icons, counts, and disabled states in tabs
- Improved accessibility and keyboard navigation

 Advanced Animations:
- Added comprehensive modal animations (fade-in, scale-in, slide-in)
- Enhanced backdrop blur effects and smooth transitions
- Implemented proper enter/exit animations for better UX
- Added reduced motion support for accessibility

 Responsive Design:
- Optimized modal layouts for mobile, tablet, and desktop
- Improved touch-friendly interactions for mobile devices
- Enhanced modal sizing and positioning across screen sizes
- Added proper scrolling and overflow handling

 Interaction Improvements:
- Enhanced ESC key support for modal closing
- Improved backdrop click handling
- Better focus management and keyboard navigation
- Consistent button styling and hover effects

All modal and tab components now follow unified design language while maintaining full functionality.
This commit is contained in:
imeepos
2025-07-15 20:09:54 +08:00
parent 5d86a6411f
commit 887f5de793
9 changed files with 886 additions and 269 deletions

View File

@@ -102,27 +102,32 @@ export const AiClassificationFormDialog: React.FC<AiClassificationFormDialogProp
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
{/* 背景遮罩 - 优化透明度和动画 */}
<div className="fixed inset-0 z-50 overflow-y-auto animate-fade-in">
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20">
{/* 美观的背景遮罩 */}
<div
className="fixed inset-0 bg-black/20 backdrop-blur-sm transition-all duration-300"
className="fixed inset-0 bg-black bg-opacity-60 backdrop-blur-sm transition-all duration-300"
onClick={handleCancel}
/>
{/* 对话框 - 优化圆角、阴影和尺寸 */}
<div className="inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full border border-gray-100">
{/* 标题栏 - 优化间距和字体 */}
<div className="bg-white px-6 pt-6 pb-4">
{/* 优化的对话框 */}
<div className="relative bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all duration-300 animate-scale-in max-w-3xl w-full mx-4">
{/* 美观的标题栏 */}
<div className="bg-gradient-to-r from-purple-50 via-pink-50 to-purple-50 px-6 py-5 border-b border-purple-200">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 tracking-tight">
{title}
</h3>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-pink-500 rounded-xl flex items-center justify-center shadow-sm">
<span className="text-white text-lg">🤖</span>
</div>
<h3 className="text-xl font-bold text-gray-900">
{title}
</h3>
</div>
<button
onClick={handleCancel}
className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500/20"
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-white hover:shadow-sm rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-purple-500/20"
>
<XMarkIcon className="h-4 w-4" />
<XMarkIcon className="h-5 w-5" />
</button>
</div>
</div>

View File

@@ -35,74 +35,84 @@ export const DeleteConfirmDialog: React.FC<DeleteConfirmDialogProps> = ({
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
{/* 背景遮罩 */}
<div
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
<div className="fixed inset-0 z-50 overflow-y-auto animate-fade-in">
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center">
{/* 美观的背景遮罩 */}
<div
className="fixed inset-0 bg-black bg-opacity-60 backdrop-blur-sm transition-all duration-300"
onClick={deleting ? undefined : onCancel}
/>
{/* 对话框 */}
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
{/* 警告图标 */}
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
{/* 优化的对话框 */}
<div className="relative bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all duration-300 animate-scale-in max-w-md w-full mx-4">
{/* 美观的内容区域 */}
<div className="p-6">
<div className="flex items-start gap-4">
{/* 优化的警告图标 */}
<div className="flex-shrink-0 w-12 h-12 bg-gradient-to-br from-red-100 to-red-200 rounded-xl flex items-center justify-center shadow-sm">
<ExclamationTriangleIcon className="h-6 w-6 text-red-600" />
</div>
{/* 内容 */}
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg leading-6 font-medium text-gray-900">
{/* 优化的内容区域 */}
<div className="flex-1 min-w-0">
<h3 className="text-xl font-bold text-gray-900 mb-2">
{title}
</h3>
<div className="mt-2">
<p className="text-sm text-gray-500">
{message}
</p>
{itemName && (
<div className="mt-3 p-3 bg-gray-50 rounded-md">
<p className="text-sm font-medium text-gray-900">
{itemName}
<p className="text-gray-600 mb-4 leading-relaxed">
{message}
</p>
{itemName && (
<div className="mb-4 p-4 bg-gradient-to-r from-gray-50 to-gray-100 rounded-xl border border-gray-200">
<p className="text-sm font-medium text-gray-700 mb-1"></p>
<p className="font-semibold text-gray-900 truncate" title={itemName}>
{itemName}
</p>
</div>
)}
<div className="p-4 bg-gradient-to-r from-red-50 to-red-100 border border-red-200 rounded-xl">
<div className="flex items-start gap-2">
<ExclamationTriangleIcon className="w-5 h-5 text-red-600 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-red-800 mb-1"></p>
<p className="text-sm text-red-700">
</p>
</div>
)}
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-md">
<p className="text-sm text-red-700">
<strong></strong>
</p>
</div>
</div>
</div>
</div>
</div>
{/* 按钮栏 */}
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
onClick={onConfirm}
disabled={deleting}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{deleting ? (
<>
<LoadingSpinner size="small" className="mr-2" />
...
</>
) : (
'确认删除'
)}
</button>
<button
type="button"
onClick={onCancel}
disabled={deleting}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
{/* 美观的按钮区域 */}
<div className="px-6 py-4 bg-gradient-to-r from-gray-50 to-gray-100 border-t border-gray-200">
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={onCancel}
disabled={deleting}
className="px-4 py-2.5 bg-white border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-all duration-200 font-medium text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<button
type="button"
onClick={onConfirm}
disabled={deleting}
className="px-4 py-2.5 bg-gradient-to-r from-red-600 to-red-700 hover:from-red-700 hover:to-red-800 text-white rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition-all duration-200 font-medium text-sm disabled:opacity-50 disabled:cursor-not-allowed shadow-sm hover:shadow-md min-w-[100px]"
>
{deleting ? (
<div className="flex items-center justify-center gap-2">
<LoadingSpinner size="small" />
<span>...</span>
</div>
) : (
'确认删除'
)}
</button>
</div>
</div>
</div>
</div>

View File

@@ -98,52 +98,68 @@ export const MaterialEditDialog: React.FC<MaterialEditDialogProps> = ({
if (!isOpen || !material) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
{/* 头部 */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900"></h2>
<div className="fixed inset-0 bg-black bg-opacity-60 backdrop-blur-sm flex items-center justify-center z-50 animate-fade-in">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 animate-scale-in overflow-hidden">
{/* 美观的头部 */}
<div className="flex items-center justify-between p-6 bg-gradient-to-r from-primary-50 to-primary-100 border-b border-primary-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center shadow-sm">
<User className="w-5 h-5 text-white" />
</div>
<h2 className="text-xl font-bold text-gray-900"></h2>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-white hover:shadow-sm rounded-lg transition-all duration-200"
disabled={saving}
>
<X className="w-6 h-6" />
<X className="w-5 h-5" />
</button>
</div>
{/* 内容 */}
{/* 美观的内容区域 */}
<div className="p-6 space-y-6">
{/* 错误信息 */}
{error && <ErrorMessage message={error} />}
{/* 素材信息 */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-gray-700"></h3>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-sm font-medium text-gray-900">{material.name}</p>
<p className="text-xs text-gray-600 mt-1">
: {material.material_type} | : {material.processing_status}
{/* 优化的素材信息 */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-800 flex items-center gap-2">
<div className="w-2 h-2 bg-primary-500 rounded-full"></div>
</h3>
<div className="bg-gradient-to-r from-gray-50 to-gray-100 rounded-xl p-4 border border-gray-200">
<p className="font-semibold text-gray-900 mb-2 truncate" title={material.name}>
{material.name}
</p>
<div className="flex items-center gap-4 text-xs text-gray-600">
<span className="px-2 py-1 bg-white rounded-lg border">
: {material.material_type}
</span>
<span className="px-2 py-1 bg-white rounded-lg border">
: {material.processing_status}
</span>
</div>
</div>
</div>
{/* 模特绑定 */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
<div className="flex items-center space-x-2">
{/* 优化的模特绑定 */}
<div className="space-y-3">
<label className="block text-sm font-semibold text-gray-800">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<User className="w-4 h-4" />
<span></span>
</div>
</label>
{loading ? (
<div className="flex items-center justify-center py-4">
<div className="flex items-center justify-center py-6 bg-gray-50 rounded-xl">
<LoadingSpinner size="small" />
<span className="ml-2 text-sm text-gray-600">...</span>
<span className="ml-3 text-sm text-gray-600">...</span>
</div>
) : (
<>
<div className="space-y-3">
<CustomSelect
value={selectedModelId}
onChange={setSelectedModelId}
@@ -152,55 +168,77 @@ export const MaterialEditDialog: React.FC<MaterialEditDialogProps> = ({
disabled={saving}
className="w-full"
/>
{selectedModelId && (
<div className="text-sm text-gray-600">
{getSelectedModel()?.description && (
<p className="mt-1">{getSelectedModel()?.description}</p>
)}
{selectedModelId && getSelectedModel() && (
<div className="p-3 bg-green-50 border border-green-200 rounded-xl">
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-green-100 rounded-lg flex items-center justify-center">
<User className="w-4 h-4 text-green-600" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-green-800">
{getSelectedModel()?.name}
</p>
{getSelectedModel()?.description && (
<p className="text-xs text-green-700 mt-1">
{getSelectedModel()?.description}
</p>
)}
</div>
</div>
</div>
)}
</>
</div>
)}
</div>
{/* 说明 */}
<div className="bg-blue-50 rounded-lg p-3">
<p className="text-xs text-blue-700">
<strong>:</strong>
"无关联模特"
</p>
{/* 优化的说明 */}
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<div className="w-5 h-5 bg-blue-100 rounded-lg flex items-center justify-center mt-0.5">
<span className="text-blue-600 text-xs font-bold">💡</span>
</div>
<div>
<p className="text-sm font-medium text-blue-800 mb-1">使</p>
<p className="text-xs text-blue-700 leading-relaxed">
"无关联模特"
</p>
</div>
</div>
</div>
</div>
{/* 操作按钮 */}
<div className="flex justify-end space-x-3 p-6 border-t border-gray-200">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
disabled={saving}
>
</button>
<button
type="button"
onClick={handleSave}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2"
disabled={saving || loading}
>
{saving ? (
<>
<LoadingSpinner size="small" />
<span>...</span>
</>
) : (
<>
<Save className="w-4 h-4" />
<span></span>
</>
)}
</button>
{/* 美观的操作按钮 */}
<div className="px-6 py-4 bg-gradient-to-r from-gray-50 to-gray-100 border-t border-gray-200">
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-all duration-200"
disabled={saving}
>
</button>
<button
type="button"
onClick={handleSave}
className="px-4 py-2.5 text-sm font-medium text-white bg-gradient-to-r from-primary-600 to-primary-700 hover:from-primary-700 hover:to-primary-800 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm hover:shadow-md min-w-[100px]"
disabled={saving || loading}
>
{saving ? (
<div className="flex items-center justify-center gap-2">
<LoadingSpinner size="small" />
<span>...</span>
</div>
) : (
<div className="flex items-center justify-center gap-2">
<Save className="w-4 h-4" />
<span></span>
</div>
)}
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,204 @@
import React, { useEffect, useRef } from 'react';
import { X } from 'lucide-react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
subtitle?: string;
icon?: React.ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
variant?: 'default' | 'danger' | 'success' | 'warning' | 'info';
children: React.ReactNode;
showCloseButton?: boolean;
closeOnBackdropClick?: boolean;
closeOnEscape?: boolean;
className?: string;
headerClassName?: string;
contentClassName?: string;
}
/**
* 统一的弹框组件
* 支持多种尺寸、变体和交互方式
*/
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
subtitle,
icon,
size = 'md',
variant = 'default',
children,
showCloseButton = true,
closeOnBackdropClick = true,
closeOnEscape = true,
className = '',
headerClassName = '',
contentClassName = '',
}) => {
const modalRef = useRef<HTMLDivElement>(null);
// ESC 键关闭
useEffect(() => {
if (!isOpen || !closeOnEscape) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, closeOnEscape, onClose]);
// 防止背景滚动
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
if (!isOpen) return null;
const getSizeClasses = () => {
const sizeMap = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
full: 'max-w-7xl',
};
return sizeMap[size];
};
const getVariantClasses = () => {
const variantMap = {
default: 'from-primary-50 via-blue-50 to-primary-50 border-primary-200',
danger: 'from-red-50 via-red-50 to-red-100 border-red-200',
success: 'from-green-50 via-green-50 to-green-100 border-green-200',
warning: 'from-yellow-50 via-yellow-50 to-yellow-100 border-yellow-200',
info: 'from-blue-50 via-blue-50 to-blue-100 border-blue-200',
};
return variantMap[variant];
};
const getIconColor = () => {
const colorMap = {
default: 'from-primary-500 to-primary-600',
danger: 'from-red-500 to-red-600',
success: 'from-green-500 to-green-600',
warning: 'from-yellow-500 to-yellow-600',
info: 'from-blue-500 to-blue-600',
};
return colorMap[variant];
};
const handleBackdropClick = (e: React.MouseEvent) => {
if (closeOnBackdropClick && e.target === e.currentTarget) {
onClose();
}
};
return (
<div
className="fixed inset-0 z-50 overflow-y-auto animate-fade-in"
onClick={handleBackdropClick}
>
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20">
{/* 背景遮罩 */}
<div className="fixed inset-0 modal-backdrop" />
{/* 弹框容器 */}
<div
ref={modalRef}
className={`modal-container ${size === 'xl' || size === 'full' ? 'large' : ''} ${getSizeClasses()} w-full mx-4 animate-modal-scale-in ${className}`}
>
{/* 头部 */}
{(title || subtitle || icon || showCloseButton) && (
<div className={`flex items-center justify-between p-6 bg-gradient-to-r ${getVariantClasses()} border-b ${headerClassName}`}>
<div className="flex items-center gap-4 flex-1 min-w-0">
{icon && (
<div className={`w-12 h-12 bg-gradient-to-br ${getIconColor()} rounded-xl flex items-center justify-center shadow-sm`}>
{icon}
</div>
)}
<div className="flex-1 min-w-0">
{title && (
<h2 className="text-xl font-bold text-gray-900 truncate">
{title}
</h2>
)}
{subtitle && (
<p className="text-sm text-gray-600 mt-1">
{subtitle}
</p>
)}
</div>
</div>
{showCloseButton && (
<button
onClick={onClose}
className="ml-4 p-2 text-gray-400 hover:text-gray-600 hover:bg-white hover:shadow-sm rounded-lg transition-all duration-200 flex-shrink-0"
>
<X className="w-5 h-5" />
</button>
)}
</div>
)}
{/* 内容 */}
<div className={`${contentClassName}`}>
{children}
</div>
</div>
</div>
</div>
);
};
// 预设的弹框配置
export const ModalPresets = {
// 确认对话框
confirm: {
size: 'sm' as const,
variant: 'default' as const,
closeOnBackdropClick: false,
},
// 危险操作确认
danger: {
size: 'sm' as const,
variant: 'danger' as const,
closeOnBackdropClick: false,
},
// 表单弹框
form: {
size: 'md' as const,
variant: 'default' as const,
closeOnBackdropClick: true,
},
// 详情查看
detail: {
size: 'lg' as const,
variant: 'default' as const,
closeOnBackdropClick: true,
},
// 全屏弹框
fullscreen: {
size: 'full' as const,
variant: 'default' as const,
closeOnBackdropClick: false,
},
};

View File

@@ -249,158 +249,156 @@ const ModelList: React.FC<ModelListProps> = ({ onModelSelect }) => {
}
return (
<div className="space-y-8 animate-fade-in">
{/* 美观的头部工具栏 */}
<div className="bg-gradient-to-r from-white via-primary-50/30 to-white rounded-xl shadow-sm border border-gray-200/50 p-6 relative overflow-hidden">
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-primary-100/30 to-primary-200/30 rounded-full -translate-y-16 translate-x-16 opacity-50"></div>
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4 relative z-10">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center shadow-sm">
<SparklesIcon className="h-6 w-6 text-white" />
<>
<div className="space-y-8 animate-fade-in">
{/* 美观的头部工具栏 */}
<div className="bg-gradient-to-r from-white via-primary-50/30 to-white rounded-xl shadow-sm border border-gray-200/50 p-6 relative overflow-hidden">
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-primary-100/30 to-primary-200/30 rounded-full -translate-y-16 translate-x-16 opacity-50"></div>
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4 relative z-10">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center shadow-sm">
<SparklesIcon className="h-6 w-6 text-white" />
</div>
<div>
<h1 className="text-xl font-bold text-gray-900 mb-0.5"></h1>
<p className="text-sm text-gray-500 flex items-center gap-2">
<span> {filteredModels.length} </span>
{favorites.size > 0 && (
<>
<span></span>
<span className="flex items-center gap-1">
<HeartIcon className="h-3.5 w-3.5 text-red-500" />
{favorites.size}
</span>
</>
)}
</p>
</div>
</div>
<div>
<h1 className="text-xl font-bold text-gray-900 mb-0.5"></h1>
<p className="text-sm text-gray-500 flex items-center gap-2">
<span> {filteredModels.length} </span>
{favorites.size > 0 && (
<>
<span></span>
<span className="flex items-center gap-1">
<HeartIcon className="h-3.5 w-3.5 text-red-500" />
{favorites.size}
</span>
</>
)}
</p>
</div>
</div>
<div className="flex items-center gap-3">
{/* 过滤器切换 */}
<button
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg transition-all duration-200 text-sm font-medium ${
showFilters
<div className="flex items-center gap-3">
{/* 过滤器切换 */}
<button
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg transition-all duration-200 text-sm font-medium ${showFilters
? 'bg-primary-100 text-primary-700 border border-primary-200'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
title={showFilters ? '隐藏筛选器' : '显示筛选器'}
>
<FunnelIcon className={`h-3.5 w-3.5 transition-transform duration-200 ${
showFilters ? 'rotate-0' : 'rotate-180'
}`} />
{showFilters ? '隐藏筛选' : '显示筛选'}
</button>
}`}
title={showFilters ? '隐藏筛选器' : '显示筛选器'}
>
<FunnelIcon className={`h-3.5 w-3.5 transition-transform duration-200 ${showFilters ? 'rotate-0' : 'rotate-180'
}`} />
{showFilters ? '隐藏筛选' : '显示筛选'}
</button>
{/* 视图模式切换 */}
<div className="flex rounded-lg border border-gray-200 bg-gray-50 p-0.5">
<button
onClick={() => setViewMode(ModelViewMode.Grid)}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md transition-all duration-200 text-sm font-medium ${
viewMode === ModelViewMode.Grid
{/* 视图模式切换 */}
<div className="flex rounded-lg border border-gray-200 bg-gray-50 p-0.5">
<button
onClick={() => setViewMode(ModelViewMode.Grid)}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md transition-all duration-200 text-sm font-medium ${viewMode === ModelViewMode.Grid
? 'bg-white text-primary-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<Squares2X2Icon className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setViewMode(ModelViewMode.List)}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md transition-all duration-200 text-sm font-medium ${
viewMode === ModelViewMode.List
}`}
>
<Squares2X2Icon className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setViewMode(ModelViewMode.List)}
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md transition-all duration-200 text-sm font-medium ${viewMode === ModelViewMode.List
? 'bg-white text-primary-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
<ListBulletIcon className="h-3.5 w-3.5" />
</button>
</div>
}`}
>
<ListBulletIcon className="h-3.5 w-3.5" />
</button>
</div>
{/* 创建模特按钮 */}
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center gap-1.5 px-4 py-2 bg-gradient-to-r from-primary-500 to-primary-600 text-white rounded-lg hover:from-primary-600 hover:to-primary-700 transition-all duration-200 shadow-sm hover:shadow-md text-sm font-medium"
>
<PlusIcon className="h-4 w-4" />
</button>
</div>
</div>
</div>
{/* 搜索和过滤 */}
<div className={`transition-all duration-300 ease-in-out overflow-hidden ${
showFilters
? 'max-h-96 opacity-100'
: 'max-h-0 opacity-0'
}`}>
<ModelSearch
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
genderFilter={genderFilter}
onGenderFilterChange={setGenderFilter}
sortBy={sortBy}
onSortByChange={setSortBy}
sortOrder={sortOrder}
onSortOrderChange={setSortOrder}
/>
</div>
{/* 模特列表 */}
{filteredModels.length === 0 ? (
<div className="text-center py-16 animate-fade-in">
<div className="max-w-md mx-auto">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<MagnifyingGlassIcon className="h-8 w-8 text-gray-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
{models.length === 0 ? '还没有模特' : '没有找到匹配的模特'}
</h3>
<p className="text-gray-600 mb-6">
{models.length === 0
? '开始添加您的第一个模特,建立您的模特库'
: '尝试调整搜索条件或筛选器'
}
</p>
{models.length === 0 && (
{/* 创建模特按钮 */}
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-gradient-to-r from-primary-500 to-primary-600 text-white rounded-lg hover:from-primary-600 hover:to-primary-700 transition-all duration-200 shadow-sm hover:shadow-md text-sm font-medium"
className="flex items-center gap-1.5 px-4 py-2 bg-gradient-to-r from-primary-500 to-primary-600 text-white rounded-lg hover:from-primary-600 hover:to-primary-700 transition-all duration-200 shadow-sm hover:shadow-md text-sm font-medium"
>
<PlusIcon className="h-4 w-4" />
</button>
)}
</div>
</div>
</div>
) : (
<div className={`animate-fade-in ${
viewMode === ModelViewMode.Grid
{/* 搜索和过滤 */}
<div className={`transition-all duration-300 ease-in-out overflow-hidden ${showFilters
? 'max-h-96 opacity-100'
: 'max-h-0 opacity-0'
}`}>
<ModelSearch
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
genderFilter={genderFilter}
onGenderFilterChange={setGenderFilter}
sortBy={sortBy}
onSortByChange={setSortBy}
sortOrder={sortOrder}
onSortOrderChange={setSortOrder}
/>
</div>
{/* 模特列表 */}
{filteredModels.length === 0 ? (
<div className="text-center py-16 animate-fade-in">
<div className="max-w-md mx-auto">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<MagnifyingGlassIcon className="h-8 w-8 text-gray-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
{models.length === 0 ? '还没有模特' : '没有找到匹配的模特'}
</h3>
<p className="text-gray-600 mb-6">
{models.length === 0
? '开始添加您的第一个模特,建立您的模特库'
: '尝试调整搜索条件或筛选器'
}
</p>
{models.length === 0 && (
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-gradient-to-r from-primary-500 to-primary-600 text-white rounded-lg hover:from-primary-600 hover:to-primary-700 transition-all duration-200 shadow-sm hover:shadow-md text-sm font-medium"
>
</button>
)}
</div>
</div>
) : (
<div className={`animate-fade-in ${viewMode === ModelViewMode.Grid
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4'
: 'space-y-3'
}`}>
{filteredModels.map((model, index) => (
<div
key={model.id}
className="animate-scale-in"
style={{ animationDelay: `${index * 50}ms` }}
>
<ModelCard
model={model}
viewMode={viewMode}
onEdit={() => setEditingModel(model)}
onDelete={() => handleDeleteModel(model.id)}
onSelect={() => onModelSelect?.(model)}
onFavorite={handleFavorite}
isFavorite={favorites.has(model.id)}
/>
</div>
))}
</div>
)}
}`}>
{filteredModels.map((model, index) => (
<div
key={model.id}
className="animate-scale-in"
style={{ animationDelay: `${index * 50}ms` }}
>
<ModelCard
model={model}
viewMode={viewMode}
onEdit={() => setEditingModel(model)}
onDelete={() => handleDeleteModel(model.id)}
onSelect={() => onModelSelect?.(model)}
onFavorite={handleFavorite}
isFavorite={favorites.has(model.id)}
/>
</div>
))}
</div>
)}
</div>
{/* 创建模特模态框 */}
{showCreateModal && (
@@ -419,7 +417,7 @@ const ModelList: React.FC<ModelListProps> = ({ onModelSelect }) => {
isEdit={true}
/>
)}
</div>
</>
);
};

View File

@@ -0,0 +1,139 @@
import React from 'react';
import { LucideIcon } from 'lucide-react';
export interface TabItem {
id: string;
label: string;
icon?: LucideIcon;
count?: number;
disabled?: boolean;
}
interface TabNavigationProps {
tabs: TabItem[];
activeTab: string;
onTabChange: (tabId: string) => void;
variant?: 'default' | 'pills' | 'underline';
size?: 'sm' | 'md' | 'lg';
className?: string;
}
/**
* 统一的选项卡导航组件
* 支持多种样式变体和尺寸
*/
export const TabNavigation: React.FC<TabNavigationProps> = ({
tabs,
activeTab,
onTabChange,
variant = 'default',
size = 'md',
className = '',
}) => {
const getTabClasses = (tab: TabItem, isActive: boolean) => {
const baseClasses = 'inline-flex items-center gap-2 font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2';
// 尺寸样式
const sizeClasses = {
sm: 'px-3 py-2 text-sm',
md: 'px-4 py-2.5 text-sm',
lg: 'px-5 py-3 text-base',
};
// 变体样式
const variantClasses = {
default: isActive
? 'text-primary-600 border-b-2 border-primary-500 bg-white rounded-t-lg'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50 rounded-t-lg',
pills: isActive
? 'text-primary-600 bg-primary-100 border border-primary-200 rounded-lg'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg',
underline: isActive
? 'text-primary-600 border-b-2 border-primary-500'
: 'text-gray-500 hover:text-gray-700 border-b-2 border-transparent hover:border-gray-300',
};
const disabledClasses = tab.disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer';
return `${baseClasses} ${sizeClasses[size]} ${variantClasses[variant]} ${disabledClasses}`;
};
const getContainerClasses = () => {
const baseClasses = 'flex overflow-x-auto';
const variantContainerClasses = {
default: 'space-x-1 border-b border-gray-100 bg-gradient-to-r from-gray-50 to-white px-4',
pills: 'space-x-2 p-1 bg-gray-100 rounded-lg',
underline: 'space-x-6 border-b border-gray-200',
};
return `${baseClasses} ${variantContainerClasses[variant]} ${className}`;
};
return (
<nav className={getContainerClasses()}>
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => !tab.disabled && onTabChange(tab.id)}
className={getTabClasses(tab, isActive)}
disabled={tab.disabled}
type="button"
>
{Icon && (
<Icon
className={`w-4 h-4 transition-all duration-200 ${
isActive ? 'text-primary-600' : 'text-gray-400'
}`}
/>
)}
<span className="whitespace-nowrap">{tab.label}</span>
{tab.count !== undefined && (
<span className={`inline-flex items-center justify-center px-2 py-0.5 rounded-full text-xs font-medium ${
isActive
? 'bg-primary-200 text-primary-800'
: 'bg-gray-200 text-gray-600'
}`}>
{tab.count}
</span>
)}
{isActive && variant === 'default' && (
<div className="absolute inset-0 bg-gradient-to-r from-primary-50/30 to-primary-100/30 rounded-t-lg -z-10"></div>
)}
</button>
);
})}
</nav>
);
};
// 预设的选项卡样式
export const TabNavigationPresets = {
// 默认样式 - 适用于页面主要导航
default: {
variant: 'default' as const,
size: 'md' as const,
},
// 药丸样式 - 适用于紧凑的选项切换
pills: {
variant: 'pills' as const,
size: 'sm' as const,
},
// 下划线样式 - 适用于简洁的导航
underline: {
variant: 'underline' as const,
size: 'md' as const,
},
// 大尺寸 - 适用于重要的页面导航
large: {
variant: 'default' as const,
size: 'lg' as const,
},
};

View File

@@ -3,9 +3,7 @@ import ModelList from '../components/ModelList';
const Models: React.FC = () => {
return (
<div className="space-y-6">
<ModelList />
</div>
<ModelList />
);
};

View File

@@ -199,11 +199,128 @@
}
}
/* ===== 弹框专用动画 ===== */
/* 弹框淡入动画 */
@keyframes modalFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* 弹框缩放进入动画 */
@keyframes modalScaleIn {
from {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* 弹框滑入动画 */
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 弹框淡出动画 */
@keyframes modalFadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
/* 弹框缩放退出动画 */
@keyframes modalScaleOut {
from {
opacity: 1;
transform: scale(1) translateY(0);
}
to {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
}
/* 弹框动画类 */
.animate-modal-fade-in {
animation: modalFadeIn 0.2s ease-out forwards;
}
.animate-modal-scale-in {
animation: modalScaleIn 0.3s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.animate-modal-slide-in {
animation: modalSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.animate-modal-fade-out {
animation: modalFadeOut 0.2s ease-in forwards;
}
.animate-modal-scale-out {
animation: modalScaleOut 0.2s ease-in forwards;
}
/* 背景遮罩动画 */
.modal-backdrop {
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.modal-backdrop.entering {
background: rgba(0, 0, 0, 0);
backdrop-filter: blur(0px);
}
.modal-backdrop.exiting {
background: rgba(0, 0, 0, 0);
backdrop-filter: blur(0px);
}
/* 弹框容器样式 */
.modal-container {
position: relative;
background: white;
border-radius: 1rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
overflow: hidden;
transform-origin: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.modal-container.large {
border-radius: 1.5rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.3);
}
/* 减少动画偏好设置 */
@media (prefers-reduced-motion: reduce) {
.animate-fade-in-up,
.hover-glow,
.card-interactive {
.card-interactive,
.animate-modal-fade-in,
.animate-modal-scale-in,
.animate-modal-slide-in,
.animate-modal-fade-out,
.animate-modal-scale-out {
animation: none !important;
transition: none !important;
}
@@ -212,6 +329,11 @@
.card-interactive:hover {
transform: none !important;
}
.modal-backdrop,
.modal-container {
transition: none !important;
}
}
.animate-fade-out {

View File

@@ -581,3 +581,106 @@
}
}
}
/* ===== 弹框响应式优化 ===== */
/* 移动端弹框优化 */
@media (max-width: 640px) {
.modal-container {
margin: 1rem;
max-height: calc(100vh - 2rem);
border-radius: 1rem;
}
.modal-container.large {
border-radius: 1rem;
}
/* 移动端弹框头部 */
.modal-header-mobile {
padding: 1rem;
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
/* 移动端弹框内容 */
.modal-content-mobile {
padding: 1rem;
max-height: calc(100vh - 8rem);
overflow-y: auto;
}
/* 移动端按钮区域 */
.modal-actions-mobile {
padding: 1rem;
flex-direction: column;
gap: 0.75rem;
}
.modal-actions-mobile button {
width: 100%;
justify-content: center;
}
}
/* 平板端弹框优化 */
@media (min-width: 641px) and (max-width: 1024px) {
.modal-container {
margin: 2rem;
max-height: calc(100vh - 4rem);
}
.modal-content-tablet {
max-height: calc(100vh - 12rem);
overflow-y: auto;
}
}
/* 大屏幕弹框优化 */
@media (min-width: 1920px) {
.modal-container {
border-radius: 1.5rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.35);
}
.modal-container.large {
border-radius: 2rem;
}
}
/* 弹框内容滚动优化 */
.modal-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
}
.modal-scroll::-webkit-scrollbar {
width: 6px;
}
.modal-scroll::-webkit-scrollbar-track {
background: transparent;
}
.modal-scroll::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5);
border-radius: 3px;
}
.modal-scroll::-webkit-scrollbar-thumb:hover {
background-color: rgba(156, 163, 175, 0.7);
}
/* 弹框层级管理 */
.modal-z-index {
z-index: 1000;
}
.modal-z-index.high {
z-index: 1100;
}
.modal-z-index.highest {
z-index: 1200;
}