feat: UI美化和UX改进 v0.1.5
- 优化设计系统和主题配置 - 增强Tailwind配置,添加更丰富的色彩系统 - 新增多种动画效果和过渡动画 - 改进字体系统和间距设置 - 添加阴影变体和渐变效果 - 改进项目列表页面UI/UX - 重新设计页面头部,添加渐变背景和统计信息 - 优化项目卡片布局和响应式设计 - 增强加载和错误状态的视觉效果 - 添加交错动画效果 - 优化项目卡片组件 - 重新设计卡片布局,添加背景装饰 - 改进统计信息展示,使用渐变背景和徽章 - 增强悬停效果和交互反馈 - 优化菜单和按钮设计 - 改进表单和模态框体验 - 重新设计模态框头部和布局 - 增强表单字段的视觉设计和验证反馈 - 添加加载状态和成功状态指示 - 改进按钮样式和交互效果 - 增强加载状态和反馈机制 - 重新设计EmptyState组件,添加装饰效果 - 增强LoadingSpinner,支持多种动画样式 - 改进ErrorMessage组件,支持多种消息类型 - 新增SkeletonLoader组件用于骨架屏加载 - 添加微交互和动画效果 - 新增AnimatedButton组件,支持涟漪效果 - 创建PageTransition组件用于页面过渡 - 添加多种动画工具组件 - 增强按钮和卡片的微交互效果 - 优化响应式设计和移动端体验 - 改进容器布局和间距设置 - 优化移动端的触摸体验 - 确保所有组件在不同屏幕尺寸下的完美适配 遵循前端开发规范,提升界面美观性和用户体验
This commit is contained in:
@@ -2,60 +2,135 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 自定义基础样式 */
|
||||
@layer base {
|
||||
/* 改进字体渲染 */
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* 改进滚动行为 */
|
||||
* {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* 改进焦点样式 */
|
||||
*:focus-visible {
|
||||
@apply outline-none ring-2 ring-primary-500 ring-offset-2;
|
||||
}
|
||||
}
|
||||
|
||||
/* 自定义组件样式 */
|
||||
@layer components {
|
||||
/* 按钮样式 */
|
||||
/* 按钮样式系统 */
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed relative overflow-hidden;
|
||||
}
|
||||
|
||||
/* 按钮悬停效果 */
|
||||
.btn::before {
|
||||
@apply absolute inset-0 bg-white opacity-0 transition-opacity duration-200;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.btn:hover::before {
|
||||
@apply opacity-10;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
@apply transform scale-95;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500 active:bg-primary-800;
|
||||
@apply bg-gradient-to-r from-primary-600 to-primary-700 text-white hover:from-primary-700 hover:to-primary-800 focus:ring-primary-500 active:from-primary-800 active:to-primary-900 shadow-lg hover:shadow-xl;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-100 text-gray-700 hover:bg-gray-200 focus:ring-gray-500 border border-gray-300;
|
||||
@apply bg-white text-gray-700 hover:bg-gray-50 focus:ring-gray-500 border border-gray-200 hover:border-gray-300 shadow-sm hover:shadow-md;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply text-gray-600 hover:bg-gray-100 focus:ring-gray-500;
|
||||
@apply text-gray-600 hover:bg-gray-100 hover:text-gray-900 focus:ring-gray-500 rounded-lg;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 active:bg-red-800;
|
||||
@apply bg-gradient-to-r from-red-600 to-red-700 text-white hover:from-red-700 hover:to-red-800 focus:ring-red-500 active:from-red-800 active:to-red-900 shadow-lg hover:shadow-xl;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
@apply bg-gradient-to-r from-green-600 to-green-700 text-white hover:from-green-700 hover:to-green-800 focus:ring-green-500 active:from-green-800 active:to-green-900 shadow-lg hover:shadow-xl;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
@apply bg-gradient-to-r from-yellow-500 to-yellow-600 text-white hover:from-yellow-600 hover:to-yellow-700 focus:ring-yellow-500 active:from-yellow-700 active:to-yellow-800 shadow-lg hover:shadow-xl;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@apply px-3 py-1.5 text-xs;
|
||||
@apply px-3 py-2 text-xs rounded-lg;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
@apply px-6 py-3 text-base;
|
||||
@apply px-6 py-3 text-base rounded-2xl;
|
||||
}
|
||||
|
||||
.btn-xl {
|
||||
@apply px-8 py-4 text-lg rounded-2xl;
|
||||
}
|
||||
|
||||
/* 表单样式系统 */
|
||||
.form-group {
|
||||
@apply space-y-2;
|
||||
}
|
||||
|
||||
/* 表单样式 */
|
||||
.form-input {
|
||||
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors duration-200;
|
||||
@apply w-full px-4 py-3 border border-gray-200 rounded-xl bg-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all duration-200 placeholder-gray-400 text-gray-900;
|
||||
}
|
||||
|
||||
.form-input:hover {
|
||||
@apply border-gray-300;
|
||||
}
|
||||
|
||||
.form-input.error {
|
||||
@apply border-red-500 focus:ring-red-500;
|
||||
@apply border-red-300 bg-red-50 focus:ring-red-500 focus:border-red-500;
|
||||
}
|
||||
|
||||
.form-input.success {
|
||||
@apply border-green-300 bg-green-50 focus:ring-green-500 focus:border-green-500;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors duration-200 resize-none;
|
||||
@apply w-full px-4 py-3 border border-gray-200 rounded-xl bg-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all duration-200 resize-none placeholder-gray-400 text-gray-900;
|
||||
}
|
||||
|
||||
.form-textarea:hover {
|
||||
@apply border-gray-300;
|
||||
}
|
||||
|
||||
.form-textarea.error {
|
||||
@apply border-red-500 focus:ring-red-500;
|
||||
@apply border-red-300 bg-red-50 focus:ring-red-500 focus:border-red-500;
|
||||
}
|
||||
|
||||
.form-textarea.success {
|
||||
@apply border-green-300 bg-green-50 focus:ring-green-500 focus:border-green-500;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
@apply block text-sm font-medium text-gray-700 mb-1;
|
||||
@apply block text-sm font-semibold text-gray-800 mb-2;
|
||||
}
|
||||
|
||||
.form-label.required::after {
|
||||
@apply text-red-500 ml-1;
|
||||
content: '*';
|
||||
}
|
||||
|
||||
.form-error {
|
||||
@apply flex items-center gap-1 text-sm text-red-600 mt-1;
|
||||
@apply flex items-center gap-2 text-sm text-red-600 mt-2 font-medium;
|
||||
}
|
||||
|
||||
.form-success {
|
||||
@apply flex items-center gap-2 text-sm text-green-600 mt-2 font-medium;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
@@ -63,50 +138,106 @@
|
||||
}
|
||||
|
||||
.form-info {
|
||||
@apply text-sm text-blue-600 mt-1;
|
||||
@apply text-sm text-blue-600 mt-2 font-medium;
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.form-warning {
|
||||
@apply text-sm text-yellow-600 mt-2 font-medium;
|
||||
}
|
||||
|
||||
/* 加载动画增强 */
|
||||
.spinner {
|
||||
@apply animate-spin;
|
||||
}
|
||||
|
||||
/* 模态框样式 */
|
||||
.loading-pulse {
|
||||
@apply animate-pulse;
|
||||
}
|
||||
|
||||
.loading-bounce {
|
||||
@apply animate-bounce;
|
||||
}
|
||||
|
||||
/* 模态框样式增强 */
|
||||
.modal-overlay {
|
||||
@apply fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4;
|
||||
@apply fixed inset-0 bg-black bg-opacity-60 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in;
|
||||
}
|
||||
|
||||
.modal {
|
||||
@apply bg-white rounded-xl shadow-xl max-w-md w-full max-h-[90vh] overflow-hidden;
|
||||
@apply bg-white rounded-2xl shadow-2xl max-w-md w-full max-h-[90vh] overflow-hidden animate-scale-in;
|
||||
}
|
||||
|
||||
.modal-lg {
|
||||
@apply max-w-2xl;
|
||||
}
|
||||
|
||||
.modal-xl {
|
||||
@apply max-w-4xl;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
@apply flex items-center justify-between p-6 border-b border-gray-200;
|
||||
@apply flex items-center justify-between p-6 border-b border-gray-100 bg-gray-25;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
@apply p-1 hover:bg-gray-100 rounded-lg transition-colors duration-200;
|
||||
@apply p-2 hover:bg-gray-100 rounded-xl transition-all duration-200 text-gray-400 hover:text-gray-600;
|
||||
}
|
||||
|
||||
/* 卡片样式 */
|
||||
.modal-body {
|
||||
@apply p-6;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
@apply flex gap-3 p-6 border-t border-gray-100 bg-gray-25;
|
||||
}
|
||||
|
||||
/* 卡片样式增强 */
|
||||
.card {
|
||||
@apply bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-md transition-shadow duration-200;
|
||||
@apply bg-white rounded-2xl border border-gray-100 shadow-sm hover:shadow-lg hover:shadow-primary-500/10 transition-all duration-300 overflow-hidden;
|
||||
}
|
||||
|
||||
/* 菜单样式 */
|
||||
.card-elevated {
|
||||
@apply shadow-lg hover:shadow-xl;
|
||||
}
|
||||
|
||||
.card-interactive {
|
||||
@apply cursor-pointer hover:-translate-y-1 hover:scale-[1.02] active:scale-[0.98];
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@apply p-6 border-b border-gray-100;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
@apply p-6;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
@apply p-6 border-t border-gray-100 bg-gray-25;
|
||||
}
|
||||
|
||||
/* 菜单样式增强 */
|
||||
.dropdown-menu {
|
||||
@apply absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50;
|
||||
@apply absolute right-0 mt-2 w-48 bg-white rounded-xl shadow-xl border border-gray-100 py-2 z-50 animate-fade-in-down;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
@apply flex items-center gap-2 w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors duration-150;
|
||||
@apply flex items-center gap-3 w-full px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 hover:text-gray-900 transition-all duration-150 mx-1 rounded-lg;
|
||||
}
|
||||
|
||||
.dropdown-item.danger {
|
||||
@apply text-red-600 hover:bg-red-50;
|
||||
@apply text-red-600 hover:bg-red-50 hover:text-red-700;
|
||||
}
|
||||
|
||||
/* 响应式工具类 */
|
||||
.dropdown-item.success {
|
||||
@apply text-green-600 hover:bg-green-50 hover:text-green-700;
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
@apply my-1 border-t border-gray-100;
|
||||
}
|
||||
|
||||
/* 响应式工具类增强 */
|
||||
.line-clamp-1 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
@@ -121,30 +252,157 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 动画增强 */
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
/* 状态指示器 */
|
||||
.status-indicator {
|
||||
@apply inline-flex items-center gap-2 px-3 py-1 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
.status-success {
|
||||
@apply bg-green-100 text-green-800;
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
@apply bg-yellow-100 text-yellow-800;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
@apply bg-red-100 text-red-800;
|
||||
}
|
||||
|
||||
.status-info {
|
||||
@apply bg-blue-100 text-blue-800;
|
||||
}
|
||||
|
||||
.status-neutral {
|
||||
@apply bg-gray-100 text-gray-800;
|
||||
}
|
||||
|
||||
/* 徽章样式 */
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
@apply bg-primary-100 text-primary-800;
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
@apply bg-gray-100 text-gray-800;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
@apply bg-green-100 text-green-800;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
@apply bg-yellow-100 text-yellow-800;
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
@apply bg-red-100 text-red-800;
|
||||
}
|
||||
|
||||
/* 分隔线样式 */
|
||||
.divider {
|
||||
@apply border-t border-gray-200;
|
||||
}
|
||||
|
||||
.divider-vertical {
|
||||
@apply border-l border-gray-200;
|
||||
}
|
||||
|
||||
/* 自定义滚动条增强 */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
@apply bg-gray-100 rounded-full;
|
||||
@apply bg-gray-50 rounded-full;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300 rounded-full hover:bg-gray-400;
|
||||
@apply bg-gray-300 rounded-full hover:bg-gray-400 transition-colors duration-200;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-corner {
|
||||
@apply bg-gray-50;
|
||||
}
|
||||
|
||||
/* 焦点样式增强 */
|
||||
.focus-visible {
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
/* 交互状态 */
|
||||
.interactive {
|
||||
@apply transition-all duration-200 cursor-pointer;
|
||||
}
|
||||
|
||||
.interactive:hover {
|
||||
@apply transform scale-105;
|
||||
}
|
||||
|
||||
.interactive:active {
|
||||
@apply transform scale-95;
|
||||
}
|
||||
|
||||
/* 玻璃态效果 */
|
||||
.glass {
|
||||
@apply bg-white bg-opacity-80 backdrop-blur-md border border-white border-opacity-20;
|
||||
}
|
||||
|
||||
.glass-dark {
|
||||
@apply bg-gray-900 bg-opacity-80 backdrop-blur-md border border-gray-700 border-opacity-20;
|
||||
}
|
||||
|
||||
/* 渐变背景 */
|
||||
.gradient-primary {
|
||||
@apply bg-gradient-to-r from-primary-500 to-primary-600;
|
||||
}
|
||||
|
||||
.gradient-secondary {
|
||||
@apply bg-gradient-to-r from-gray-500 to-gray-600;
|
||||
}
|
||||
|
||||
.gradient-success {
|
||||
@apply bg-gradient-to-r from-green-500 to-green-600;
|
||||
}
|
||||
|
||||
.gradient-warning {
|
||||
@apply bg-gradient-to-r from-yellow-500 to-yellow-600;
|
||||
}
|
||||
|
||||
.gradient-error {
|
||||
@apply bg-gradient-to-r from-red-500 to-red-600;
|
||||
}
|
||||
|
||||
/* 文本渐变 */
|
||||
.text-gradient-primary {
|
||||
@apply bg-gradient-to-r from-primary-600 to-primary-800 bg-clip-text text-transparent;
|
||||
}
|
||||
|
||||
.text-gradient-secondary {
|
||||
@apply bg-gradient-to-r from-gray-600 to-gray-800 bg-clip-text text-transparent;
|
||||
}
|
||||
|
||||
/* 阴影变体 */
|
||||
.shadow-glow {
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.shadow-glow-lg {
|
||||
box-shadow: 0 0 40px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.shadow-colored {
|
||||
box-shadow: 0 10px 25px -5px rgba(59, 130, 246, 0.1), 0 4px 6px -2px rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
}
|
||||
@@ -48,8 +48,9 @@ function App() {
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<main className="container mx-auto px-6 py-8 max-w-7xl">
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-50">
|
||||
{/* 主要内容区域 */}
|
||||
<main className="container mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-6 lg:py-8 max-w-7xl">
|
||||
<Routes>
|
||||
<Route path="/" element={<ProjectList />} />
|
||||
<Route path="/project/:id" element={<ProjectDetails />} />
|
||||
|
||||
195
apps/desktop/src/components/AnimatedButton.tsx
Normal file
195
apps/desktop/src/components/AnimatedButton.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import React, { useState } from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface AnimatedButtonProps {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'success';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
icon?: LucideIcon;
|
||||
iconPosition?: 'left' | 'right';
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
ripple?: boolean;
|
||||
glow?: boolean;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
}
|
||||
|
||||
/**
|
||||
* 增强的动画按钮组件
|
||||
* 支持涟漪效果、发光效果和各种微交互
|
||||
*/
|
||||
export const AnimatedButton: React.FC<AnimatedButtonProps> = ({
|
||||
children,
|
||||
onClick,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
icon: Icon,
|
||||
iconPosition = 'left',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
className = '',
|
||||
ripple = true,
|
||||
glow = false,
|
||||
type = 'button'
|
||||
}) => {
|
||||
const [ripples, setRipples] = useState<Array<{ id: number; x: number; y: number }>>([]);
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (disabled || loading) return;
|
||||
|
||||
// 创建涟漪效果
|
||||
if (ripple) {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
const newRipple = { id: Date.now(), x, y };
|
||||
|
||||
setRipples(prev => [...prev, newRipple]);
|
||||
|
||||
// 移除涟漪效果
|
||||
setTimeout(() => {
|
||||
setRipples(prev => prev.filter(r => r.id !== newRipple.id));
|
||||
}, 600);
|
||||
}
|
||||
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
const getVariantClasses = () => {
|
||||
const base = 'btn relative overflow-hidden';
|
||||
const variants = {
|
||||
primary: 'btn-primary',
|
||||
secondary: 'btn-secondary',
|
||||
ghost: 'btn-ghost',
|
||||
danger: 'btn-danger',
|
||||
success: 'btn-success'
|
||||
};
|
||||
return `${base} ${variants[variant]}`;
|
||||
};
|
||||
|
||||
const getSizeClasses = () => {
|
||||
const sizes = {
|
||||
sm: 'btn-sm',
|
||||
md: '',
|
||||
lg: 'btn-lg'
|
||||
};
|
||||
return sizes[size];
|
||||
};
|
||||
|
||||
const glowClasses = glow ? 'shadow-glow hover:shadow-glow-lg' : '';
|
||||
const disabledClasses = disabled ? 'opacity-50 cursor-not-allowed' : '';
|
||||
const loadingClasses = loading ? 'cursor-wait' : '';
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={`
|
||||
${getVariantClasses()}
|
||||
${getSizeClasses()}
|
||||
${glowClasses}
|
||||
${disabledClasses}
|
||||
${loadingClasses}
|
||||
${className}
|
||||
group
|
||||
transform transition-all duration-200
|
||||
hover:scale-105 active:scale-95
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2
|
||||
`}
|
||||
onClick={handleClick}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{/* 涟漪效果 */}
|
||||
{ripples.map(ripple => (
|
||||
<span
|
||||
key={ripple.id}
|
||||
className="absolute bg-white bg-opacity-30 rounded-full animate-ping"
|
||||
style={{
|
||||
left: ripple.x - 10,
|
||||
top: ripple.y - 10,
|
||||
width: 20,
|
||||
height: 20,
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 按钮内容 */}
|
||||
<span className="relative flex items-center justify-center gap-2">
|
||||
{/* 左侧图标 */}
|
||||
{Icon && iconPosition === 'left' && (
|
||||
<Icon
|
||||
size={size === 'sm' ? 14 : size === 'lg' ? 20 : 16}
|
||||
className={`transition-transform duration-200 ${loading ? 'animate-spin' : 'group-hover:scale-110'}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 加载状态 */}
|
||||
{loading && (
|
||||
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
|
||||
{/* 文本内容 */}
|
||||
<span className={`transition-all duration-200 ${loading ? 'opacity-70' : ''}`}>
|
||||
{children}
|
||||
</span>
|
||||
|
||||
{/* 右侧图标 */}
|
||||
{Icon && iconPosition === 'right' && (
|
||||
<Icon
|
||||
size={size === 'sm' ? 14 : size === 'lg' ? 20 : 16}
|
||||
className={`transition-transform duration-200 ${loading ? 'animate-spin' : 'group-hover:scale-110'}`}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* 悬停光晕效果 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white to-transparent opacity-0 group-hover:opacity-10 transition-opacity duration-300 -skew-x-12 transform translate-x-full group-hover:translate-x-[-100%] transition-transform duration-700" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 浮动操作按钮组件
|
||||
*/
|
||||
export const FloatingActionButton: React.FC<{
|
||||
icon: LucideIcon;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}> = ({ icon: Icon, onClick, className = '', size = 'md' }) => {
|
||||
const sizeClasses = {
|
||||
sm: 'w-12 h-12',
|
||||
md: 'w-14 h-14',
|
||||
lg: 'w-16 h-16'
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
sm: 20,
|
||||
md: 24,
|
||||
lg: 28
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`
|
||||
${sizeClasses[size]}
|
||||
bg-primary-600 hover:bg-primary-700 text-white
|
||||
rounded-full shadow-lg hover:shadow-xl
|
||||
flex items-center justify-center
|
||||
transition-all duration-300
|
||||
hover:scale-110 active:scale-95
|
||||
focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2
|
||||
group
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
<Icon
|
||||
size={iconSizes[size]}
|
||||
className="transition-transform duration-200 group-hover:rotate-12"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { FolderPlus } from 'lucide-react';
|
||||
import { FolderPlus, Sparkles } from 'lucide-react';
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
@@ -10,7 +10,7 @@ interface EmptyStateProps {
|
||||
|
||||
/**
|
||||
* 空状态组件
|
||||
* 遵循简洁大方的设计风格
|
||||
* 遵循现代化设计风格,提供更好的用户体验
|
||||
*/
|
||||
export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
title,
|
||||
@@ -19,15 +19,48 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
onAction
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="text-gray-400 mb-6">
|
||||
<FolderPlus size={64} />
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center animate-fade-in-up">
|
||||
{/* 图标区域 */}
|
||||
<div className="relative mb-8">
|
||||
{/* 背景装饰 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary-100 to-blue-100 rounded-full blur-2xl opacity-50 scale-150"></div>
|
||||
|
||||
{/* 主图标 */}
|
||||
<div className="relative p-6 bg-gradient-to-r from-primary-50 to-blue-50 rounded-3xl border border-primary-100">
|
||||
<FolderPlus size={64} className="text-primary-600 animate-bounce-subtle" />
|
||||
|
||||
{/* 装饰性小图标 */}
|
||||
<div className="absolute -top-2 -right-2 p-2 bg-yellow-100 rounded-full animate-pulse">
|
||||
<Sparkles size={16} className="text-yellow-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文本内容 */}
|
||||
<div className="max-w-md space-y-4">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-3">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-gray-600 leading-relaxed text-lg">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="mt-8">
|
||||
<button
|
||||
className="btn btn-primary btn-lg shadow-glow animate-pulse-slow"
|
||||
onClick={onAction}
|
||||
>
|
||||
<FolderPlus size={20} />
|
||||
{actionText}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<div className="mt-6 text-sm text-gray-500">
|
||||
<p>💡 提示:您可以通过拖拽文件夹到此处快速创建项目</p>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">{title}</h3>
|
||||
<p className="text-gray-600 mb-8 max-w-md">{description}</p>
|
||||
<button className="btn btn-primary" onClick={onAction}>
|
||||
{actionText}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,36 +1,125 @@
|
||||
import React from 'react';
|
||||
import { AlertCircle, RefreshCw, X } from 'lucide-react';
|
||||
import { AlertCircle, RefreshCw, X, AlertTriangle, Info, CheckCircle } from 'lucide-react';
|
||||
|
||||
interface ErrorMessageProps {
|
||||
message: string;
|
||||
type?: 'error' | 'warning' | 'info' | 'success';
|
||||
onRetry?: () => void;
|
||||
onDismiss?: () => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误消息组件
|
||||
* 增强的消息组件
|
||||
* 支持多种消息类型和更好的视觉设计
|
||||
*/
|
||||
export const ErrorMessage: React.FC<ErrorMessageProps> = ({
|
||||
message,
|
||||
type = 'error',
|
||||
onRetry,
|
||||
onDismiss
|
||||
onDismiss,
|
||||
title
|
||||
}) => {
|
||||
const getConfig = () => {
|
||||
switch (type) {
|
||||
case 'warning':
|
||||
return {
|
||||
bgColor: 'bg-yellow-50',
|
||||
borderColor: 'border-yellow-200',
|
||||
textColor: 'text-yellow-800',
|
||||
iconColor: 'text-yellow-600',
|
||||
icon: AlertTriangle,
|
||||
defaultTitle: '警告'
|
||||
};
|
||||
case 'info':
|
||||
return {
|
||||
bgColor: 'bg-blue-50',
|
||||
borderColor: 'border-blue-200',
|
||||
textColor: 'text-blue-800',
|
||||
iconColor: 'text-blue-600',
|
||||
icon: Info,
|
||||
defaultTitle: '信息'
|
||||
};
|
||||
case 'success':
|
||||
return {
|
||||
bgColor: 'bg-green-50',
|
||||
borderColor: 'border-green-200',
|
||||
textColor: 'text-green-800',
|
||||
iconColor: 'text-green-600',
|
||||
icon: CheckCircle,
|
||||
defaultTitle: '成功'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bgColor: 'bg-red-50',
|
||||
borderColor: 'border-red-200',
|
||||
textColor: 'text-red-800',
|
||||
iconColor: 'text-red-600',
|
||||
icon: AlertCircle,
|
||||
defaultTitle: '错误'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const config = getConfig();
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle size={20} className="text-red-500 flex-shrink-0" />
|
||||
<span className="text-red-700">{message}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{onRetry && (
|
||||
<button className="btn btn-secondary btn-sm" onClick={onRetry}>
|
||||
<RefreshCw size={14} />
|
||||
重试
|
||||
</button>
|
||||
)}
|
||||
<div className={`${config.bgColor} ${config.borderColor} border rounded-2xl p-6 animate-fade-in-up shadow-sm`}>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* 图标 */}
|
||||
<div className={`${config.iconColor} flex-shrink-0 mt-0.5`}>
|
||||
<Icon size={24} />
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* 标题 */}
|
||||
{(title || config.defaultTitle) && (
|
||||
<h4 className={`${config.textColor} font-semibold text-lg mb-2`}>
|
||||
{title || config.defaultTitle}
|
||||
</h4>
|
||||
)}
|
||||
|
||||
{/* 消息内容 */}
|
||||
<p className={`${config.textColor} leading-relaxed`}>
|
||||
{message}
|
||||
</p>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
{(onRetry || onDismiss) && (
|
||||
<div className="flex items-center gap-3 mt-4">
|
||||
{onRetry && (
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={onRetry}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
重试
|
||||
</button>
|
||||
)}
|
||||
{onDismiss && (
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={onDismiss}
|
||||
title="关闭"
|
||||
>
|
||||
<X size={14} />
|
||||
关闭
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右上角关闭按钮 */}
|
||||
{onDismiss && (
|
||||
<button className="btn btn-ghost btn-sm" onClick={onDismiss}>
|
||||
<X size={14} />
|
||||
<button
|
||||
className={`${config.iconColor} hover:bg-white hover:bg-opacity-50 p-1 rounded-lg transition-colors duration-200 flex-shrink-0`}
|
||||
onClick={onDismiss}
|
||||
title="关闭"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,28 +1,119 @@
|
||||
import React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Loader2, Circle } from 'lucide-react';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
text?: string;
|
||||
variant?: 'spinner' | 'dots' | 'pulse' | 'bars';
|
||||
color?: 'primary' | 'secondary' | 'white';
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载动画组件
|
||||
* 增强的加载动画组件
|
||||
* 支持多种动画样式和尺寸
|
||||
*/
|
||||
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
size = 'medium',
|
||||
text
|
||||
text,
|
||||
variant = 'spinner',
|
||||
color = 'primary'
|
||||
}) => {
|
||||
const sizeMap = {
|
||||
small: 16,
|
||||
medium: 24,
|
||||
large: 32
|
||||
small: { icon: 16, text: 'text-sm', padding: 'p-3' },
|
||||
medium: { icon: 24, text: 'text-base', padding: 'p-6' },
|
||||
large: { icon: 32, text: 'text-lg', padding: 'p-8' }
|
||||
};
|
||||
|
||||
const colorMap = {
|
||||
primary: 'text-primary-600',
|
||||
secondary: 'text-gray-600',
|
||||
white: 'text-white'
|
||||
};
|
||||
|
||||
const currentSize = sizeMap[size];
|
||||
const currentColor = colorMap[color];
|
||||
|
||||
const renderSpinner = () => {
|
||||
switch (variant) {
|
||||
case 'dots':
|
||||
return (
|
||||
<div className="flex space-x-1">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Circle
|
||||
key={i}
|
||||
size={currentSize.icon / 3}
|
||||
className={`${currentColor} animate-loading-dots`}
|
||||
style={{ animationDelay: `${i * 0.2}s` }}
|
||||
fill="currentColor"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'pulse':
|
||||
return (
|
||||
<div className={`w-${currentSize.icon / 4} h-${currentSize.icon / 4} ${currentColor.replace('text-', 'bg-')} rounded-full animate-pulse-slow`} />
|
||||
);
|
||||
|
||||
case 'bars':
|
||||
return (
|
||||
<div className="flex space-x-1">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-1 h-${currentSize.icon / 4} ${currentColor.replace('text-', 'bg-')} animate-loading-bars`}
|
||||
style={{ animationDelay: `${i * 0.1}s` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Loader2
|
||||
size={currentSize.icon}
|
||||
className={`animate-spin ${currentColor}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-center gap-3 ${size === 'small' ? 'p-4' : 'p-8'}`}>
|
||||
<Loader2 size={sizeMap[size]} className="spinner text-primary-600" />
|
||||
{text && <span className="text-gray-600">{text}</span>}
|
||||
<div className={`flex flex-col items-center justify-center gap-4 ${currentSize.padding} animate-fade-in`}>
|
||||
{/* 加载动画 */}
|
||||
<div className="relative">
|
||||
{/* 背景光晕效果 */}
|
||||
{color === 'primary' && (
|
||||
<div className="absolute inset-0 bg-primary-200 rounded-full blur-lg opacity-30 animate-pulse"></div>
|
||||
)}
|
||||
|
||||
{/* 主要动画 */}
|
||||
<div className="relative">
|
||||
{renderSpinner()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 加载文本 */}
|
||||
{text && (
|
||||
<div className="text-center">
|
||||
<span className={`${currentColor} ${currentSize.text} font-medium animate-pulse`}>
|
||||
{text}
|
||||
</span>
|
||||
|
||||
{/* 动态点点点效果 */}
|
||||
<span className="inline-flex ml-1">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`${currentColor} animate-pulse`}
|
||||
style={{ animationDelay: `${i * 0.3}s` }}
|
||||
>
|
||||
.
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
209
apps/desktop/src/components/PageTransition.tsx
Normal file
209
apps/desktop/src/components/PageTransition.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
interface PageTransitionProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面过渡动画组件
|
||||
* 提供平滑的页面切换效果
|
||||
*/
|
||||
export const PageTransition: React.FC<PageTransitionProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
delay = 0
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(true);
|
||||
}, delay);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [delay]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
transition-all duration-500 ease-out
|
||||
${isVisible
|
||||
? 'opacity-100 translate-y-0'
|
||||
: 'opacity-0 translate-y-4'
|
||||
}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 交错动画容器
|
||||
* 为子元素提供依次出现的动画效果
|
||||
*/
|
||||
export const StaggeredContainer: React.FC<{
|
||||
children: React.ReactNode;
|
||||
staggerDelay?: number;
|
||||
className?: string;
|
||||
}> = ({ children, staggerDelay = 100, className = '' }) => {
|
||||
return (
|
||||
<div className={className}>
|
||||
{React.Children.map(children, (child, index) => (
|
||||
<PageTransition delay={index * staggerDelay}>
|
||||
{child}
|
||||
</PageTransition>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 滑入动画组件
|
||||
*/
|
||||
export const SlideIn: React.FC<{
|
||||
children: React.ReactNode;
|
||||
direction?: 'left' | 'right' | 'up' | 'down';
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}> = ({ children, direction = 'up', delay = 0, className = '' }) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(true);
|
||||
}, delay);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [delay]);
|
||||
|
||||
const getTransformClasses = () => {
|
||||
const transforms = {
|
||||
left: isVisible ? 'translate-x-0' : '-translate-x-8',
|
||||
right: isVisible ? 'translate-x-0' : 'translate-x-8',
|
||||
up: isVisible ? 'translate-y-0' : 'translate-y-8',
|
||||
down: isVisible ? 'translate-y-0' : '-translate-y-8'
|
||||
};
|
||||
return transforms[direction];
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
transition-all duration-500 ease-out
|
||||
${isVisible ? 'opacity-100' : 'opacity-0'}
|
||||
${getTransformClasses()}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 缩放动画组件
|
||||
*/
|
||||
export const ScaleIn: React.FC<{
|
||||
children: React.ReactNode;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}> = ({ children, delay = 0, className = '' }) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(true);
|
||||
}, delay);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [delay]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
transition-all duration-300 ease-out
|
||||
${isVisible
|
||||
? 'opacity-100 scale-100'
|
||||
: 'opacity-0 scale-95'
|
||||
}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 悬停放大组件
|
||||
*/
|
||||
export const HoverScale: React.FC<{
|
||||
children: React.ReactNode;
|
||||
scale?: number;
|
||||
className?: string;
|
||||
}> = ({ children, scale = 1.05, className = '' }) => {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
transition-transform duration-200 ease-out
|
||||
hover:scale-[${scale}]
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 悬停倾斜组件
|
||||
*/
|
||||
export const HoverTilt: React.FC<{
|
||||
children: React.ReactNode;
|
||||
degree?: number;
|
||||
className?: string;
|
||||
}> = ({ children, degree = 2, className = '' }) => {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
transition-transform duration-200 ease-out
|
||||
hover:rotate-[${degree}deg]
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 脉冲动画组件
|
||||
*/
|
||||
export const Pulse: React.FC<{
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}> = ({ children, className = '' }) => {
|
||||
return (
|
||||
<div className={`animate-pulse ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 呼吸动画组件
|
||||
*/
|
||||
export const Breathe: React.FC<{
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}> = ({ children, className = '' }) => {
|
||||
return (
|
||||
<div className={`animate-breathe ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -17,9 +17,7 @@ import {
|
||||
FileAudio,
|
||||
FileImage,
|
||||
File,
|
||||
HardDrive,
|
||||
BarChart3,
|
||||
Hash
|
||||
BarChart3
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ProjectCardProps {
|
||||
@@ -141,131 +139,159 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card p-6 hover:shadow-lg hover:-translate-y-1 transition-all duration-200 group cursor-pointer animate-slide-up">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-primary-600 group-hover:text-primary-700 transition-colors duration-200">
|
||||
<Folder size={24} />
|
||||
</div>
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
className="p-1 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors duration-200 opacity-0 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowMenu(!showMenu);
|
||||
}}
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
{showMenu && (
|
||||
<div className="dropdown-menu animate-fade-in">
|
||||
<button
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
onOpen(project);
|
||||
setShowMenu(false);
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
打开项目
|
||||
</button>
|
||||
<button
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
handleOpenFolder(e);
|
||||
setShowMenu(false);
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
打开文件夹
|
||||
</button>
|
||||
<button
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
onEdit(project);
|
||||
setShowMenu(false);
|
||||
}}
|
||||
>
|
||||
<Edit3 size={14} />
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
className="dropdown-item danger"
|
||||
onClick={() => {
|
||||
onDelete(project.id);
|
||||
setShowMenu(false);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
</button>
|
||||
<div className="card card-interactive group cursor-pointer animate-fade-in-up relative overflow-hidden">
|
||||
{/* 背景装饰 */}
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-primary-100 to-blue-100 rounded-full -translate-y-16 translate-x-16 opacity-50 group-hover:opacity-70 transition-opacity duration-300"></div>
|
||||
|
||||
{/* 卡片头部 */}
|
||||
<div className="relative p-6 pb-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 bg-primary-100 text-primary-600 rounded-xl group-hover:bg-primary-200 group-hover:scale-110 transition-all duration-300">
|
||||
<Folder size={20} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 line-clamp-1 group-hover:text-primary-700 transition-colors duration-200">
|
||||
{project.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 mt-1">
|
||||
<Calendar size={12} />
|
||||
<span>{formatTime(project.updated_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 菜单按钮 */}
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
className="p-2 rounded-xl text-gray-400 hover:text-gray-600 hover:bg-white hover:shadow-md transition-all duration-200 opacity-0 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowMenu(!showMenu);
|
||||
}}
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
{showMenu && (
|
||||
<div className="dropdown-menu animate-scale-in">
|
||||
<button
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
onOpen(project);
|
||||
setShowMenu(false);
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
打开项目
|
||||
</button>
|
||||
<button
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
handleOpenFolder(e);
|
||||
setShowMenu(false);
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
打开文件夹
|
||||
</button>
|
||||
<div className="dropdown-divider"></div>
|
||||
<button
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
onEdit(project);
|
||||
setShowMenu(false);
|
||||
}}
|
||||
>
|
||||
<Edit3 size={14} />
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
className="dropdown-item danger"
|
||||
onClick={() => {
|
||||
onDelete(project.id);
|
||||
setShowMenu(false);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4" onClick={() => onOpen(project)}>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2 line-clamp-1">
|
||||
{project.name}
|
||||
</h3>
|
||||
|
||||
{/* 项目描述 */}
|
||||
{project.description && (
|
||||
<p className="text-sm text-gray-600 mb-3 line-clamp-2">
|
||||
<p className="text-sm text-gray-600 line-clamp-2 mb-4 leading-relaxed">
|
||||
{project.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 项目统计信息 */}
|
||||
{/* 项目统计信息 */}
|
||||
<div className="relative px-6 pb-4" onClick={() => onOpen(project)}>
|
||||
{isLoadingStats ? (
|
||||
<div className="bg-gray-50 rounded-lg p-3 mb-3">
|
||||
<div className="flex items-center space-x-1 text-gray-500 text-sm">
|
||||
<div className="bg-gradient-to-r from-gray-50 to-gray-100 rounded-xl p-4 mb-4">
|
||||
<div className="flex items-center space-x-2 text-gray-500 text-sm">
|
||||
<BarChart3 className="w-4 h-4 animate-pulse" />
|
||||
<span>加载统计中...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : stats && (
|
||||
<div className="bg-gray-50 rounded-lg p-3 mb-3 space-y-2">
|
||||
<div className="flex items-center space-x-1 text-gray-700 font-medium text-sm">
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
<span>项目统计</span>
|
||||
<div className="bg-gradient-to-r from-primary-50 to-blue-50 rounded-xl p-4 mb-4 border border-primary-100">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center space-x-2 text-primary-700 font-semibold text-sm">
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
<span>项目统计</span>
|
||||
</div>
|
||||
{stats.total_materials > 0 && (
|
||||
<div className="badge badge-primary">
|
||||
{stats.total_materials} 个素材
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 素材统计 */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="flex items-center space-x-1 text-gray-600">
|
||||
<Hash className="w-3 h-3" />
|
||||
<span>{stats.total_materials} 个素材</span>
|
||||
{/* 素材统计网格 */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{stats.total_materials}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">总素材</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1 text-gray-600">
|
||||
<HardDrive className="w-3 h-3" />
|
||||
<span>{formatFileSize(stats.total_size)}</span>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{formatFileSize(stats.total_size)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">总大小</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文件类型分布 */}
|
||||
{(stats.video_count > 0 || stats.audio_count > 0 || stats.image_count > 0) && (
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{(stats.video_count > 0 || stats.audio_count > 0 || stats.image_count > 0 || stats.other_count > 0) && (
|
||||
<div className="flex items-center justify-center gap-4 text-xs">
|
||||
{stats.video_count > 0 && (
|
||||
<div className="flex items-center space-x-1 text-blue-600">
|
||||
<div className="flex items-center space-x-1 text-blue-600 bg-blue-50 px-2 py-1 rounded-lg">
|
||||
<FileVideo className="w-3 h-3" />
|
||||
<span>{stats.video_count}</span>
|
||||
<span className="font-medium">{stats.video_count}</span>
|
||||
</div>
|
||||
)}
|
||||
{stats.audio_count > 0 && (
|
||||
<div className="flex items-center space-x-1 text-green-600">
|
||||
<div className="flex items-center space-x-1 text-green-600 bg-green-50 px-2 py-1 rounded-lg">
|
||||
<FileAudio className="w-3 h-3" />
|
||||
<span>{stats.audio_count}</span>
|
||||
<span className="font-medium">{stats.audio_count}</span>
|
||||
</div>
|
||||
)}
|
||||
{stats.image_count > 0 && (
|
||||
<div className="flex items-center space-x-1 text-purple-600">
|
||||
<div className="flex items-center space-x-1 text-purple-600 bg-purple-50 px-2 py-1 rounded-lg">
|
||||
<FileImage className="w-3 h-3" />
|
||||
<span>{stats.image_count}</span>
|
||||
<span className="font-medium">{stats.image_count}</span>
|
||||
</div>
|
||||
)}
|
||||
{stats.other_count > 0 && (
|
||||
<div className="flex items-center space-x-1 text-gray-600">
|
||||
<div className="flex items-center space-x-1 text-gray-600 bg-gray-50 px-2 py-1 rounded-lg">
|
||||
<File className="w-3 h-3" />
|
||||
<span>{stats.other_count}</span>
|
||||
<span className="font-medium">{stats.other_count}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -273,43 +299,40 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 项目基本信息 */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<MapPin size={12} />
|
||||
<span className="truncate" title={project.path}>
|
||||
{getDirectoryName(project.path)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<Calendar size={12} />
|
||||
<span>
|
||||
{formatTime(project.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
{/* 项目路径信息 */}
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 mb-4">
|
||||
<MapPin size={12} />
|
||||
<span className="truncate bg-gray-100 px-2 py-1 rounded-lg font-mono" title={project.path}>
|
||||
{getDirectoryName(project.path)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4 border-t border-gray-100">
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={handleOpenFolder}
|
||||
title="打开项目文件夹"
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm flex-1"
|
||||
onClick={() => onEdit(project)}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary btn-sm flex-1"
|
||||
onClick={() => onOpen(project)}
|
||||
>
|
||||
打开
|
||||
</button>
|
||||
{/* 卡片底部操作区 */}
|
||||
<div className="relative px-6 pb-6">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="btn btn-ghost btn-sm p-2"
|
||||
onClick={handleOpenFolder}
|
||||
title="打开项目文件夹"
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm flex-1"
|
||||
onClick={() => onEdit(project)}
|
||||
>
|
||||
<Edit3 size={16} />
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary btn-sm flex-1 shadow-glow"
|
||||
onClick={() => onOpen(project)}
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
打开
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -133,20 +133,33 @@ export const ProjectForm: React.FC<ProjectFormProps> = ({
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal">
|
||||
<div className="modal modal-lg">
|
||||
{/* 模态框头部 */}
|
||||
<div className="modal-header">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
{isEdit ? '编辑项目' : '新建项目'}
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-primary-100 text-primary-600 rounded-xl">
|
||||
<Folder size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
{isEdit ? '编辑项目' : '新建项目'}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
{isEdit ? '修改项目信息' : '创建一个新的视频项目'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="modal-close" onClick={onCancel}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="name" className="form-label">
|
||||
项目名称 *
|
||||
{/* 模态框内容 */}
|
||||
<form onSubmit={handleSubmit} className="modal-body space-y-6">
|
||||
{/* 项目名称 */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="name" className="form-label required">
|
||||
项目名称
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
@@ -154,8 +167,9 @@ export const ProjectForm: React.FC<ProjectFormProps> = ({
|
||||
className={`form-input ${errors.name ? 'error' : ''}`}
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="输入项目名称"
|
||||
placeholder="为您的项目起一个好听的名字"
|
||||
maxLength={100}
|
||||
autoFocus
|
||||
/>
|
||||
{errors.name && (
|
||||
<div className="form-error">
|
||||
@@ -163,22 +177,48 @@ export const ProjectForm: React.FC<ProjectFormProps> = ({
|
||||
{errors.name}
|
||||
</div>
|
||||
)}
|
||||
<div className="form-hint">
|
||||
{formData.name.length}/100 字符
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="path" className="form-label">
|
||||
项目路径 *
|
||||
{/* 项目路径 */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="path" className="form-label required">
|
||||
项目路径
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id="path"
|
||||
type="text"
|
||||
className={`form-input ${errors.path ? 'error' : ''}`}
|
||||
value={formData.path}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, path: e.target.value }))}
|
||||
placeholder="选择或输入项目路径"
|
||||
readOnly={isEdit}
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
id="path"
|
||||
type="text"
|
||||
className={`form-input ${errors.path ? 'error' : ''}`}
|
||||
value={formData.path}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, path: e.target.value }))}
|
||||
placeholder="选择项目存储位置"
|
||||
readOnly={isEdit}
|
||||
/>
|
||||
{isValidatingPath && (
|
||||
<div className="form-info flex items-center gap-2 mt-2">
|
||||
<div className="w-4 h-4 border-2 border-primary-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
验证路径中...
|
||||
</div>
|
||||
)}
|
||||
{errors.path && (
|
||||
<div className="form-error">
|
||||
<AlertCircle size={14} />
|
||||
{errors.path}
|
||||
</div>
|
||||
)}
|
||||
{!errors.path && formData.path && !isValidatingPath && (
|
||||
<div className="form-success">
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
路径有效
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isEdit && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -186,22 +226,17 @@ export const ProjectForm: React.FC<ProjectFormProps> = ({
|
||||
onClick={handleSelectDirectory}
|
||||
>
|
||||
<Folder size={16} />
|
||||
浏览
|
||||
浏览文件夹
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isValidatingPath && (
|
||||
<div className="form-info">验证路径中...</div>
|
||||
)}
|
||||
{errors.path && (
|
||||
<div className="form-error">
|
||||
<AlertCircle size={14} />
|
||||
{errors.path}
|
||||
</div>
|
||||
)}
|
||||
<div className="form-hint">
|
||||
{isEdit ? '编辑模式下无法修改项目路径' : '选择一个空文件夹或新建文件夹作为项目目录'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* 项目描述 */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="description" className="form-label">
|
||||
项目描述
|
||||
</label>
|
||||
@@ -210,12 +245,17 @@ export const ProjectForm: React.FC<ProjectFormProps> = ({
|
||||
className={`form-textarea ${errors.description ? 'error' : ''}`}
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="输入项目描述(可选)"
|
||||
rows={3}
|
||||
placeholder="描述一下这个项目的用途和内容(可选)"
|
||||
rows={4}
|
||||
maxLength={500}
|
||||
/>
|
||||
<div className="form-hint">
|
||||
{formData.description.length}/500
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="form-hint">
|
||||
添加描述有助于您更好地管理项目
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{formData.description.length}/500
|
||||
</div>
|
||||
</div>
|
||||
{errors.description && (
|
||||
<div className="form-error">
|
||||
@@ -224,25 +264,37 @@ export const ProjectForm: React.FC<ProjectFormProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary flex-1"
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary flex-1"
|
||||
disabled={isLoading || isValidatingPath || Object.keys(errors).length > 0}
|
||||
>
|
||||
{isLoading ? '处理中...' : (isEdit ? '保存' : '创建')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* 模态框底部 */}
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary flex-1"
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="project-form"
|
||||
className="btn btn-primary flex-1 shadow-glow"
|
||||
disabled={isLoading || isValidatingPath || Object.keys(errors).length > 0}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
处理中...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{isEdit ? '保存更改' : '创建项目'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -80,32 +80,43 @@ export const ProjectList: React.FC = () => {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="w-full animate-fade-in">
|
||||
<div className="flex items-center justify-between mb-8 pb-4 border-b border-gray-200">
|
||||
<h1 className="text-3xl font-bold text-gray-900">我的项目</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleCleanupInvalidProjects}
|
||||
disabled={isCleaningUp}
|
||||
title="清理无效的项目记录"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
{isCleaningUp ? '清理中...' : '清理'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={openCreateProjectModal}
|
||||
>
|
||||
<Plus size={20} />
|
||||
新建项目
|
||||
</button>
|
||||
{/* 页面头部 - 错误状态 */}
|
||||
<div className="relative mb-8">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-red-50 to-orange-50 rounded-3xl opacity-50"></div>
|
||||
<div className="relative flex flex-col sm:flex-row sm:items-center justify-between p-6 rounded-3xl border border-red-100 bg-white/80 backdrop-blur-sm">
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">我的项目</h1>
|
||||
<p className="text-red-600 text-sm">
|
||||
加载项目时遇到问题
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={handleCleanupInvalidProjects}
|
||||
disabled={isCleaningUp}
|
||||
title="清理无效的项目记录"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{isCleaningUp ? '清理中...' : '清理'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={openCreateProjectModal}
|
||||
>
|
||||
<Plus size={20} />
|
||||
新建项目
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
onRetry={loadProjects}
|
||||
onDismiss={clearError}
|
||||
/>
|
||||
<div className="animate-fade-in-up">
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
onRetry={loadProjects}
|
||||
onDismiss={clearError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -114,83 +125,143 @@ export const ProjectList: React.FC = () => {
|
||||
if (isLoading && projects.length === 0) {
|
||||
return (
|
||||
<div className="w-full animate-fade-in">
|
||||
<div className="flex items-center justify-between mb-8 pb-4 border-b border-gray-200">
|
||||
<h1 className="text-3xl font-bold text-gray-900">我的项目</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleCleanupInvalidProjects}
|
||||
disabled={isCleaningUp}
|
||||
title="清理无效的项目记录"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
{isCleaningUp ? '清理中...' : '清理'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={openCreateProjectModal}
|
||||
>
|
||||
<Plus size={20} />
|
||||
新建项目
|
||||
</button>
|
||||
{/* 页面头部 - 加载状态 */}
|
||||
<div className="relative mb-8">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary-50 to-blue-50 rounded-3xl opacity-50"></div>
|
||||
<div className="relative flex flex-col sm:flex-row sm:items-center justify-between p-6 rounded-3xl border border-gray-100 bg-white/80 backdrop-blur-sm">
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<h1 className="text-3xl font-bold text-gradient-primary mb-2">我的项目</h1>
|
||||
<p className="text-gray-600 text-sm">
|
||||
正在加载您的项目...
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={handleCleanupInvalidProjects}
|
||||
disabled={isCleaningUp}
|
||||
title="清理无效的项目记录"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{isCleaningUp ? '清理中...' : '清理'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={openCreateProjectModal}
|
||||
disabled
|
||||
>
|
||||
<Plus size={20} />
|
||||
新建项目
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<LoadingSpinner text="加载项目中..." />
|
||||
<div className="flex items-center justify-center py-16 animate-fade-in-up">
|
||||
<div className="text-center">
|
||||
<LoadingSpinner size="large" text="加载项目中..." />
|
||||
<p className="text-gray-500 text-sm mt-4">请稍候,正在获取您的项目列表</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between mb-8 pb-4 border-b border-gray-200">
|
||||
<h1 className="text-3xl font-bold text-gray-900">我的项目</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleCleanupInvalidProjects}
|
||||
disabled={isLoading || isCleaningUp}
|
||||
title="清理无效的项目记录"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
{isCleaningUp ? '清理中...' : '清理'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={openCreateProjectModal}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Plus size={20} />
|
||||
新建项目
|
||||
</button>
|
||||
<div className="w-full animate-fade-in">
|
||||
{/* 页面头部 - 增强设计 */}
|
||||
<div className="relative mb-8">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary-50 to-blue-50 rounded-3xl opacity-50"></div>
|
||||
<div className="relative flex flex-col sm:flex-row sm:items-center justify-between p-6 rounded-3xl border border-gray-100 bg-white/80 backdrop-blur-sm">
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<h1 className="text-3xl font-bold text-gradient-primary mb-2">我的项目</h1>
|
||||
<p className="text-gray-600 text-sm">
|
||||
管理您的视频项目,开始创作之旅
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={handleCleanupInvalidProjects}
|
||||
disabled={isLoading || isCleaningUp}
|
||||
title="清理无效的项目记录"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{isCleaningUp ? '清理中...' : '清理'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary shadow-glow"
|
||||
onClick={openCreateProjectModal}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Plus size={20} />
|
||||
新建项目
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<EmptyState
|
||||
title="还没有项目"
|
||||
description="创建您的第一个项目开始使用 MixVideo"
|
||||
actionText="新建项目"
|
||||
onAction={openCreateProjectModal}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{projects.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
onOpen={handleProjectOpen}
|
||||
onEdit={handleProjectEdit}
|
||||
onDelete={handleProjectDelete}
|
||||
/>
|
||||
))}
|
||||
{/* 项目统计信息 */}
|
||||
{projects.length > 0 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
|
||||
<div className="card card-body text-center">
|
||||
<div className="text-2xl font-bold text-primary-600 mb-1">
|
||||
{projects.length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">总项目数</div>
|
||||
</div>
|
||||
<div className="card card-body text-center">
|
||||
<div className="text-2xl font-bold text-green-600 mb-1">
|
||||
{projects.filter(p => new Date(p.updated_at) > new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)).length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">本周活跃</div>
|
||||
</div>
|
||||
<div className="card card-body text-center">
|
||||
<div className="text-2xl font-bold text-blue-600 mb-1">
|
||||
{projects.filter(p => new Date(p.created_at) > new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)).length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">本月新建</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 项目内容区域 */}
|
||||
{projects.length === 0 ? (
|
||||
<div className="animate-fade-in-up">
|
||||
<EmptyState
|
||||
title="还没有项目"
|
||||
description="创建您的第一个项目开始使用 MixVideo"
|
||||
actionText="新建项目"
|
||||
onAction={openCreateProjectModal}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* 项目网格 */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{projects.map((project, index) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="animate-fade-in-up"
|
||||
style={{ animationDelay: `${index * 0.1}s` }}
|
||||
>
|
||||
<ProjectCard
|
||||
project={project}
|
||||
onOpen={handleProjectOpen}
|
||||
onEdit={handleProjectEdit}
|
||||
onDelete={handleProjectDelete}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 加载遮罩 - 改进设计 */}
|
||||
{isLoading && projects.length > 0 && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-20 flex items-center justify-center z-40">
|
||||
<LoadingSpinner size="small" />
|
||||
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm flex items-center justify-center z-40 animate-fade-in">
|
||||
<div className="bg-white rounded-2xl p-6 shadow-2xl">
|
||||
<LoadingSpinner size="medium" text="更新中..." />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
136
apps/desktop/src/components/SkeletonLoader.tsx
Normal file
136
apps/desktop/src/components/SkeletonLoader.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React from 'react';
|
||||
|
||||
interface SkeletonLoaderProps {
|
||||
variant?: 'card' | 'list' | 'text' | 'avatar' | 'button';
|
||||
count?: number;
|
||||
className?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 骨架屏加载组件
|
||||
* 提供多种预设样式和自定义选项
|
||||
*/
|
||||
export const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
|
||||
variant = 'text',
|
||||
count = 1,
|
||||
className = '',
|
||||
width,
|
||||
height
|
||||
}) => {
|
||||
const baseClasses = 'animate-pulse bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 bg-[length:200%_100%] animate-loading-shimmer rounded-lg';
|
||||
|
||||
const getVariantClasses = () => {
|
||||
switch (variant) {
|
||||
case 'card':
|
||||
return 'h-48 w-full';
|
||||
case 'list':
|
||||
return 'h-16 w-full';
|
||||
case 'avatar':
|
||||
return 'h-12 w-12 rounded-full';
|
||||
case 'button':
|
||||
return 'h-10 w-24';
|
||||
default:
|
||||
return 'h-4 w-full';
|
||||
}
|
||||
};
|
||||
|
||||
const renderSkeleton = (index: number) => {
|
||||
const variantClasses = getVariantClasses();
|
||||
const customStyle = {
|
||||
width: width || undefined,
|
||||
height: height || undefined,
|
||||
};
|
||||
|
||||
if (variant === 'card') {
|
||||
return (
|
||||
<div key={index} className={`${baseClasses} ${className} p-6 space-y-4`} style={customStyle}>
|
||||
{/* 卡片头部 */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="h-12 w-12 bg-gray-300 rounded-xl"></div>
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="h-4 bg-gray-300 rounded w-3/4"></div>
|
||||
<div className="h-3 bg-gray-300 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 卡片内容 */}
|
||||
<div className="space-y-3">
|
||||
<div className="h-3 bg-gray-300 rounded"></div>
|
||||
<div className="h-3 bg-gray-300 rounded w-5/6"></div>
|
||||
</div>
|
||||
|
||||
{/* 统计区域 */}
|
||||
<div className="bg-gray-200 rounded-xl p-4 space-y-3">
|
||||
<div className="h-3 bg-gray-300 rounded w-1/3"></div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="h-8 bg-gray-300 rounded"></div>
|
||||
<div className="h-8 bg-gray-300 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<div className="flex gap-2 pt-4">
|
||||
<div className="h-8 w-8 bg-gray-300 rounded-lg"></div>
|
||||
<div className="h-8 bg-gray-300 rounded flex-1"></div>
|
||||
<div className="h-8 bg-gray-300 rounded flex-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'list') {
|
||||
return (
|
||||
<div key={index} className={`${baseClasses} ${className} flex items-center space-x-4 p-4`} style={customStyle}>
|
||||
<div className="h-10 w-10 bg-gray-300 rounded-lg"></div>
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="h-4 bg-gray-300 rounded w-3/4"></div>
|
||||
<div className="h-3 bg-gray-300 rounded w-1/2"></div>
|
||||
</div>
|
||||
<div className="h-8 w-20 bg-gray-300 rounded"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`${baseClasses} ${variantClasses} ${className}`}
|
||||
style={customStyle}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{Array.from({ length: count }, (_, index) => renderSkeleton(index))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 项目卡片骨架屏
|
||||
*/
|
||||
export const ProjectCardSkeleton: React.FC<{ count?: number }> = ({ count = 4 }) => {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{Array.from({ length: count }, (_, index) => (
|
||||
<SkeletonLoader key={index} variant="card" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 列表项骨架屏
|
||||
*/
|
||||
export const ListItemSkeleton: React.FC<{ count?: number }> = ({ count = 5 }) => {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: count }, (_, index) => (
|
||||
<SkeletonLoader key={index} variant="list" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ export default {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// 主色调 - 现代蓝色系
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
@@ -18,8 +19,11 @@ export default {
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
950: '#172554',
|
||||
},
|
||||
// 中性色 - 更丰富的灰色层次
|
||||
gray: {
|
||||
25: '#fcfcfd',
|
||||
50: '#f9fafb',
|
||||
100: '#f3f4f6',
|
||||
200: '#e5e7eb',
|
||||
@@ -30,25 +34,268 @@ export default {
|
||||
700: '#374151',
|
||||
800: '#1f2937',
|
||||
900: '#111827',
|
||||
950: '#030712',
|
||||
},
|
||||
// 成功色
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
},
|
||||
// 警告色
|
||||
warning: {
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
200: '#fde68a',
|
||||
300: '#fcd34d',
|
||||
400: '#fbbf24',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
700: '#b45309',
|
||||
800: '#92400e',
|
||||
900: '#78350f',
|
||||
},
|
||||
// 错误色
|
||||
error: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
},
|
||||
// 信息色
|
||||
info: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'sans-serif'],
|
||||
sans: [
|
||||
'Inter',
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'Segoe UI',
|
||||
'Roboto',
|
||||
'Oxygen',
|
||||
'Ubuntu',
|
||||
'Cantarell',
|
||||
'sans-serif'
|
||||
],
|
||||
mono: [
|
||||
'JetBrains Mono',
|
||||
'Fira Code',
|
||||
'Monaco',
|
||||
'Consolas',
|
||||
'Liberation Mono',
|
||||
'Courier New',
|
||||
'monospace'
|
||||
],
|
||||
},
|
||||
fontSize: {
|
||||
'xs': ['0.75rem', { lineHeight: '1rem' }],
|
||||
'sm': ['0.875rem', { lineHeight: '1.25rem' }],
|
||||
'base': ['1rem', { lineHeight: '1.5rem' }],
|
||||
'lg': ['1.125rem', { lineHeight: '1.75rem' }],
|
||||
'xl': ['1.25rem', { lineHeight: '1.75rem' }],
|
||||
'2xl': ['1.5rem', { lineHeight: '2rem' }],
|
||||
'3xl': ['1.875rem', { lineHeight: '2.25rem' }],
|
||||
'4xl': ['2.25rem', { lineHeight: '2.5rem' }],
|
||||
'5xl': ['3rem', { lineHeight: '1' }],
|
||||
'6xl': ['3.75rem', { lineHeight: '1' }],
|
||||
},
|
||||
spacing: {
|
||||
'18': '4.5rem',
|
||||
'88': '22rem',
|
||||
'128': '32rem',
|
||||
},
|
||||
borderRadius: {
|
||||
'none': '0',
|
||||
'sm': '0.125rem',
|
||||
'DEFAULT': '0.25rem',
|
||||
'md': '0.375rem',
|
||||
'lg': '0.5rem',
|
||||
'xl': '0.75rem',
|
||||
'2xl': '1rem',
|
||||
'3xl': '1.5rem',
|
||||
'full': '9999px',
|
||||
},
|
||||
boxShadow: {
|
||||
'xs': '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
||||
'sm': '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
|
||||
'DEFAULT': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
|
||||
'md': '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
|
||||
'lg': '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)',
|
||||
'xl': '0 25px 50px -12px rgb(0 0 0 / 0.25)',
|
||||
'2xl': '0 25px 50px -12px rgb(0 0 0 / 0.25)',
|
||||
'inner': 'inset 0 2px 4px 0 rgb(0 0 0 / 0.05)',
|
||||
'glow': '0 0 20px rgb(59 130 246 / 0.15)',
|
||||
'glow-lg': '0 0 40px rgb(59 130 246 / 0.15)',
|
||||
},
|
||||
animation: {
|
||||
// 基础动画
|
||||
'spin-slow': 'spin 3s linear infinite',
|
||||
'fade-in': 'fadeIn 0.5s ease-in-out',
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
|
||||
// 进入动画
|
||||
'fade-in': 'fadeIn 0.3s ease-out',
|
||||
'fade-in-up': 'fadeInUp 0.4s ease-out',
|
||||
'fade-in-down': 'fadeInDown 0.4s ease-out',
|
||||
'fade-in-left': 'fadeInLeft 0.4s ease-out',
|
||||
'fade-in-right': 'fadeInRight 0.4s ease-out',
|
||||
|
||||
// 滑动动画
|
||||
'slide-up': 'slideUp 0.3s ease-out',
|
||||
'slide-down': 'slideDown 0.3s ease-out',
|
||||
'slide-left': 'slideLeft 0.3s ease-out',
|
||||
'slide-right': 'slideRight 0.3s ease-out',
|
||||
|
||||
// 缩放动画
|
||||
'scale-in': 'scaleIn 0.2s ease-out',
|
||||
'scale-out': 'scaleOut 0.2s ease-in',
|
||||
|
||||
// 弹跳动画
|
||||
'bounce-in': 'bounceIn 0.6s ease-out',
|
||||
'bounce-subtle': 'bounceSubtle 0.6s ease-out',
|
||||
|
||||
// 摇摆动画
|
||||
'wiggle': 'wiggle 1s ease-in-out infinite',
|
||||
'shake': 'shake 0.5s ease-in-out',
|
||||
|
||||
// 呼吸动画
|
||||
'breathe': 'breathe 2s ease-in-out infinite',
|
||||
|
||||
// 加载动画
|
||||
'loading-dots': 'loadingDots 1.4s ease-in-out infinite',
|
||||
'loading-bars': 'loadingBars 1.2s ease-in-out infinite',
|
||||
'loading-shimmer': 'loadingShimmer 2s ease-in-out infinite',
|
||||
},
|
||||
keyframes: {
|
||||
// 淡入动画
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
fadeInUp: {
|
||||
'0%': { opacity: '0', transform: 'translateY(20px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
fadeInDown: {
|
||||
'0%': { opacity: '0', transform: 'translateY(-20px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
fadeInLeft: {
|
||||
'0%': { opacity: '0', transform: 'translateX(-20px)' },
|
||||
'100%': { opacity: '1', transform: 'translateX(0)' },
|
||||
},
|
||||
fadeInRight: {
|
||||
'0%': { opacity: '0', transform: 'translateX(20px)' },
|
||||
'100%': { opacity: '1', transform: 'translateX(0)' },
|
||||
},
|
||||
|
||||
// 滑动动画
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
slideDown: {
|
||||
'0%': { transform: 'translateY(-10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
slideLeft: {
|
||||
'0%': { transform: 'translateX(10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateX(0)', opacity: '1' },
|
||||
},
|
||||
slideRight: {
|
||||
'0%': { transform: 'translateX(-10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateX(0)', opacity: '1' },
|
||||
},
|
||||
|
||||
// 缩放动画
|
||||
scaleIn: {
|
||||
'0%': { transform: 'scale(0.95)', opacity: '0' },
|
||||
'100%': { transform: 'scale(1)', opacity: '1' },
|
||||
},
|
||||
scaleOut: {
|
||||
'0%': { transform: 'scale(1)', opacity: '1' },
|
||||
'100%': { transform: 'scale(0.95)', opacity: '0' },
|
||||
},
|
||||
|
||||
// 弹跳动画
|
||||
bounceIn: {
|
||||
'0%': { transform: 'scale(0.3)', opacity: '0' },
|
||||
'50%': { transform: 'scale(1.05)' },
|
||||
'70%': { transform: 'scale(0.9)' },
|
||||
'100%': { transform: 'scale(1)', opacity: '1' },
|
||||
},
|
||||
bounceSubtle: {
|
||||
'0%, 20%, 53%, 80%, 100%': { transform: 'translate3d(0,0,0)' },
|
||||
'40%, 43%': { transform: 'translate3d(0, -8px, 0)' },
|
||||
'70%': { transform: 'translate3d(0, -4px, 0)' },
|
||||
'90%': { transform: 'translate3d(0, -2px, 0)' },
|
||||
},
|
||||
|
||||
// 摇摆动画
|
||||
wiggle: {
|
||||
'0%, 100%': { transform: 'rotate(-3deg)' },
|
||||
'50%': { transform: 'rotate(3deg)' },
|
||||
},
|
||||
shake: {
|
||||
'0%, 100%': { transform: 'translateX(0)' },
|
||||
'10%, 30%, 50%, 70%, 90%': { transform: 'translateX(-2px)' },
|
||||
'20%, 40%, 60%, 80%': { transform: 'translateX(2px)' },
|
||||
},
|
||||
|
||||
// 呼吸动画
|
||||
breathe: {
|
||||
'0%, 100%': { transform: 'scale(1)', opacity: '1' },
|
||||
'50%': { transform: 'scale(1.05)', opacity: '0.8' },
|
||||
},
|
||||
|
||||
// 加载动画
|
||||
loadingDots: {
|
||||
'0%, 80%, 100%': { transform: 'scale(0)' },
|
||||
'40%': { transform: 'scale(1)' },
|
||||
},
|
||||
loadingBars: {
|
||||
'0%, 40%, 100%': { transform: 'scaleY(0.4)' },
|
||||
'20%': { transform: 'scaleY(1)' },
|
||||
},
|
||||
loadingShimmer: {
|
||||
'0%': { backgroundPosition: '-200% 0' },
|
||||
'100%': { backgroundPosition: '200% 0' },
|
||||
},
|
||||
},
|
||||
backdropBlur: {
|
||||
'xs': '2px',
|
||||
},
|
||||
transitionTimingFunction: {
|
||||
'bounce-in': 'cubic-bezier(0.68, -0.55, 0.265, 1.55)',
|
||||
'smooth': 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'snappy': 'cubic-bezier(0.4, 0, 0.6, 1)',
|
||||
},
|
||||
transitionDuration: {
|
||||
'400': '400ms',
|
||||
'600': '600ms',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user