fix: 完善前端组件和类型定义

- 添加素材导入对话框组件
- 实现项目详情页面完整功能
- 添加素材状态管理store
- 完善TypeScript类型定义
- 更新项目列表路由导航
This commit is contained in:
imeepos
2025-07-13 20:49:04 +08:00
parent fdb87bf64e
commit 036488e13b
6 changed files with 1191 additions and 23 deletions

View File

@@ -1,6 +1,8 @@
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { ProjectList } from './components/ProjectList'; import { ProjectList } from './components/ProjectList';
import { ProjectForm } from './components/ProjectForm'; import { ProjectForm } from './components/ProjectForm';
import { ProjectDetails } from './pages/ProjectDetails';
import { useProjectStore } from './store/projectStore'; import { useProjectStore } from './store/projectStore';
import { useUIStore } from './store/uiStore'; import { useUIStore } from './store/uiStore';
import { CreateProjectRequest, UpdateProjectRequest } from './types/project'; import { CreateProjectRequest, UpdateProjectRequest } from './types/project';
@@ -45,9 +47,13 @@ function App() {
}; };
return ( return (
<Router>
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<main className="container mx-auto px-6 py-8 max-w-7xl"> <main className="container mx-auto px-6 py-8 max-w-7xl">
<ProjectList /> <Routes>
<Route path="/" element={<ProjectList />} />
<Route path="/project/:id" element={<ProjectDetails />} />
</Routes>
</main> </main>
{/* 创建项目模态框 */} {/* 创建项目模态框 */}
@@ -68,6 +74,7 @@ function App() {
/> />
)} )}
</div> </div>
</Router>
); );
} }

View File

