710 lines
26 KiB
TypeScript
710 lines
26 KiB
TypeScript
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;
|