Files
mixvideo-v2/apps/desktop/src/pages/ModelDetail.tsx

710 lines
26 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, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
ArrowLeftIcon,
PhotoIcon,
PlusIcon,
PlayIcon,
TrashIcon,
EyeIcon,
ArrowPathIcon,
SparklesIcon
} from '@heroicons/react/24/outline';
import { Model, PhotoType } from '../types/model';
import {
VideoGenerationTask,
VideoPromptConfig,
VideoGenerationStatus,
TEMPLATE_OPTIONS,
SCENE_OPTIONS,
PRODUCT_OPTIONS,
VIDEO_GENERATION_STATUS_CONFIG
} from '../types/videoGeneration';
import { videoGenerationService } from '../services/videoGenerationService';
import { open } from '@tauri-apps/plugin-dialog';
import { invoke } from '@tauri-apps/api/core';
import { CustomSelect } from '../components/CustomSelect';
import { LoadingSpinner } from '../components/LoadingSpinner';
import { DeleteConfirmDialog } from '../components/DeleteConfirmDialog';
const ModelDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
// 状态管理
const [model, setModel] = useState<Model | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedPhotos, setSelectedPhotos] = useState<string[]>([]);
const [promptConfig, setPromptConfig] = useState<VideoPromptConfig>({
product: '',
scene: '',
model_desc: '',
template: '抚媚眼神',
duplicate: 1
});
const [videoTasks, setVideoTasks] = useState<VideoGenerationTask[]>([]);
const [isGenerating, setIsGenerating] = useState(false);
const [uploadingPhotos, setUploadingPhotos] = useState(false);
const [deletePhotoConfirm, setDeletePhotoConfirm] = useState<{
show: boolean;
photoId: string | null;
photoName: string | null;
deleting: boolean;
}>({
show: false,
photoId: null,
photoName: null,
deleting: false,
});
const [_deleteTaskConfirm, setDeleteTaskConfirm] = useState<{
show: boolean;
taskId: string | null;
taskName: string | null;
deleting: boolean;
}>({
show: false,
taskId: null,
taskName: null,
deleting: false,
});
// 加载模特详情
const loadModelDetail = async () => {
if (!id) return;
try {
setLoading(true);
setError(null);
const modelData = await videoGenerationService.getModelDetailWithPhotos(id);
if (modelData) {
setModel(modelData);
// 设置默认的模特描述
setPromptConfig(prev => ({
...prev,
model_desc: modelData.description || `${modelData.name}${modelData.tags.join('、')}`
}));
} else {
setError('模特不存在');
}
} catch (err) {
setError(err as string);
} finally {
setLoading(false);
}
};
// 加载视频生成任务
const loadVideoTasks = async () => {
if (!id) return;
try {
const tasks = await videoGenerationService.getVideoGenerationTasks({
model_id: id
});
setVideoTasks(tasks);
} catch (err) {
console.error('加载视频任务失败:', err);
}
};
// 上传照片
const handleUploadPhotos = async () => {
if (!id) return;
try {
setUploadingPhotos(true);
const selected = await open({
multiple: true,
filters: [{
name: 'Images',
extensions: ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp']
}]
});
if (selected && Array.isArray(selected)) {
const photos = await videoGenerationService.batchUploadModelPhotos(
id,
selected,
PhotoType.Portrait,
'模特照片',
['视频生成']
);
// 重新加载模特数据
await loadModelDetail();
console.log(`成功上传 ${photos.length} 张照片`);
}
} catch (err) {
setError(`上传照片失败: ${err}`);
} finally {
setUploadingPhotos(false);
}
};
// 选择/取消选择照片
const togglePhotoSelection = (photoId: string) => {
setSelectedPhotos(prev =>
prev.includes(photoId)
? prev.filter(id => id !== photoId)
: [...prev, photoId]
);
};
// 生成视频
const handleGenerateVideo = async () => {
if (!id || selectedPhotos.length === 0) {
setError('请至少选择一张照片');
return;
}
if (!promptConfig.product.trim() || !promptConfig.scene.trim()) {
setError('请填写产品描述和场景描述');
return;
}
try {
setIsGenerating(true);
setError(null);
const request = {
model_id: id,
prompt_config: promptConfig,
selected_photos: selectedPhotos
};
// 创建并执行任务
const task = await videoGenerationService.createAndExecuteVideoGeneration(request);
// 重新加载任务列表
await loadVideoTasks();
// 开始轮询任务状态
videoGenerationService.pollTaskUntilComplete(
task.id,
(updatedTask) => {
// 更新任务列表中的任务状态
setVideoTasks(prev =>
prev.map(t => t.id === updatedTask.id ? updatedTask : t)
);
}
).then(() => {
// 任务完成后重新加载任务列表
loadVideoTasks();
}).catch(err => {
console.error('轮询任务状态失败:', err);
});
} catch (err) {
setError(`生成视频失败: ${err}`);
} finally {
setIsGenerating(false);
}
};
// 重试任务
const handleRetryTask = async (taskId: string) => {
try {
await videoGenerationService.retryVideoGenerationTask(taskId);
await loadVideoTasks();
} catch (err) {
setError(`重试任务失败: ${err}`);
}
};
// 显示删除任务确认弹框
const showDeleteTaskConfirm = (taskId: string) => {
const task = videoTasks.find(t => t.id === taskId);
const taskName = task ? `${task.prompt_config.product} - ${task.prompt_config.template}` : '未知任务';
setDeleteTaskConfirm({
show: true,
taskId,
taskName,
deleting: false,
});
};
// 显示删除照片确认弹框
const showDeletePhotoConfirm = (photoId: string) => {
if (!model) return;
const photo = model.photos.find(p => p.id === photoId);
const photoName = photo?.description || photo?.file_name || '未知照片';
setDeletePhotoConfirm({
show: true,
photoId,
photoName,
deleting: false,
});
};
// 确认删除照片
const confirmDeletePhoto = async () => {
if (!deletePhotoConfirm.photoId || !id) return;
setDeletePhotoConfirm(prev => ({ ...prev, deleting: true }));
try {
await invoke('delete_model_photo', {
modelId: id,
photoId: deletePhotoConfirm.photoId
});
// 重新加载模特数据
await loadModelDetail();
// 如果删除的照片在选中列表中,移除它
setSelectedPhotos(prev => prev.filter(selectedId => selectedId !== deletePhotoConfirm.photoId));
console.log('照片删除成功');
// 关闭确认弹框
setDeletePhotoConfirm({
show: false,
photoId: null,
photoName: null,
deleting: false,
});
} catch (err) {
setError(`删除照片失败: ${err}`);
setDeletePhotoConfirm(prev => ({ ...prev, deleting: false }));
}
};
// 取消删除照片
const cancelDeletePhoto = () => {
setDeletePhotoConfirm({
show: false,
photoId: null,
photoName: null,
deleting: false,
});
};
// 初始化
useEffect(() => {
loadModelDetail();
loadVideoTasks();
}, [id]);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<LoadingSpinner size="large" />
</div>
);
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-red-600">{error}</p>
<button
onClick={() => setError(null)}
className="mt-2 text-sm text-red-500 hover:text-red-700"
>
</button>
</div>
);
}
if (!model) {
return (
<div className="text-center py-8">
<p className="text-gray-500"></p>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-blue-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{/* 头部 - 优化设计 */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 mb-6 animate-fade-in">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<button
onClick={() => navigate('/models')}
className="group flex items-center text-gray-600 hover:text-blue-600 transition-all duration-200 hover:bg-blue-50 px-3 py-2 rounded-lg"
>
<ArrowLeftIcon className="w-5 h-5 mr-2 group-hover:-translate-x-1 transition-transform duration-200" />
</button>
<div className="h-8 w-px bg-gray-200"></div>
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-gray-900 to-blue-600 bg-clip-text text-transparent">
{model.name}
</h1>
{model.stage_name && (
<span className="text-lg text-gray-500 font-medium">({model.stage_name})</span>
)}
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => navigate(`/models/${id}/dynamics`)}
className="group flex items-center px-4 py-2 bg-gradient-to-r from-primary-500 to-primary-600 text-white rounded-lg hover:from-primary-600 hover:to-primary-700 shadow-sm hover:shadow-md transition-all duration-200 text-sm font-medium"
>
<SparklesIcon className="w-4 h-4 mr-2 group-hover:rotate-12 transition-transform duration-200" />
</button>
<button
onClick={handleUploadPhotos}
disabled={uploadingPhotos}
className="group flex items-center px-6 py-3 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-xl hover:from-blue-700 hover:to-blue-800 disabled:opacity-50 disabled:cursor-not-allowed shadow-lg hover:shadow-xl transition-all duration-200 hover:scale-105"
>
{uploadingPhotos ? (
<LoadingSpinner size="small" className="mr-2" color="white" />
) : (
<PlusIcon className="w-5 h-5 mr-2 group-hover:rotate-90 transition-transform duration-200" />
)}
</button>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* 左侧:模特信息和照片 */}
<div className="lg:col-span-2 space-y-8">
{/* 模特基本信息 - 优化设计 */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 hover:shadow-md transition-all duration-300 animate-slide-up">
<div className="flex items-center mb-6">
<div className="w-2 h-8 bg-gradient-to-b from-blue-500 to-blue-600 rounded-full mr-4"></div>
<h2 className="text-xl font-bold text-gray-900"></h2>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500"></span>
<span>{model.gender}</span>
</div>
{model.age && (
<div>
<span className="text-gray-500"></span>
<span>{model.age}</span>
</div>
)}
{model.height && (
<div>
<span className="text-gray-500"></span>
<span>{model.height}cm</span>
</div>
)}
{model.weight && (
<div>
<span className="text-gray-500"></span>
<span>{model.weight}kg</span>
</div>
)}
</div>
{model.description && (
<div className="mt-4">
<span className="text-gray-500"></span>
<p className="mt-1 text-gray-900">{model.description}</p>
</div>
)}
{model.tags.length > 0 && (
<div className="mt-4">
<span className="text-gray-500"></span>
<div className="mt-1 flex flex-wrap gap-2">
{model.tags.map((tag, index) => (
<span
key={index}
className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full"
>
{tag}
</span>
))}
</div>
</div>
)}
</div>
{/* 照片选择 */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold"></h2>
<span className="text-sm text-gray-500">
{selectedPhotos.length}
</span>
</div>
{model.photos.length === 0 ? (
<div className="text-center py-8">
<PhotoIcon className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-500"></p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{model.photos.map((photo) => (
<div
key={photo.id}
className={`relative aspect-square rounded-lg overflow-hidden border-2 transition-all group ${selectedPhotos.includes(photo.id)
? 'border-blue-500 ring-2 ring-blue-200'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div
className="w-full h-full cursor-pointer"
onClick={() => togglePhotoSelection(photo.id)}
>
<img
src={photo.file_path.startsWith('http') ? photo.file_path : `file://${photo.file_path}`}
alt={photo.description || '模特照片'}
className="w-full h-full object-cover"
/>
</div>
{/* 删除按钮 */}
<button
onClick={(e) => {
e.stopPropagation();
showDeletePhotoConfirm(photo.id);
}}
className="absolute top-2 right-2 w-6 h-6 bg-red-500 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center hover:bg-red-600"
title="删除照片"
>
<TrashIcon className="w-3 h-3" />
</button>
{/* 选中状态显示 */}
{selectedPhotos.includes(photo.id) && (
<div className="absolute inset-0 bg-blue-500 bg-opacity-20 flex items-center justify-center pointer-events-none">
<div className="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center">
<span className="text-white text-xs font-bold">
{selectedPhotos.indexOf(photo.id) + 1}
</span>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
{/* 右侧:视频生成配置和任务 */}
<div className="space-y-6">
{/* 视频生成配置 */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="space-y-4">
{/* 产品描述 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
type="text"
value={promptConfig.product}
onChange={(e) => setPromptConfig(prev => ({ ...prev, product: e.target.value }))}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200"
placeholder="输入产品描述,如:超短牛仔裙(白色紧身蕾丝短袖)"
list="product-options"
/>
<datalist id="product-options">
{PRODUCT_OPTIONS.map((option, index) => (
<option key={index} value={option} />
))}
</datalist>
</div>
{/* 场景描述 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
type="text"
value={promptConfig.scene}
onChange={(e) => setPromptConfig(prev => ({ ...prev, scene: e.target.value }))}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200"
placeholder="输入场景描述,如:室内可爱简约的女性卧室"
list="scene-options"
/>
<datalist id="scene-options">
{SCENE_OPTIONS.map((option, index) => (
<option key={index} value={option} />
))}
</datalist>
</div>
{/* 模特描述 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<textarea
value={promptConfig.model_desc}
onChange={(e) => setPromptConfig(prev => ({ ...prev, model_desc: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={3}
placeholder="描述模特的特征和风格"
/>
</div>
{/* 模板类型 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<CustomSelect
value={promptConfig.template}
onChange={(value) => setPromptConfig(prev => ({ ...prev, template: value }))}
options={TEMPLATE_OPTIONS}
placeholder="选择模板类型"
/>
</div>
{/* 生成数量 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="number"
min="1"
max="5"
value={promptConfig.duplicate}
onChange={(e) => setPromptConfig(prev => ({ ...prev, duplicate: parseInt(e.target.value) || 1 }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* 生成按钮 */}
<button
onClick={handleGenerateVideo}
disabled={isGenerating || selectedPhotos.length === 0}
className="w-full flex items-center justify-center px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isGenerating ? (
<>
<LoadingSpinner size="small" className="mr-2" />
...
</>
) : (
<>
<PlayIcon className="w-5 h-5 mr-2" />
</>
)}
</button>
</div>
</div>
{/* 视频生成任务 */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold"></h2>
<button
onClick={loadVideoTasks}
className="text-gray-500 hover:text-gray-700"
>
<ArrowPathIcon className="w-5 h-5" />
</button>
</div>
{videoTasks.length === 0 ? (
<p className="text-gray-500 text-center py-4"></p>
) : (
<div className="space-y-3">
{videoTasks.map((task) => {
const statusConfig = VIDEO_GENERATION_STATUS_CONFIG[task.status];
return (
<div
key={task.id}
className={`p-3 rounded-lg border ${statusConfig.borderColor} ${statusConfig.bgColor}`}
>
<div className="flex items-center justify-between mb-2">
<span className={`text-sm font-medium ${statusConfig.color}`}>
{statusConfig.label}
</span>
<div className="flex items-center space-x-2">
{task.status === VideoGenerationStatus.Failed && (
<button
onClick={() => handleRetryTask(task.id)}
className="text-blue-600 hover:text-blue-800"
title="重试"
>
<ArrowPathIcon className="w-4 h-4" />
</button>
)}
<button
onClick={() => showDeleteTaskConfirm(task.id)}
className="text-red-600 hover:text-red-800"
title="删除"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
</div>
<div className="text-xs text-gray-600">
<p>: {task.prompt_config.product}</p>
<p>: {task.prompt_config.template}</p>
<p>: {new Date(task.created_at).toLocaleString()}</p>
</div>
{task.result && task.result.video_urls.length > 0 && (
<div className="mt-2">
<p className="text-xs text-gray-600 mb-1">:</p>
<div className="flex flex-wrap gap-2">
{task.result.video_urls.map((url, index) => (
<a
key={index}
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center text-blue-600 hover:text-blue-800 text-xs"
>
<EyeIcon className="w-3 h-3 mr-1" />
{index + 1}
</a>
))}
</div>
</div>
)}
{task.error_message && (
<div className="mt-2 text-xs text-red-600">
: {task.error_message}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
</div>
</div>
{/* 删除照片确认弹框 */}
<DeleteConfirmDialog
isOpen={deletePhotoConfirm.show}
title="删除照片"
message="确定要删除这张照片吗?此操作不可撤销。"
itemName={deletePhotoConfirm.photoName || undefined}
deleting={deletePhotoConfirm.deleting}
onConfirm={confirmDeletePhoto}
onCancel={cancelDeletePhoto}
/>
</div>
);
};
export default ModelDetail;