feat: 大幅增强ProjectCard - 添加统计信息和打开文件夹功能
功能增强: 为首页项目列表中的ProjectCard添加了丰富的统计信息展示和便捷的文件夹操作功能, 让用户能够快速了解项目状态并方便地访问项目文件。 新增功能: 1. 项目统计信息展示: - 自动加载项目素材统计数据 - 显示素材总数和总文件大小 - 按类型分类显示(视频、音频、图片、其他) - 使用颜色编码区分不同文件类型 2. 打开文件夹功能: - 底部按钮栏新增文件夹图标按钮 - 下拉菜单中添加'打开文件夹'选项 - 支持Windows长路径格式处理 - 双重备用机制确保兼容性 3. 加载状态优化: - 统计信息加载时显示加载状态 - 静默处理加载失败,不影响卡片显示 - 优雅的动画效果 技术实现: 1. 统计数据获取: - 使用get_project_material_stats命令 - React hooks管理状态 - useEffect自动加载数据 2. 文件夹操作: - 集成@tauri-apps/plugin-opener - 路径标准化处理 - 错误处理和用户提示 3. UI设计: - 统计信息卡片式展示 - 图标+数字的直观显示 - 响应式布局适配 4. 数据格式化: - formatFileSize函数处理文件大小 - 智能单位转换(B/KB/MB/GB/TB) 视觉效果: 项目统计信息一目了然 文件类型分布清晰展示 便捷的文件夹访问按钮 加载状态友好提示 颜色编码增强可读性 用户体验: - 快速了解项目规模和内容 - 一键打开项目文件夹 - 直观的文件类型分布 - 流畅的交互体验 现在首页的项目卡片功能更加完善,用户可以快速了解项目状态并便捷地进行文件管理!
This commit is contained in:
parent
822bbd8b64
commit
c2e7b2c70f
|
|
@ -1,15 +1,25 @@
|
||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Project } from '../types/project';
|
import { Project } from '../types/project';
|
||||||
|
import { MaterialStats } from '../types/material';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { zhCN } from 'date-fns/locale';
|
import { zhCN } from 'date-fns/locale';
|
||||||
import {
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
Folder,
|
import {
|
||||||
MoreVertical,
|
Folder,
|
||||||
Edit3,
|
MoreVertical,
|
||||||
Trash2,
|
Edit3,
|
||||||
|
Trash2,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Calendar,
|
Calendar,
|
||||||
MapPin
|
MapPin,
|
||||||
|
FolderOpen,
|
||||||
|
FileVideo,
|
||||||
|
FileAudio,
|
||||||
|
FileImage,
|
||||||
|
File,
|
||||||
|
HardDrive,
|
||||||
|
BarChart3,
|
||||||
|
Hash
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface ProjectCardProps {
|
interface ProjectCardProps {
|
||||||
|
|
@ -19,6 +29,15 @@ interface ProjectCardProps {
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 格式化文件大小
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 项目卡片组件
|
* 项目卡片组件
|
||||||
* 遵循简洁大方的设计风格
|
* 遵循简洁大方的设计风格
|
||||||
|
|
@ -30,8 +49,30 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
||||||
onDelete
|
onDelete
|
||||||
}) => {
|
}) => {
|
||||||
const [showMenu, setShowMenu] = React.useState(false);
|
const [showMenu, setShowMenu] = React.useState(false);
|
||||||
|
const [stats, setStats] = useState<MaterialStats | null>(null);
|
||||||
|
const [isLoadingStats, setIsLoadingStats] = useState(false);
|
||||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 加载项目统计信息
|
||||||
|
useEffect(() => {
|
||||||
|
const loadStats = async () => {
|
||||||
|
setIsLoadingStats(true);
|
||||||
|
try {
|
||||||
|
const projectStats = await invoke<MaterialStats>('get_project_material_stats', {
|
||||||
|
projectId: project.id
|
||||||
|
});
|
||||||
|
setStats(projectStats);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载项目统计失败:', error);
|
||||||
|
// 静默失败,不影响卡片显示
|
||||||
|
} finally {
|
||||||
|
setIsLoadingStats(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadStats();
|
||||||
|
}, [project.id]);
|
||||||
|
|
||||||
// 点击外部关闭菜单
|
// 点击外部关闭菜单
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
|
@ -68,6 +109,37 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
||||||
return parts[parts.length - 1] || path;
|
return parts[parts.length - 1] || path;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 打开项目文件夹
|
||||||
|
const handleOpenFolder = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
const { openPath } = await import('@tauri-apps/plugin-opener');
|
||||||
|
|
||||||
|
// 处理 Windows 路径格式,移除 \\?\ 前缀
|
||||||
|
let normalizedPath = project.path;
|
||||||
|
if (normalizedPath.startsWith('\\\\?\\')) {
|
||||||
|
normalizedPath = normalizedPath.substring(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
await openPath(normalizedPath);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('打开文件夹失败:', error);
|
||||||
|
|
||||||
|
// 如果 openPath 失败,尝试使用 revealItemInDir
|
||||||
|
try {
|
||||||
|
const { revealItemInDir } = await import('@tauri-apps/plugin-opener');
|
||||||
|
let normalizedPath = project.path;
|
||||||
|
if (normalizedPath.startsWith('\\\\?\\')) {
|
||||||
|
normalizedPath = normalizedPath.substring(4);
|
||||||
|
}
|
||||||
|
await revealItemInDir(normalizedPath);
|
||||||
|
} catch (fallbackError) {
|
||||||
|
console.error('备用方法也失败:', fallbackError);
|
||||||
|
alert('无法打开文件夹,请检查路径是否存在');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
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 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="flex items-center justify-between mb-4">
|
||||||
|
|
@ -96,6 +168,16 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
||||||
<ExternalLink size={14} />
|
<ExternalLink size={14} />
|
||||||
打开项目
|
打开项目
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="dropdown-item"
|
||||||
|
onClick={(e) => {
|
||||||
|
handleOpenFolder(e);
|
||||||
|
setShowMenu(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FolderOpen size={14} />
|
||||||
|
打开文件夹
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="dropdown-item"
|
className="dropdown-item"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
@ -132,6 +214,66 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 项目统计信息 */}
|
||||||
|
{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">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 素材统计 */}
|
||||||
|
<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>
|
||||||
|
<div className="flex items-center space-x-1 text-gray-600">
|
||||||
|
<HardDrive className="w-3 h-3" />
|
||||||
|
<span>{formatFileSize(stats.total_size)}</span>
|
||||||
|
</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 && (
|
||||||
|
<div className="flex items-center space-x-1 text-blue-600">
|
||||||
|
<FileVideo className="w-3 h-3" />
|
||||||
|
<span>{stats.video_count}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{stats.audio_count > 0 && (
|
||||||
|
<div className="flex items-center space-x-1 text-green-600">
|
||||||
|
<FileAudio className="w-3 h-3" />
|
||||||
|
<span>{stats.audio_count}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{stats.image_count > 0 && (
|
||||||
|
<div className="flex items-center space-x-1 text-purple-600">
|
||||||
|
<FileImage className="w-3 h-3" />
|
||||||
|
<span>{stats.image_count}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{stats.other_count > 0 && (
|
||||||
|
<div className="flex items-center space-x-1 text-gray-600">
|
||||||
|
<File className="w-3 h-3" />
|
||||||
|
<span>{stats.other_count}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 项目基本信息 */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||||
<MapPin size={12} />
|
<MapPin size={12} />
|
||||||
|
|
@ -149,6 +291,13 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 pt-4 border-t border-gray-100">
|
<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
|
<button
|
||||||
className="btn btn-secondary btn-sm flex-1"
|
className="btn btn-secondary btn-sm flex-1"
|
||||||
onClick={() => onEdit(project)}
|
onClick={() => onEdit(project)}
|
||||||
|
|
|
||||||
|
|
@ -238,11 +238,6 @@ export const ProjectDetails: React.FC = () => {
|
||||||
{/* 选项卡内容 */}
|
{/* 选项卡内容 */}
|
||||||
{activeTab === 'materials' && (
|
{activeTab === 'materials' && (
|
||||||
<div>
|
<div>
|
||||||
{/* 素材导入区域 */}
|
|
||||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center mb-6">
|
|
||||||
<Upload className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 素材列表 */}
|
{/* 素材列表 */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-3">项目素材</h3>
|
<h3 className="text-lg font-medium text-gray-900 mb-3">项目素材</h3>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue