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 components;
|
||||||
@tailwind utilities;
|
@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 {
|
@layer components {
|
||||||
/* 按钮样式 */
|
/* 按钮样式系统 */
|
||||||
.btn {
|
.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 {
|
.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 {
|
.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 {
|
.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 {
|
.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 {
|
.btn-sm {
|
||||||
@apply px-3 py-1.5 text-xs;
|
@apply px-3 py-2 text-xs rounded-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-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 {
|
.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 {
|
.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 {
|
.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 {
|
.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 {
|
.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 {
|
.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 {
|
.form-hint {
|
||||||
@@ -63,50 +138,106 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-info {
|
.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 {
|
.spinner {
|
||||||
@apply animate-spin;
|
@apply animate-spin;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 模态框样式 */
|
.loading-pulse {
|
||||||
|
@apply animate-pulse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-bounce {
|
||||||
|
@apply animate-bounce;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 模态框样式增强 */
|
||||||
.modal-overlay {
|
.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 {
|
.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 {
|
.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 {
|
.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 {
|
.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 {
|
.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 {
|
.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 {
|
.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 {
|
.line-clamp-1 {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 1;
|
-webkit-line-clamp: 1;
|
||||||
@@ -121,30 +252,157 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 动画增强 */
|
.line-clamp-3 {
|
||||||
.animate-fade-in {
|
display: -webkit-box;
|
||||||
animation: fadeIn 0.3s ease-out;
|
-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 {
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-track {
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
@apply bg-gray-100 rounded-full;
|
@apply bg-gray-50 rounded-full;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
.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 {
|
.focus-visible {
|
||||||
@apply focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2;
|
@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 (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-50">
|
||||||
<main className="container mx-auto px-6 py-8 max-w-7xl">
|
{/* 主要内容区域 */}
|
||||||
|
<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>
|
<Routes>
|
||||||
<Route path="/" element={<ProjectList />} />
|
<Route path="/" element={<ProjectList />} />
|
||||||
<Route path="/project/:id" element={<ProjectDetails />} />
|
<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 React from 'react';
|
||||||
import { FolderPlus } from 'lucide-react';
|
import { FolderPlus, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
interface EmptyStateProps {
|
interface EmptyStateProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -10,7 +10,7 @@ interface EmptyStateProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 空状态组件
|
* 空状态组件
|
||||||
* 遵循简洁大方的设计风格
|
* 遵循现代化设计风格,提供更好的用户体验
|
||||||
*/
|
*/
|
||||||
export const EmptyState: React.FC<EmptyStateProps> = ({
|
export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||||
title,
|
title,
|
||||||
@@ -19,15 +19,48 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
|
|||||||
onAction
|
onAction
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
<div className="flex flex-col items-center justify-center py-20 text-center animate-fade-in-up">
|
||||||
<div className="text-gray-400 mb-6">
|
{/* 图标区域 */}
|
||||||
<FolderPlus size={64} />
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,36 +1,125 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { AlertCircle, RefreshCw, X } from 'lucide-react';
|
import { AlertCircle, RefreshCw, X, AlertTriangle, Info, CheckCircle } from 'lucide-react';
|
||||||
|
|
||||||
interface ErrorMessageProps {
|
interface ErrorMessageProps {
|
||||||
message: string;
|
message: string;
|
||||||
|
type?: 'error' | 'warning' | 'info' | 'success';
|
||||||
onRetry?: () => void;
|
onRetry?: () => void;
|
||||||
onDismiss?: () => void;
|
onDismiss?: () => void;
|
||||||
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 错误消息组件
|
* 增强的消息组件
|
||||||
|
* 支持多种消息类型和更好的视觉设计
|
||||||
*/
|
*/
|
||||||
export const ErrorMessage: React.FC<ErrorMessageProps> = ({
|
export const ErrorMessage: React.FC<ErrorMessageProps> = ({
|
||||||
message,
|
message,
|
||||||
|
type = 'error',
|
||||||
onRetry,
|
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 (
|
return (
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center justify-between">
|
<div className={`${config.bgColor} ${config.borderColor} border rounded-2xl p-6 animate-fade-in-up shadow-sm`}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-start gap-4">
|
||||||
<AlertCircle size={20} className="text-red-500 flex-shrink-0" />
|
{/* 图标 */}
|
||||||
<span className="text-red-700">{message}</span>
|
<div className={`${config.iconColor} flex-shrink-0 mt-0.5`}>
|
||||||
</div>
|
<Icon size={24} />
|
||||||
<div className="flex items-center gap-2">
|
</div>
|
||||||
{onRetry && (
|
|
||||||
<button className="btn btn-secondary btn-sm" onClick={onRetry}>
|
{/* 内容 */}
|
||||||
<RefreshCw size={14} />
|
<div className="flex-1 min-w-0">
|
||||||
重试
|
{/* 标题 */}
|
||||||
</button>
|
{(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 && (
|
{onDismiss && (
|
||||||
<button className="btn btn-ghost btn-sm" onClick={onDismiss}>
|
<button
|
||||||
<X size={14} />
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,28 +1,119 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2, Circle } from 'lucide-react';
|
||||||
|
|
||||||
interface LoadingSpinnerProps {
|
interface LoadingSpinnerProps {
|
||||||
size?: 'small' | 'medium' | 'large';
|
size?: 'small' | 'medium' | 'large';
|
||||||
text?: string;
|
text?: string;
|
||||||
|
variant?: 'spinner' | 'dots' | 'pulse' | 'bars';
|
||||||
|
color?: 'primary' | 'secondary' | 'white';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加载动画组件
|
* 增强的加载动画组件
|
||||||
|
* 支持多种动画样式和尺寸
|
||||||
*/
|
*/
|
||||||
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||||
size = 'medium',
|
size = 'medium',
|
||||||
text
|
text,
|
||||||
|
variant = 'spinner',
|
||||||
|
color = 'primary'
|
||||||
}) => {
|
}) => {
|
||||||
const sizeMap = {
|
const sizeMap = {
|
||||||
small: 16,
|
small: { icon: 16, text: 'text-sm', padding: 'p-3' },
|
||||||
medium: 24,
|
medium: { icon: 24, text: 'text-base', padding: 'p-6' },
|
||||||
large: 32
|
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 (
|
return (
|
||||||
<div className={`flex items-center justify-center gap-3 ${size === 'small' ? 'p-4' : 'p-8'}`}>
|
<div className={`flex flex-col items-center justify-center gap-4 ${currentSize.padding} animate-fade-in`}>
|
||||||
<Loader2 size={sizeMap[size]} className="spinner text-primary-600" />
|
{/* 加载动画 */}
|
||||||
{text && <span className="text-gray-600">{text}</span>}
|
<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>
|
</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,
|
FileAudio,
|
||||||
FileImage,
|
FileImage,
|
||||||
File,
|
File,
|
||||||
HardDrive,
|
BarChart3
|
||||||
BarChart3,
|
|
||||||
Hash
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface ProjectCardProps {
|
interface ProjectCardProps {
|
||||||
@@ -141,131 +139,159 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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="card card-interactive group cursor-pointer animate-fade-in-up relative overflow-hidden">
|
||||||
<div className="flex items-center justify-between mb-4">
|
{/* 背景装饰 */}
|
||||||
<div className="text-primary-600 group-hover:text-primary-700 transition-colors duration-200">
|
<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>
|
||||||
<Folder size={24} />
|
|
||||||
</div>
|
{/* 卡片头部 */}
|
||||||
<div className="relative" ref={menuRef}>
|
<div className="relative p-6 pb-4">
|
||||||
<button
|
<div className="flex items-center justify-between mb-4">
|
||||||
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"
|
<div className="flex items-center gap-3">
|
||||||
onClick={(e) => {
|
<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">
|
||||||
e.stopPropagation();
|
<Folder size={20} />
|
||||||
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>
|
</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>
|
|
||||||
|
|
||||||
<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 && (
|
{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}
|
{project.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 项目统计信息 */}
|
{/* 项目统计信息 */}
|
||||||
|
<div className="relative px-6 pb-4" onClick={() => onOpen(project)}>
|
||||||
{isLoadingStats ? (
|
{isLoadingStats ? (
|
||||||
<div className="bg-gray-50 rounded-lg p-3 mb-3">
|
<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-1 text-gray-500 text-sm">
|
<div className="flex items-center space-x-2 text-gray-500 text-sm">
|
||||||
<BarChart3 className="w-4 h-4 animate-pulse" />
|
<BarChart3 className="w-4 h-4 animate-pulse" />
|
||||||
<span>加载统计中...</span>
|
<span>加载统计中...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : stats && (
|
) : stats && (
|
||||||
<div className="bg-gray-50 rounded-lg p-3 mb-3 space-y-2">
|
<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 space-x-1 text-gray-700 font-medium text-sm">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<BarChart3 className="w-4 h-4" />
|
<div className="flex items-center space-x-2 text-primary-700 font-semibold text-sm">
|
||||||
<span>项目统计</span>
|
<BarChart3 className="w-4 h-4" />
|
||||||
|
<span>项目统计</span>
|
||||||
|
</div>
|
||||||
|
{stats.total_materials > 0 && (
|
||||||
|
<div className="badge badge-primary">
|
||||||
|
{stats.total_materials} 个素材
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 素材统计 */}
|
{/* 素材统计网格 */}
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||||
<div className="flex items-center space-x-1 text-gray-600">
|
<div className="text-center">
|
||||||
<Hash className="w-3 h-3" />
|
<div className="text-lg font-bold text-gray-900">
|
||||||
<span>{stats.total_materials} 个素材</span>
|
{stats.total_materials}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600">总素材</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-1 text-gray-600">
|
<div className="text-center">
|
||||||
<HardDrive className="w-3 h-3" />
|
<div className="text-lg font-bold text-gray-900">
|
||||||
<span>{formatFileSize(stats.total_size)}</span>
|
{formatFileSize(stats.total_size)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600">总大小</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 文件类型分布 */}
|
{/* 文件类型分布 */}
|
||||||
{(stats.video_count > 0 || stats.audio_count > 0 || stats.image_count > 0) && (
|
{(stats.video_count > 0 || stats.audio_count > 0 || stats.image_count > 0 || stats.other_count > 0) && (
|
||||||
<div className="flex items-center gap-3 text-xs">
|
<div className="flex items-center justify-center gap-4 text-xs">
|
||||||
{stats.video_count > 0 && (
|
{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" />
|
<FileVideo className="w-3 h-3" />
|
||||||
<span>{stats.video_count}</span>
|
<span className="font-medium">{stats.video_count}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{stats.audio_count > 0 && (
|
{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" />
|
<FileAudio className="w-3 h-3" />
|
||||||
<span>{stats.audio_count}</span>
|
<span className="font-medium">{stats.audio_count}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{stats.image_count > 0 && (
|
{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" />
|
<FileImage className="w-3 h-3" />
|
||||||
<span>{stats.image_count}</span>
|
<span className="font-medium">{stats.image_count}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{stats.other_count > 0 && (
|
{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" />
|
<File className="w-3 h-3" />
|
||||||
<span>{stats.other_count}</span>
|
<span className="font-medium">{stats.other_count}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -273,43 +299,40 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 项目基本信息 */}
|
{/* 项目路径信息 */}
|
||||||
<div className="space-y-1">
|
<div className="flex items-center gap-2 text-xs text-gray-500 mb-4">
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
<MapPin size={12} />
|
||||||
<MapPin size={12} />
|
<span className="truncate bg-gray-100 px-2 py-1 rounded-lg font-mono" title={project.path}>
|
||||||
<span className="truncate" title={project.path}>
|
{getDirectoryName(project.path)}
|
||||||
{getDirectoryName(project.path)}
|
</span>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 pt-4 border-t border-gray-100">
|
{/* 卡片底部操作区 */}
|
||||||
<button
|
<div className="relative px-6 pb-6">
|
||||||
className="btn btn-secondary btn-sm"
|
<div className="flex gap-2">
|
||||||
onClick={handleOpenFolder}
|
<button
|
||||||
title="打开项目文件夹"
|
className="btn btn-ghost btn-sm p-2"
|
||||||
>
|
onClick={handleOpenFolder}
|
||||||
<FolderOpen size={14} />
|
title="打开项目文件夹"
|
||||||
</button>
|
>
|
||||||
<button
|
<FolderOpen size={16} />
|
||||||
className="btn btn-secondary btn-sm flex-1"
|
</button>
|
||||||
onClick={() => onEdit(project)}
|
<button
|
||||||
>
|
className="btn btn-secondary btn-sm flex-1"
|
||||||
编辑
|
onClick={() => onEdit(project)}
|
||||||
</button>
|
>
|
||||||
<button
|
<Edit3 size={16} />
|
||||||
className="btn btn-primary btn-sm flex-1"
|
编辑
|
||||||
onClick={() => onOpen(project)}
|
</button>
|
||||||
>
|
<button
|
||||||
打开
|
className="btn btn-primary btn-sm flex-1 shadow-glow"
|
||||||
</button>
|
onClick={() => onOpen(project)}
|
||||||
|
>
|
||||||
|
<ExternalLink size={16} />
|
||||||
|
打开
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -133,20 +133,33 @@ export const ProjectForm: React.FC<ProjectFormProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay">
|
<div className="modal-overlay">
|
||||||
<div className="modal">
|
<div className="modal modal-lg">
|
||||||
|
{/* 模态框头部 */}
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<h2 className="text-xl font-semibold text-gray-900">
|
<div className="flex items-center gap-3">
|
||||||
{isEdit ? '编辑项目' : '新建项目'}
|
<div className="p-2 bg-primary-100 text-primary-600 rounded-xl">
|
||||||
</h2>
|
<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}>
|
<button className="modal-close" onClick={onCancel}>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
{/* 模态框内容 */}
|
||||||
<div className="space-y-2">
|
<form onSubmit={handleSubmit} className="modal-body space-y-6">
|
||||||
<label htmlFor="name" className="form-label">
|
{/* 项目名称 */}
|
||||||
项目名称 *
|
<div className="form-group">
|
||||||
|
<label htmlFor="name" className="form-label required">
|
||||||
|
项目名称
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="name"
|
id="name"
|
||||||
@@ -154,8 +167,9 @@ export const ProjectForm: React.FC<ProjectFormProps> = ({
|
|||||||
className={`form-input ${errors.name ? 'error' : ''}`}
|
className={`form-input ${errors.name ? 'error' : ''}`}
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||||
placeholder="输入项目名称"
|
placeholder="为您的项目起一个好听的名字"
|
||||||
maxLength={100}
|
maxLength={100}
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{errors.name && (
|
{errors.name && (
|
||||||
<div className="form-error">
|
<div className="form-error">
|
||||||
@@ -163,22 +177,48 @@ export const ProjectForm: React.FC<ProjectFormProps> = ({
|
|||||||
{errors.name}
|
{errors.name}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="form-hint">
|
||||||
|
{formData.name.length}/100 字符
|
||||||
|
</div>
|
||||||
</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>
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-3">
|
||||||
<input
|
<div className="flex-1">
|
||||||
id="path"
|
<input
|
||||||
type="text"
|
id="path"
|
||||||
className={`form-input ${errors.path ? 'error' : ''}`}
|
type="text"
|
||||||
value={formData.path}
|
className={`form-input ${errors.path ? 'error' : ''}`}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, path: e.target.value }))}
|
value={formData.path}
|
||||||
placeholder="选择或输入项目路径"
|
onChange={(e) => setFormData(prev => ({ ...prev, path: e.target.value }))}
|
||||||
readOnly={isEdit}
|
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 && (
|
{!isEdit && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -186,22 +226,17 @@ export const ProjectForm: React.FC<ProjectFormProps> = ({
|
|||||||
onClick={handleSelectDirectory}
|
onClick={handleSelectDirectory}
|
||||||
>
|
>
|
||||||
<Folder size={16} />
|
<Folder size={16} />
|
||||||
浏览
|
浏览文件夹
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isValidatingPath && (
|
<div className="form-hint">
|
||||||
<div className="form-info">验证路径中...</div>
|
{isEdit ? '编辑模式下无法修改项目路径' : '选择一个空文件夹或新建文件夹作为项目目录'}
|
||||||
)}
|
</div>
|
||||||
{errors.path && (
|
|
||||||
<div className="form-error">
|
|
||||||
<AlertCircle size={14} />
|
|
||||||
{errors.path}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
{/* 项目描述 */}
|
||||||
|
<div className="form-group">
|
||||||
<label htmlFor="description" className="form-label">
|
<label htmlFor="description" className="form-label">
|
||||||
项目描述
|
项目描述
|
||||||
</label>
|
</label>
|
||||||
@@ -210,12 +245,17 @@ export const ProjectForm: React.FC<ProjectFormProps> = ({
|
|||||||
className={`form-textarea ${errors.description ? 'error' : ''}`}
|
className={`form-textarea ${errors.description ? 'error' : ''}`}
|
||||||
value={formData.description}
|
value={formData.description}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||||
placeholder="输入项目描述(可选)"
|
placeholder="描述一下这个项目的用途和内容(可选)"
|
||||||
rows={3}
|
rows={4}
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
<div className="form-hint">
|
<div className="flex justify-between items-center">
|
||||||
{formData.description.length}/500
|
<div className="form-hint">
|
||||||
|
添加描述有助于您更好地管理项目
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{formData.description.length}/500
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{errors.description && (
|
{errors.description && (
|
||||||
<div className="form-error">
|
<div className="form-error">
|
||||||
@@ -224,25 +264,37 @@ export const ProjectForm: React.FC<ProjectFormProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -80,32 +80,43 @@ export const ProjectList: React.FC = () => {
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full animate-fade-in">
|
<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="relative mb-8">
|
||||||
<div className="flex items-center gap-3">
|
<div className="absolute inset-0 bg-gradient-to-r from-red-50 to-orange-50 rounded-3xl opacity-50"></div>
|
||||||
<button
|
<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">
|
||||||
className="btn btn-secondary"
|
<div className="mb-4 sm:mb-0">
|
||||||
onClick={handleCleanupInvalidProjects}
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">我的项目</h1>
|
||||||
disabled={isCleaningUp}
|
<p className="text-red-600 text-sm">
|
||||||
title="清理无效的项目记录"
|
加载项目时遇到问题
|
||||||
>
|
</p>
|
||||||
<Trash2 size={18} />
|
</div>
|
||||||
{isCleaningUp ? '清理中...' : '清理'}
|
<div className="flex items-center gap-3">
|
||||||
</button>
|
<button
|
||||||
<button
|
className="btn btn-secondary btn-sm"
|
||||||
className="btn btn-primary"
|
onClick={handleCleanupInvalidProjects}
|
||||||
onClick={openCreateProjectModal}
|
disabled={isCleaningUp}
|
||||||
>
|
title="清理无效的项目记录"
|
||||||
<Plus size={20} />
|
>
|
||||||
新建项目
|
<Trash2 size={16} />
|
||||||
</button>
|
{isCleaningUp ? '清理中...' : '清理'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={openCreateProjectModal}
|
||||||
|
>
|
||||||
|
<Plus size={20} />
|
||||||
|
新建项目
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ErrorMessage
|
<div className="animate-fade-in-up">
|
||||||
message={error}
|
<ErrorMessage
|
||||||
onRetry={loadProjects}
|
message={error}
|
||||||
onDismiss={clearError}
|
onRetry={loadProjects}
|
||||||
/>
|
onDismiss={clearError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -114,83 +125,143 @@ export const ProjectList: React.FC = () => {
|
|||||||
if (isLoading && projects.length === 0) {
|
if (isLoading && projects.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full animate-fade-in">
|
<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="relative mb-8">
|
||||||
<div className="flex items-center gap-3">
|
<div className="absolute inset-0 bg-gradient-to-r from-primary-50 to-blue-50 rounded-3xl opacity-50"></div>
|
||||||
<button
|
<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">
|
||||||
className="btn btn-secondary"
|
<div className="mb-4 sm:mb-0">
|
||||||
onClick={handleCleanupInvalidProjects}
|
<h1 className="text-3xl font-bold text-gradient-primary mb-2">我的项目</h1>
|
||||||
disabled={isCleaningUp}
|
<p className="text-gray-600 text-sm">
|
||||||
title="清理无效的项目记录"
|
正在加载您的项目...
|
||||||
>
|
</p>
|
||||||
<Trash2 size={18} />
|
</div>
|
||||||
{isCleaningUp ? '清理中...' : '清理'}
|
<div className="flex items-center gap-3">
|
||||||
</button>
|
<button
|
||||||
<button
|
className="btn btn-secondary btn-sm"
|
||||||
className="btn btn-primary"
|
onClick={handleCleanupInvalidProjects}
|
||||||
onClick={openCreateProjectModal}
|
disabled={isCleaningUp}
|
||||||
>
|
title="清理无效的项目记录"
|
||||||
<Plus size={20} />
|
>
|
||||||
新建项目
|
<Trash2 size={16} />
|
||||||
</button>
|
{isCleaningUp ? '清理中...' : '清理'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={openCreateProjectModal}
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<Plus size={20} />
|
||||||
|
新建项目
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center py-16">
|
<div className="flex items-center justify-center py-16 animate-fade-in-up">
|
||||||
<LoadingSpinner text="加载项目中..." />
|
<div className="text-center">
|
||||||
|
<LoadingSpinner size="large" text="加载项目中..." />
|
||||||
|
<p className="text-gray-500 text-sm mt-4">请稍候,正在获取您的项目列表</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<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="relative mb-8">
|
||||||
<div className="flex items-center gap-3">
|
<div className="absolute inset-0 bg-gradient-to-r from-primary-50 to-blue-50 rounded-3xl opacity-50"></div>
|
||||||
<button
|
<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">
|
||||||
className="btn btn-secondary"
|
<div className="mb-4 sm:mb-0">
|
||||||
onClick={handleCleanupInvalidProjects}
|
<h1 className="text-3xl font-bold text-gradient-primary mb-2">我的项目</h1>
|
||||||
disabled={isLoading || isCleaningUp}
|
<p className="text-gray-600 text-sm">
|
||||||
title="清理无效的项目记录"
|
管理您的视频项目,开始创作之旅
|
||||||
>
|
</p>
|
||||||
<Trash2 size={18} />
|
</div>
|
||||||
{isCleaningUp ? '清理中...' : '清理'}
|
<div className="flex items-center gap-3">
|
||||||
</button>
|
<button
|
||||||
<button
|
className="btn btn-secondary btn-sm"
|
||||||
className="btn btn-primary"
|
onClick={handleCleanupInvalidProjects}
|
||||||
onClick={openCreateProjectModal}
|
disabled={isLoading || isCleaningUp}
|
||||||
disabled={isLoading}
|
title="清理无效的项目记录"
|
||||||
>
|
>
|
||||||
<Plus size={20} />
|
<Trash2 size={16} />
|
||||||
新建项目
|
{isCleaningUp ? '清理中...' : '清理'}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary shadow-glow"
|
||||||
|
onClick={openCreateProjectModal}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<Plus size={20} />
|
||||||
|
新建项目
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{projects.length === 0 ? (
|
{/* 项目统计信息 */}
|
||||||
<EmptyState
|
{projects.length > 0 && (
|
||||||
title="还没有项目"
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
|
||||||
description="创建您的第一个项目开始使用 MixVideo"
|
<div className="card card-body text-center">
|
||||||
actionText="新建项目"
|
<div className="text-2xl font-bold text-primary-600 mb-1">
|
||||||
onAction={openCreateProjectModal}
|
{projects.length}
|
||||||
/>
|
</div>
|
||||||
) : (
|
<div className="text-sm text-gray-600">总项目数</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
</div>
|
||||||
{projects.map((project) => (
|
<div className="card card-body text-center">
|
||||||
<ProjectCard
|
<div className="text-2xl font-bold text-green-600 mb-1">
|
||||||
key={project.id}
|
{projects.filter(p => new Date(p.updated_at) > new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)).length}
|
||||||
project={project}
|
</div>
|
||||||
onOpen={handleProjectOpen}
|
<div className="text-sm text-gray-600">本周活跃</div>
|
||||||
onEdit={handleProjectEdit}
|
</div>
|
||||||
onDelete={handleProjectDelete}
|
<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>
|
</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 && (
|
{isLoading && projects.length > 0 && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-20 flex items-center justify-center z-40">
|
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm flex items-center justify-center z-40 animate-fade-in">
|
||||||
<LoadingSpinner size="small" />
|
<div className="bg-white rounded-2xl p-6 shadow-2xl">
|
||||||
|
<LoadingSpinner size="medium" text="更新中..." />
|
||||||
|
</div>
|
||||||
</div>
|
</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: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
// 主色调 - 现代蓝色系
|
||||||
primary: {
|
primary: {
|
||||||
50: '#eff6ff',
|
50: '#eff6ff',
|
||||||
100: '#dbeafe',
|
100: '#dbeafe',
|
||||||
@@ -18,8 +19,11 @@ export default {
|
|||||||
700: '#1d4ed8',
|
700: '#1d4ed8',
|
||||||
800: '#1e40af',
|
800: '#1e40af',
|
||||||
900: '#1e3a8a',
|
900: '#1e3a8a',
|
||||||
|
950: '#172554',
|
||||||
},
|
},
|
||||||
|
// 中性色 - 更丰富的灰色层次
|
||||||
gray: {
|
gray: {
|
||||||
|
25: '#fcfcfd',
|
||||||
50: '#f9fafb',
|
50: '#f9fafb',
|
||||||
100: '#f3f4f6',
|
100: '#f3f4f6',
|
||||||
200: '#e5e7eb',
|
200: '#e5e7eb',
|
||||||
@@ -30,25 +34,268 @@ export default {
|
|||||||
700: '#374151',
|
700: '#374151',
|
||||||
800: '#1f2937',
|
800: '#1f2937',
|
||||||
900: '#111827',
|
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: {
|
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: {
|
animation: {
|
||||||
|
// 基础动画
|
||||||
'spin-slow': 'spin 3s linear infinite',
|
'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-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: {
|
keyframes: {
|
||||||
|
// 淡入动画
|
||||||
fadeIn: {
|
fadeIn: {
|
||||||
'0%': { opacity: '0' },
|
'0%': { opacity: '0' },
|
||||||
'100%': { opacity: '1' },
|
'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: {
|
slideUp: {
|
||||||
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
'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