mixvideo-v2/apps/desktop/src/pages/ProjectDetails.tsx

386 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, FolderOpen, Calendar, Settings, Upload, FileVideo, FileAudio, FileImage, HardDrive } from 'lucide-react';
import { useProjectStore } from '../store/projectStore';
import { useMaterialStore } from '../store/materialStore';
import { Project } from '../types/project';
import { MaterialImportResult } from '../types/material';
import { LoadingSpinner } from '../components/LoadingSpinner';
import { ErrorMessage } from '../components/ErrorMessage';
import { MaterialImportDialog } from '../components/MaterialImportDialog';
import { FFmpegDebugPanel } from '../components/FFmpegDebugPanel';
import { MaterialCard } from '../components/MaterialCard';
import { VideoClassificationProgress } from '../components/VideoClassificationProgress';
import { AiAnalysisLogViewer } from '../components/AiAnalysisLogViewer';
import MaterialCardSkeleton from '../components/MaterialCardSkeleton';
/**
* 项目详情页面组件
* 遵循 Tauri 开发规范的页面组件设计模式
*/
export const ProjectDetails: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { projects, isLoading, error, loadProjects } = useProjectStore();
const {
materials,
stats,
loadMaterials,
loadMaterialStats,
isLoading: materialsLoading
} = useMaterialStore();
const [project, setProject] = useState<Project | null>(null);
const [showImportDialog, setShowImportDialog] = useState(false);
const [activeTab, setActiveTab] = useState<'materials' | 'debug' | 'ai-logs'>('materials');
// 加载项目详情
useEffect(() => {
if (!projects.length) {
loadProjects();
}
}, [projects.length, loadProjects]);
// 根据ID查找项目
useEffect(() => {
if (id && projects.length > 0) {
const foundProject = projects.find(p => p.id === id);
setProject(foundProject || null);
// 加载项目素材
if (foundProject) {
loadMaterials(foundProject.id);
loadMaterialStats(foundProject.id);
}
}
}, [id, projects, loadMaterials, loadMaterialStats]);
// 返回项目列表
const handleBack = () => {
navigate('/');
};
// 打开项目文件夹
const handleOpenFolder = async () => {
if (project) {
try {
const { openPath } = await import('@tauri-apps/plugin-opener');
// 处理 Windows 路径格式,移除 \\?\ 前缀
let normalizedPath = project.path;
if (normalizedPath.startsWith('\\\\?\\')) {
normalizedPath = normalizedPath.substring(4);
}
console.log('尝试打开路径:', normalizedPath);
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);
}
console.log('尝试使用 revealItemInDir 打开:', normalizedPath);
await revealItemInDir(normalizedPath);
} catch (fallbackError) {
console.error('备用方法也失败:', fallbackError);
// 可以在这里显示用户友好的错误提示
alert('无法打开文件夹,请检查路径是否存在');
}
}
}
};
// 素材导入处理
const handleMaterialImport = () => {
setShowImportDialog(true);
};
// 导入完成处理
const handleImportComplete = (result: MaterialImportResult) => {
setShowImportDialog(false);
console.log('导入完成:', result);
// 重新加载素材列表
if (project) {
loadMaterials(project.id);
loadMaterialStats(project.id);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<LoadingSpinner />
</div>
);
}
if (error) {
return <ErrorMessage message={error} />;
}
if (!project) {
return (
<div className="text-center py-12">
<h2 className="text-2xl font-bold text-gray-900 mb-4"></h2>
<p className="text-gray-600 mb-6">ID是否正确</p>
<button
onClick={handleBack}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<ArrowLeft className="w-4 h-4 mr-2" />
</button>
</div>
);
}
return (
<div className="max-w-6xl mx-auto">
{/* 页面头部 */}
<div className="mb-8">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between space-y-4 sm:space-y-0 mb-4">
<button
onClick={handleBack}
className="inline-flex items-center text-gray-600 hover:text-gray-900 transition-colors self-start"
>
<ArrowLeft className="w-5 h-5 mr-2" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</button>
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<button
onClick={handleOpenFolder}
className="inline-flex items-center px-3 sm:px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors text-sm"
>
<FolderOpen className="w-4 h-4" />
<span className="hidden sm:inline ml-2"></span>
</button>
<button
onClick={handleMaterialImport}
className="inline-flex items-center px-3 sm:px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
>
<Upload className="w-4 h-4" />
<span className="hidden sm:inline ml-2"></span>
</button>
<button className="inline-flex items-center px-3 sm:px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors text-sm">
<Settings className="w-4 h-4" />
<span className="hidden sm:inline ml-2"></span>
</button>
</div>
</div>
</div>
{/* 项目统计概览 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 md:gap-6 mb-6">
{/* 总素材数 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 md:p-6">
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<p className="text-xs md:text-sm font-medium text-gray-600 truncate"></p>
<p className="text-xl md:text-2xl font-bold text-gray-900">{stats?.total_materials || 0}</p>
</div>
<div className="w-10 h-10 md:w-12 md:h-12 bg-blue-100 rounded-lg flex items-center justify-center ml-2">
<FolderOpen className="w-5 h-5 md:w-6 md:h-6 text-blue-600" />
</div>
</div>
</div>
{/* 视频文件 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 md:p-6">
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<p className="text-xs md:text-sm font-medium text-gray-600 truncate"></p>
<p className="text-xl md:text-2xl font-bold text-gray-900">{stats?.video_count || 0}</p>
</div>
<div className="w-10 h-10 md:w-12 md:h-12 bg-green-100 rounded-lg flex items-center justify-center ml-2">
<FileVideo className="w-5 h-5 md:w-6 md:h-6 text-green-600" />
</div>
</div>
</div>
{/* 音频文件 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 md:p-6">
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<p className="text-xs md:text-sm font-medium text-gray-600 truncate"></p>
<p className="text-xl md:text-2xl font-bold text-gray-900">{stats?.audio_count || 0}</p>
</div>
<div className="w-10 h-10 md:w-12 md:h-12 bg-purple-100 rounded-lg flex items-center justify-center ml-2">
<FileAudio className="w-5 h-5 md:w-6 md:h-6 text-purple-600" />
</div>
</div>
</div>
{/* 图片文件 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 md:p-6">
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<p className="text-xs md:text-sm font-medium text-gray-600 truncate"></p>
<p className="text-xl md:text-2xl font-bold text-gray-900">{stats?.image_count || 0}</p>
</div>
<div className="w-10 h-10 md:w-12 md:h-12 bg-orange-100 rounded-lg flex items-center justify-center ml-2">
<FileImage className="w-5 h-5 md:w-6 md:h-6 text-orange-600" />
</div>
</div>
</div>
</div>
{/* 主要内容区域 */}
<div className="space-y-6">
{/* 选项卡导航 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200">
<div className="border-b border-gray-200">
<nav className="flex space-x-4 md:space-x-8 px-4 md:px-6 overflow-x-auto" aria-label="Tabs">
<button
onClick={() => setActiveTab('materials')}
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors whitespace-nowrap ${
activeTab === 'materials'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<div className="flex items-center space-x-2">
<FolderOpen className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</div>
</button>
<button
onClick={() => setActiveTab('debug')}
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors whitespace-nowrap ${
activeTab === 'debug'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<div className="flex items-center space-x-2">
<Settings className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</div>
</button>
<button
onClick={() => setActiveTab('ai-logs')}
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors whitespace-nowrap ${
activeTab === 'ai-logs'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<div className="flex items-center space-x-2">
<HardDrive className="w-4 h-4" />
<span className="hidden sm:inline">AI分析日志</span>
<span className="sm:hidden">AI日志</span>
</div>
</button>
</nav>
</div>
{/* 选项卡内容 */}
<div className="p-4 md:p-6">
{/* 素材管理选项卡 */}
{activeTab === 'materials' && (
<div className="space-y-6">
{/* AI视频分类进度 */}
{project && (
<VideoClassificationProgress
projectId={project.id}
autoRefresh={true}
refreshInterval={3000}
/>
)}
{/* 素材列表 */}
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900"></h3>
<button
onClick={() => setShowImportDialog(true)}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
<Upload className="w-4 h-4 mr-2" />
</button>
</div>
{materialsLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3 md:gap-4">
<MaterialCardSkeleton count={8} />
</div>
) : materials.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3 md:gap-4">
{materials.map((material) => (
<MaterialCard key={material.id} material={material} />
))}
</div>
) : (
<div className="text-center py-16">
<div className="w-20 h-20 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
<FolderOpen className="w-10 h-10 text-gray-400" />
</div>
<h4 className="text-xl font-medium text-gray-900 mb-2"></h4>
<p className="text-gray-500 mb-6 max-w-sm mx-auto">
AI帮助您进行智能分类和管理
</p>
<button
onClick={() => setShowImportDialog(true)}
className="inline-flex items-center px-6 py-3 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
<Upload className="w-5 h-5 mr-2" />
</button>
</div>
)}
</div>
</div>
)}
{/* 调试工具选项卡 */}
{activeTab === 'debug' && (
<div>
<div className="mb-4">
<h3 className="text-lg font-medium text-gray-900 mb-2"></h3>
<p className="text-sm text-gray-600">
FFmpeg测试和系统诊断功能
</p>
</div>
<FFmpegDebugPanel />
</div>
)}
{/* AI分析日志选项卡 */}
{activeTab === 'ai-logs' && project && (
<div>
<div className="mb-4">
<h3 className="text-lg font-medium text-gray-900 mb-2">AI分析日志</h3>
<p className="text-sm text-gray-600">
AI视频分类的详细日志
</p>
</div>
<AiAnalysisLogViewer projectId={project.id} />
</div>
)}
</div>
</div>
</div>
{/* 素材导入对话框 */}
{project && (
<MaterialImportDialog
isOpen={showImportDialog}
projectId={project.id}
onClose={() => setShowImportDialog(false)}
onImportComplete={handleImportComplete}
/>
)}
</div>
);
};