@@ -0,0 +1,315 @@
import React, { useState, useEffect } from 'react';
import { X, Upload, FileText, AlertCircle, CheckCircle, Loader2 } from 'lucide-react';
import { useMaterialStore } from '../store/materialStore';
import { CreateMaterialRequest, MaterialImportResult } from '../types/material';
interface MaterialImportDialogProps {
isOpen: boolean;
projectId: string;
onClose: () => void;
onImportComplete: (result: MaterialImportResult) => void;
}
/**
* 素材导入对话框组件
* 遵循 Tauri 开发规范的组件设计模式
*/
export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
isOpen,
projectId,
onClose,
onImportComplete
}) => {
const {
isImporting,
importProgress,
error,
importMaterials,
selectMaterialFiles,
validateMaterialFiles,
checkFFmpegAvailable,
clearError
} = useMaterialStore();
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
const [autoProcess, setAutoProcess] = useState(true);
const [maxSegmentDuration, setMaxSegmentDuration] = useState(300); // 5分钟
const [ffmpegAvailable, setFFmpegAvailable] = useState(false);
const [step, setStep] = useState<'select' | 'configure' | 'importing' | 'complete'>('select');
// 检查 FFmpeg 可用性
useEffect(() => {
if (isOpen) {
checkFFmpegAvailable().then(setFFmpegAvailable);
}
}, [isOpen, checkFFmpegAvailable]);
// 重置状态
useEffect(() => {
if (isOpen) {
setSelectedFiles([]);
setStep('select');
clearError();
}
}, [isOpen, clearError]);
// 选择文件
const handleSelectFiles = async () => {
try {
const filePaths = await selectMaterialFiles();
if (filePaths.length > 0) {
const validFiles = await validateMaterialFiles(filePaths);
setSelectedFiles(validFiles);
if (validFiles.length > 0) {
setStep('configure');
}
}
} catch (error) {
console.error('选择文件失败:', error);
}
};
// 开始导入
const handleStartImport = async () => {
if (selectedFiles.length === 0) return;
setStep('importing');
try {
const request: CreateMaterialRequest = {
project_id: projectId,
file_paths: selectedFiles,
auto_process: autoProcess,
max_segment_duration: maxSegmentDuration,
};
const result = await importMaterials(request);
setStep('complete');
onImportComplete(result);
} catch (error) {
console.error('导入失败:', error);
}
};
// 格式化文件大小
const formatFileSize = (bytes: number) => {
const sizes = ['B', 'KB', 'MB', 'GB'];
if (bytes === 0) return '0 B';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
};
// 获取文件名
const getFileName = (path: string) => {
return path.split(/[/\\]/).pop() || path;
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-hidden">
{/* 头部 */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900"></h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
{/* 内容 */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-140px)]">
{/* FFmpeg 状态检查 */}
{!ffmpegAvailable && (
<div className="mb-6 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-center">
<AlertCircle className="w-5 h-5 text-yellow-600 mr-2" />
<span className="text-yellow-800">
FFmpeg FFmpeg
</span>
</div>
</div>
)}
{/* 错误信息 */}
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center">
<AlertCircle className="w-5 h-5 text-red-600 mr-2" />
<span className="text-red-800">{error}</span>
</div>
</div>
)}
{/* 步骤 1: 选择文件 */}
{step === 'select' && (
<div className="space-y-6">
<div className="text-center">
<Upload className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2"></h3>
<p className="text-gray-600 mb-6">
</p>
<button
onClick={handleSelectFiles}
className="inline-flex items-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Upload className="w-5 h-5 mr-2" />
</button>
</div>
</div>
)}
{/* 步骤 2: 配置导入选项 */}
{step === 'configure' && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium text-gray-900 mb-4"></h3>
<div className="max-h-40 overflow-y-auto border border-gray-200 rounded-lg">
{selectedFiles.map((file, index) => (
<div key={index} className="flex items-center p-3 border-b border-gray-100 last:border-b-0">
<FileText className="w-5 h-5 text-gray-400 mr-3" />
<span className="text-sm text-gray-900 truncate">{getFileName(file)}</span>
</div>
))}
</div>
</div>
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900"></h3>
<div className="flex items-center">
<input
type="checkbox"
id="autoProcess"
checked={autoProcess}
onChange={(e) => setAutoProcess(e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="autoProcess" className="ml-2 text-sm text-gray-900">
</label>
</div>
{autoProcess && (
<div>
<label htmlFor="maxDuration" className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
type="number"
id="maxDuration"
value={maxSegmentDuration}
onChange={(e) => setMaxSegmentDuration(Number(e.target.value))}
min="60"
max="3600"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500"
/>
<p className="mt-1 text-xs text-gray-500">
</p>
</div>
)}
</div>
</div>
)}
{/* 步骤 3: 导入进度 */}
{step === 'importing' && (
<div className="space-y-6">
<div className="text-center">
<Loader2 className="w-16 h-16 text-blue-600 mx-auto mb-4 animate-spin" />
<h3 className="text-lg font-medium text-gray-900 mb-2"></h3>
{importProgress && (
<div className="space-y-4">
<div className="text-sm text-gray-600">
{importProgress.current_status}
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{
width: `${(importProgress.processed_count / importProgress.total_count) * 100}%`
}}
/>
</div>
<div className="text-sm text-gray-500">
{importProgress.processed_count} / {importProgress.total_count}
</div>
{importProgress.current_file && (
<div className="text-sm text-gray-600">
: {getFileName(importProgress.current_file)}
</div>
)}
{importProgress.errors.length > 0 && (
<div className="text-left">
<h4 className="text-sm font-medium text-red-600 mb-2">:</h4>
<div className="max-h-20 overflow-y-auto text-xs text-red-600 space-y-1">
{importProgress.errors.map((error, index) => (
<div key={index}>{error}</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
)}
{/* 步骤 4: 完成 */}
{step === 'complete' && (
<div className="space-y-6">
<div className="text-center">
<CheckCircle className="w-16 h-16 text-green-600 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2"></h3>
<p className="text-gray-600">
</p>
</div>
</div>
)}
</div>
{/* 底部按钮 */}
<div className="flex items-center justify-end space-x-3 p-6 border-t border-gray-200">
{step === 'configure' && (
<>
<button
onClick={() => setStep('select')}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
</button>
<button
onClick={handleStartImport}
disabled={selectedFiles.length === 0}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
</button>
</>
)}
{(step === 'select' || step === 'complete') && (
<button
onClick={onClose}
className="px-6 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
{step === 'complete' ? '完成' : '取消'}
</button>
)}
</div>
</div>
</div>
);
};

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { useProjectStore } from '../store/projectStore'; import { useProjectStore } from '../store/projectStore';
import { useUIStore } from '../store/uiStore'; import { useUIStore } from '../store/uiStore';
@@ -13,6 +14,7 @@ import { Plus, Trash2 } from 'lucide-react';
* 遵循 Tauri 开发规范的组件设计模式 * 遵循 Tauri 开发规范的组件设计模式
*/ */
export const ProjectList: React.FC = () => { export const ProjectList: React.FC = () => {
const navigate = useNavigate();
const { const {
projects, projects,
isLoading, isLoading,
@@ -54,8 +56,8 @@ export const ProjectList: React.FC = () => {
// 处理项目打开 // 处理项目打开
const handleProjectOpen = (project: any) => { const handleProjectOpen = (project: any) => {
setCurrentProject(project); setCurrentProject(project);
// TODO: 导航到项目详情页面 // 导航到项目详情页面
console.log('打开项目:', project); navigate(`/project/${project.id}`);
}; };
// 处理项目编辑 // 处理项目编辑

View File

@@ -0,0 +1,289 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, FolderOpen, Calendar, Settings, Upload } 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';
/**
* 项目详情页面组件
* 遵循 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);
// 加载项目详情
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');
await openPath(project.path);
} catch (error) {
console.error('打开文件夹失败:', error);
}
}
};
// 素材导入处理
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 items-center justify-between mb-4">
<button
onClick={handleBack}
className="inline-flex items-center text-gray-600 hover:text-gray-900 transition-colors"
>
<ArrowLeft className="w-5 h-5 mr-2" />
</button>
<div className="flex items-center space-x-3">
<button
onClick={handleOpenFolder}
className="inline-flex items-center px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
<FolderOpen className="w-4 h-4 mr-2" />
</button>
<button
onClick={handleMaterialImport}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Upload className="w-4 h-4 mr-2" />
</button>
<button className="inline-flex items-center px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors">
<Settings className="w-4 h-4 mr-2" />
</button>
</div>
</div>
{/* 项目基本信息 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">{project.name}</h1>
{project.description && (
<p className="text-gray-600 mb-4">{project.description}</p>
)}
<div className="flex items-center space-x-6 text-sm text-gray-500">
<div className="flex items-center">
<FolderOpen className="w-4 h-4 mr-1" />
<span className="font-mono">{project.path}</span>
</div>
<div className="flex items-center">
<Calendar className="w-4 h-4 mr-1" />
<span> {new Date(project.created_at).toLocaleDateString('zh-CN')}</span>
</div>
</div>
</div>
<div className={`px-3 py-1 rounded-full text-xs font-medium ${
project.is_active
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}>
{project.is_active ? '活跃' : '非活跃'}
</div>
</div>
</div>
</div>
{/* 项目内容区域 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 素材管理 */}
<div className="lg:col-span-2">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4"></h2>
{/* 素材导入区域 */}
<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" />
<h3 className="text-lg font-medium text-gray-900 mb-2"></h3>
<p className="text-gray-600 mb-4">
</p>
<button
onClick={handleMaterialImport}
className="inline-flex items-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Upload className="w-5 h-5 mr-2" />
</button>
</div>
{/* 素材列表 */}
<div>
<h3 className="text-lg font-medium text-gray-900 mb-3"></h3>
{materialsLoading ? (
<div className="flex items-center justify-center py-8">
<LoadingSpinner />
</div>
) : materials.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{materials.map((material) => (
<div key={material.id} className="border border-gray-200 rounded-lg p-4">
<h4 className="font-medium text-gray-900 truncate">{material.name}</h4>
<p className="text-sm text-gray-600 mt-1">
: {material.material_type}
</p>
<p className="text-sm text-gray-600">
: {material.processing_status}
</p>
<p className="text-sm text-gray-600">
: {(material.file_size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-gray-500">
<p>使</p>
</div>
)}
</div>
</div>
</div>
{/* 项目信息侧边栏 */}
<div className="space-y-6">
{/* 项目统计 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4"></h3>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-600"></span>
<span className="font-medium">{stats?.total_materials || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600"></span>
<span className="font-medium">{stats?.video_count || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600"></span>
<span className="font-medium">{stats?.audio_count || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600"></span>
<span className="font-medium">{stats?.image_count || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600"></span>
<span className="font-medium">
{stats ? (stats.total_size / 1024 / 1024 / 1024).toFixed(2) + ' GB' : '0 GB'}
</span>
</div>
</div>
</div>
{/* 最近活动 */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4"></h3>
<div className="text-center py-4 text-gray-500">
<p></p>
</div>
</div>
</div>
</div>
{/* 素材导入对话框 */}
{project && (
<MaterialImportDialog
isOpen={showImportDialog}
projectId={project.id}
onClose={() => setShowImportDialog(false)}
onImportComplete={handleImportComplete}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,294 @@
import { create } from 'zustand';
import { invoke } from '@tauri-apps/api/core';
import {
Material,
MaterialStats,
CreateMaterialRequest,
MaterialImportResult,
ProcessingStatus,
MaterialProcessingConfig
} from '../types/material';
/**
* 素材状态管理
* 遵循 Tauri 开发规范的状态管理模式
*/
interface MaterialState {
// 状态
materials: Material[];
currentMaterial: Material | null;
stats: MaterialStats | null;
isLoading: boolean;
isImporting: boolean;
isProcessing: boolean;
error: string | null;
importProgress: {
current_file: string;
processed_count: number;
total_count: number;
current_status: string;
errors: string[];
} | null;
// 操作
loadMaterials: (projectId: string) => Promise<void>;
loadMaterialStats: (projectId: string) => Promise<void>;
importMaterials: (request: CreateMaterialRequest) => Promise<MaterialImportResult>;
getMaterialById: (id: string) => Promise<Material | null>;
deleteMaterial: (id: string) => Promise<void>;
processMaterials: (materialIds: string[]) => Promise<void>;
updateMaterialStatus: (id: string, status: ProcessingStatus, errorMessage?: string) => Promise<void>;
cleanupInvalidMaterials: (projectId: string) => Promise<string>;
setCurrentMaterial: (material: Material | null) => void;
clearError: () => void;
// 工具方法
selectMaterialFiles: () => Promise<string[]>;
validateMaterialFiles: (filePaths: string[]) => Promise<string[]>;
getFileInfo: (filePath: string) => Promise<any>;
checkFFmpegAvailable: () => Promise<boolean>;
getFFmpegVersion: () => Promise<string>;
getSupportedExtensions: () => Promise<string[]>;
}
export const useMaterialStore = create<MaterialState>((set, get) => ({
// 初始状态
materials: [],
currentMaterial: null,
stats: null,
isLoading: false,
isImporting: false,
isProcessing: false,
error: null,
importProgress: null,
// 加载项目素材
loadMaterials: async (projectId: string) => {
set({ isLoading: true, error: null });
try {
const materials = await invoke<Material[]>('get_project_materials', { projectId });
set({ materials, isLoading: false });
} catch (error) {
set({
error: error as string,
isLoading: false,
materials: []
});
}
},
// 加载素材统计
loadMaterialStats: async (projectId: string) => {
try {
const stats = await invoke<MaterialStats>('get_project_material_stats', { projectId });
set({ stats });
} catch (error) {
console.error('加载素材统计失败:', error);
}
},
// 导入素材
importMaterials: async (request: CreateMaterialRequest) => {
set({ isImporting: true, error: null, importProgress: {
current_file: '',
processed_count: 0,
total_count: request.file_paths.length,
current_status: '准备导入...',
errors: []
}});
try {
const result = await invoke<MaterialImportResult>('import_materials', { request });
// 更新素材列表
const { materials } = get();
set({
materials: [...materials, ...result.created_materials],
isImporting: false,
importProgress: null
});
// 重新加载统计信息
get().loadMaterialStats(request.project_id);
return result;
} catch (error) {
set({
error: error as string,
isImporting: false,
importProgress: null
});
throw error;
}
},
// 获取素材详情
getMaterialById: async (id: string) => {
try {
const material = await invoke<Material | null>('get_material_by_id', { id });
return material;
} catch (error) {
set({ error: error as string });
return null;
}
},
// 删除素材
deleteMaterial: async (id: string) => {
try {
await invoke('delete_material', { id });
// 从状态中移除
const { materials } = get();
set({ materials: materials.filter(m => m.id !== id) });
// 如果删除的是当前素材,清空当前素材
const { currentMaterial } = get();
if (currentMaterial?.id === id) {
set({ currentMaterial: null });
}
} catch (error) {
set({ error: error as string });
throw error;
}
},
// 批量处理素材
processMaterials: async (materialIds: string[]) => {
set({ isProcessing: true, error: null });
try {
const processedIds = await invoke<string[]>('batch_process_materials', { materialIds });
// 更新处理状态
const { materials } = get();
const updatedMaterials = materials.map(material => {
if (processedIds.includes(material.id)) {
return { ...material, processing_status: 'Processing' as ProcessingStatus };
}
return material;
});
set({ materials: updatedMaterials, isProcessing: false });
} catch (error) {
set({ error: error as string, isProcessing: false });
throw error;
}
},
// 更新素材状态
updateMaterialStatus: async (id: string, status: ProcessingStatus, errorMessage?: string) => {
try {
await invoke('update_material_status', { id, status, errorMessage });
// 更新本地状态
const { materials } = get();
const updatedMaterials = materials.map(material => {
if (material.id === id) {
return {
...material,
processing_status: status,
error_message: errorMessage,
updated_at: new Date().toISOString()
};
}
return material;
});
set({ materials: updatedMaterials });
} catch (error) {
set({ error: error as string });
throw error;
}
},
// 清理失效素材
cleanupInvalidMaterials: async (projectId: string) => {
try {
const result = await invoke<string>('cleanup_invalid_materials', { projectId });
// 重新加载素材列表
await get().loadMaterials(projectId);
return result;
} catch (error) {
set({ error: error as string });
throw error;
}
},
// 设置当前素材
setCurrentMaterial: (material: Material | null) => {
set({ currentMaterial: material });
},
// 清除错误
clearError: () => {
set({ error: null });
},
// 选择素材文件
selectMaterialFiles: async () => {
try {
const filePaths = await invoke<string[]>('select_material_files');
return filePaths;
} catch (error) {
set({ error: error as string });
return [];
}
},
// 验证素材文件
validateMaterialFiles: async (filePaths: string[]) => {
try {
const validFiles = await invoke<string[]>('validate_material_files', { filePaths });
return validFiles;
} catch (error) {
set({ error: error as string });
return [];
}
},
// 获取文件信息
getFileInfo: async (filePath: string) => {
try {
const fileInfo = await invoke('get_file_info', { filePath });
return fileInfo;
} catch (error) {
set({ error: error as string });
return null;
}
},
// 检查 FFmpeg 是否可用
checkFFmpegAvailable: async () => {
try {
const isAvailable = await invoke<boolean>('check_ffmpeg_available');
return isAvailable;
} catch (error) {
console.error('检查 FFmpeg 失败:', error);
return false;
}
},
// 获取 FFmpeg 版本
getFFmpegVersion: async () => {
try {
const version = await invoke<string>('get_ffmpeg_version');
return version;
} catch (error) {
console.error('获取 FFmpeg 版本失败:', error);
return 'Unknown';
}
},
// 获取支持的扩展名
getSupportedExtensions: async () => {
try {
const extensions = await invoke<string[]>('get_supported_extensions');
return extensions;
} catch (error) {
console.error('获取支持的扩展名失败:', error);
return [];
}
},
}));

View File

@@ -0,0 +1,261 @@
/**
* 素材相关类型定义
* 遵循 Tauri 开发规范的类型安全设计
*/
export type MaterialType = 'Video' | 'Audio' | 'Image' | 'Document' | 'Other';
export type ProcessingStatus = 'Pending' | 'Processing' | 'Completed' | 'Failed' | 'Skipped';
export interface VideoMetadata {
duration: number;
width: number;
height: number;
fps: number;
bitrate: number;
codec: string;
format: string;
has_audio: boolean;
audio_codec?: string;
audio_bitrate?: number;
audio_sample_rate?: number;
}
export interface AudioMetadata {
duration: number;
bitrate: number;
codec: string;
sample_rate: number;
channels: number;
format: string;
}
export interface ImageMetadata {
width: number;
height: number;
format: string;
color_space?: string;
has_alpha: boolean;
dpi?: number;
}
export type MaterialMetadata =
| { Video: VideoMetadata }
| { Audio: AudioMetadata }
| { Image: ImageMetadata }
| 'None';
export interface SceneSegment {
start_time: number;
end_time: number;
duration: number;
scene_id: number;
confidence: number;
}
export interface SceneDetection {
scenes: SceneSegment[];
total_scenes: number;
detection_method: string;
threshold: number;
}
export interface MaterialSegment {
id: string;
material_id: string;
segment_index: number;
start_time: number;
end_time: number;
duration: number;
file_path: string;
file_size: number;
created_at: string;
}
export interface Material {
id: string;
project_id: string;
name: string;
original_path: string;
file_size: number;
md5_hash: string;
material_type: MaterialType;
processing_status: ProcessingStatus;
metadata: MaterialMetadata;
scene_detection?: SceneDetection;
segments: MaterialSegment[];
created_at: string;
updated_at: string;
processed_at?: string;
error_message?: string;
}
export interface CreateMaterialRequest {
project_id: string;
file_paths: string[];
auto_process: boolean;
max_segment_duration?: number;
}
export interface MaterialProcessingConfig {
max_segment_duration: number;
scene_detection_threshold: number;
enable_scene_detection: boolean;
video_quality: string;
audio_quality: string;
output_format: string;
}
export interface MaterialImportResult {
total_files: number;
processed_files: number;
skipped_files: number;
failed_files: number;
created_materials: Material[];
errors: string[];
processing_time: number;
}
export interface MaterialStats {
total_materials: number;
video_count: number;
audio_count: number;
image_count: number;
other_count: number;
total_size: number;
total_duration: number;
processing_status_counts: Record<string, number>;
}
export interface FileInfo {
name: string;
path: string;
size: number;
extension: string;
material_type: MaterialType;
is_supported: boolean;
modified: number;
}
/**
* 素材导入相关的 Tauri 命令类型定义
*/
export interface MaterialCommands {
// 素材管理命令
import_materials(request: CreateMaterialRequest): Promise<MaterialImportResult>;
get_project_materials(project_id: string): Promise<Material[]>;
get_material_by_id(id: string): Promise<Material | null>;
delete_material(id: string): Promise<void>;
get_project_material_stats(project_id: string): Promise<MaterialStats>;
batch_process_materials(material_ids: string[]): Promise<string[]>;
update_material_status(id: string, status: string, error_message?: string): Promise<void>;
cleanup_invalid_materials(project_id: string): Promise<string>;
// 文件操作命令
is_supported_format(file_path: string): Promise<boolean>;
get_supported_extensions(): Promise<string[]>;
select_material_files(): Promise<string[]>;
validate_material_files(file_paths: string[]): Promise<string[]>;
get_file_info(file_path: string): Promise<FileInfo>;
// FFmpeg 相关命令
check_ffmpeg_available(): Promise<boolean>;
get_ffmpeg_version(): Promise<string>;
extract_file_metadata(file_path: string): Promise<MaterialMetadata>;
detect_video_scenes(file_path: string, threshold: number): Promise<number[]>;
generate_video_thumbnail(
input_path: string,
output_path: string,
timestamp: number,
width: number,
height: number
): Promise<void>;
}
/**
* 素材导入进度状态
*/
export interface ImportProgress {
current_file: string;
processed_count: number;
total_count: number;
current_status: string;
errors: string[];
}
/**
* 素材卡片组件属性
*/
export interface MaterialCardProps {
material: Material;
onView: (material: Material) => void;
onEdit: (material: Material) => void;
onDelete: (id: string) => void;
onProcess: (material: Material) => void;
}
/**
* 素材导入对话框属性
*/
export interface MaterialImportDialogProps {
isOpen: boolean;
projectId: string;
onClose: () => void;
onImportComplete: (result: MaterialImportResult) => void;
}
/**
* 素材列表组件属性
*/
export interface MaterialListProps {
projectId: string;
materials: Material[];
isLoading: boolean;
onRefresh: () => void;
onImport: () => void;
}
/**
* 素材统计组件属性
*/
export interface MaterialStatsProps {
stats: MaterialStats;
isLoading: boolean;
}
/**
* 视频预览组件属性
*/
export interface VideoPreviewProps {
material: Material;
onSegmentSelect?: (segment: MaterialSegment) => void;
}
/**
* 场景检测结果组件属性
*/
export interface SceneDetectionProps {
sceneDetection: SceneDetection;
onSceneSelect?: (scene: SceneSegment) => void;
}
/**
* 素材处理配置组件属性
*/
export interface ProcessingConfigProps {
config: MaterialProcessingConfig;
onChange: (config: MaterialProcessingConfig) => void;
}
/**
* 工具函数类型
*/
export interface MaterialUtils {
formatFileSize(bytes: number): string;
formatDuration(seconds: number): string;
getStatusColor(status: ProcessingStatus): string;
getTypeIcon(type: MaterialType): string;
isVideoType(type: MaterialType): boolean;
isAudioType(type: MaterialType): boolean;
isImageType(type: MaterialType): boolean;
}