feat: UI美化和UX改进 v0.1.5

- 优化设计系统和主题配置
  - 增强Tailwind配置,添加更丰富的色彩系统
  - 新增多种动画效果和过渡动画
  - 改进字体系统和间距设置
  - 添加阴影变体和渐变效果

- 改进项目列表页面UI/UX
  - 重新设计页面头部,添加渐变背景和统计信息
  - 优化项目卡片布局和响应式设计
  - 增强加载和错误状态的视觉效果
  - 添加交错动画效果

- 优化项目卡片组件
  - 重新设计卡片布局,添加背景装饰
  - 改进统计信息展示,使用渐变背景和徽章
  - 增强悬停效果和交互反馈
  - 优化菜单和按钮设计

- 改进表单和模态框体验
  - 重新设计模态框头部和布局
  - 增强表单字段的视觉设计和验证反馈
  - 添加加载状态和成功状态指示
  - 改进按钮样式和交互效果

- 增强加载状态和反馈机制
  - 重新设计EmptyState组件,添加装饰效果
  - 增强LoadingSpinner,支持多种动画样式
  - 改进ErrorMessage组件,支持多种消息类型
  - 新增SkeletonLoader组件用于骨架屏加载

- 添加微交互和动画效果
  - 新增AnimatedButton组件,支持涟漪效果
  - 创建PageTransition组件用于页面过渡
  - 添加多种动画工具组件
  - 增强按钮和卡片的微交互效果

- 优化响应式设计和移动端体验
  - 改进容器布局和间距设置
  - 优化移动端的触摸体验
  - 确保所有组件在不同屏幕尺寸下的完美适配

遵循前端开发规范,提升界面美观性和用户体验
This commit is contained in:
imeepos
2025-07-14 00:23:58 +08:00
parent ae854ef871
commit c366261008
12 changed files with 1751 additions and 346 deletions

View File

@@ -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);
}
}

View File

@@ -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 />} />

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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>
);
};

View 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>
);
};

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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>

View 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>
);
};

View File

@@ -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',
},
},
},