Files
mixvideo-v2/apps/desktop/src/components/ModelImageGallery.tsx
imeepos 7552761d9a feat: 模特详情页改版 - 实现个人看板、形象图片管理和穿搭图片生成功能
新功能:
- 个人看板统计信息展示 (照片数量、穿搭图片、生成记录等)
- 个人形象图片管理 (上传、删除、预览、收藏)
- 穿搭图片生成功能 (选择模特图片 + 上传商品图片 => AI生成穿搭效果)
- 穿搭图片管理界面 (生成记录展示、状态跟踪、结果预览)

 技术实现:
- 新增穿搭图片相关数据模型和数据库表
- 实现OutfitImageService服务层
- 创建多个UI组件 (ModelDashboardStats, ModelImageGallery, OutfitImageGenerator等)
- 优化模特详情页整体布局,采用响应式设计

 UI/UX优化:
- 遵循promptx/frontend-developer设计规范
- 统一的视觉风格和动画效果
- 支持拖拽上传、图片预览、状态指示等交互
- 响应式布局适配不同屏幕尺寸

 测试:
- Rust编译检查通过 (cargo check)
- 前端构建检查通过 (pnpm run -w tauri:web:build)
- 所有TypeScript类型检查通过
2025-07-30 12:51:34 +08:00

439 lines
16 KiB
TypeScript

import React, { useState, useCallback } from 'react';
import {
Eye,
Trash2,
Heart,
Plus,
Grid3X3,
List,
Search
} from 'lucide-react';
import { ModelPhoto, PhotoType } from '../types/model';
import { convertFileSrc } from '@tauri-apps/api/core';
import { ModelImageUploader } from './ModelImageUploader';
import { ModelImagePreviewModal } from './ModelImagePreviewModal';
import { DeleteConfirmDialog } from './DeleteConfirmDialog';
interface ModelImageGalleryProps {
photos: ModelPhoto[];
onUpload: (imagePaths: string[], photoType: PhotoType) => Promise<void>;
onDelete: (photo: ModelPhoto) => Promise<void>;
onToggleFavorite?: (photo: ModelPhoto) => Promise<void>;
isUploading?: boolean;
className?: string;
}
type ViewMode = 'grid' | 'list';
type FilterType = 'all' | PhotoType;
/**
* 模特图片画廊组件
* 集成上传、预览、删除等功能
*/
export const ModelImageGallery: React.FC<ModelImageGalleryProps> = ({
photos,
onUpload,
onDelete,
onToggleFavorite,
isUploading = false,
className = ''
}) => {
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [filter, setFilter] = useState<FilterType>('all');
const [searchQuery, setSearchQuery] = useState('');
const [showUploader, setShowUploader] = useState(false);
// 预览模态框状态
const [previewModal, setPreviewModal] = useState<{
isOpen: boolean;
photos: ModelPhoto[];
initialIndex: number;
}>({
isOpen: false,
photos: [],
initialIndex: 0
});
// 删除确认对话框状态
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
photo: ModelPhoto | null;
deleting: boolean;
}>({
show: false,
photo: null,
deleting: false
});
// 过滤照片
const filteredPhotos = photos.filter(photo => {
const matchesFilter = filter === 'all' || photo.photo_type === filter;
const matchesSearch = searchQuery === '' ||
photo.file_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(photo.description && photo.description.toLowerCase().includes(searchQuery.toLowerCase())) ||
photo.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()));
return matchesFilter && matchesSearch;
});
// 处理图片上传
const handleUpload = useCallback(async (imagePaths: string[], photoType: PhotoType) => {
try {
await onUpload(imagePaths, photoType);
setShowUploader(false);
} catch (error) {
console.error('上传失败:', error);
}
}, [onUpload]);
// 打开预览
const openPreview = useCallback((photo: ModelPhoto) => {
const photoIndex = filteredPhotos.findIndex(p => p.id === photo.id);
setPreviewModal({
isOpen: true,
photos: filteredPhotos,
initialIndex: photoIndex
});
}, [filteredPhotos]);
// 关闭预览
const closePreview = useCallback(() => {
setPreviewModal(prev => ({ ...prev, isOpen: false }));
}, []);
// 处理删除确认
const handleDeleteClick = useCallback((photo: ModelPhoto) => {
setDeleteConfirm({
show: true,
photo,
deleting: false
});
}, []);
// 确认删除
const confirmDelete = useCallback(async () => {
if (!deleteConfirm.photo) return;
try {
setDeleteConfirm(prev => ({ ...prev, deleting: true }));
await onDelete(deleteConfirm.photo);
setDeleteConfirm({ show: false, photo: null, deleting: false });
} catch (error) {
console.error('删除失败:', error);
setDeleteConfirm(prev => ({ ...prev, deleting: false }));
}
}, [deleteConfirm.photo, onDelete]);
// 取消删除
const cancelDelete = useCallback(() => {
setDeleteConfirm({ show: false, photo: null, deleting: false });
}, []);
const getPhotoTypeLabel = (photoType: PhotoType): string => {
switch (photoType) {
case PhotoType.Portrait:
return '个人形象照';
case PhotoType.Commercial:
return '商业照';
case PhotoType.Casual:
return '生活照';
case PhotoType.FullBody:
return '全身照';
case PhotoType.Headshot:
return '头像照';
case PhotoType.Artistic:
return '艺术照';
default:
return '其他';
}
};
const getPhotoTypeColor = (photoType: PhotoType): string => {
switch (photoType) {
case PhotoType.Portrait:
return 'bg-blue-100 text-blue-800';
case PhotoType.Commercial:
return 'bg-green-100 text-green-800';
case PhotoType.Casual:
return 'bg-purple-100 text-purple-800';
case PhotoType.FullBody:
return 'bg-indigo-100 text-indigo-800';
case PhotoType.Headshot:
return 'bg-pink-100 text-pink-800';
case PhotoType.Artistic:
return 'bg-yellow-100 text-yellow-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
return (
<div className={`space-y-6 ${className}`}>
{/* 头部工具栏 */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<h2 className="text-xl font-bold text-gray-900"></h2>
<span className="text-sm text-gray-500">
{photos.length}
{filteredPhotos.length !== photos.length && ` (显示 ${filteredPhotos.length} 张)`}
</span>
</div>
<div className="flex items-center space-x-2">
{/* 搜索框 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="搜索照片..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
{/* 过滤器 */}
<select
value={filter}
onChange={(e) => setFilter(e.target.value as FilterType)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="all"></option>
<option value={PhotoType.Portrait}></option>
<option value={PhotoType.Commercial}></option>
<option value={PhotoType.Casual}></option>
<option value={PhotoType.FullBody}></option>
<option value={PhotoType.Headshot}></option>
<option value={PhotoType.Artistic}></option>
</select>
{/* 视图模式切换 */}
<div className="flex border border-gray-300 rounded-lg overflow-hidden">
<button
onClick={() => setViewMode('grid')}
className={`p-2 ${viewMode === 'grid' ? 'bg-blue-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'}`}
title="网格视图"
>
<Grid3X3 className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 ${viewMode === 'list' ? 'bg-blue-500 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'}`}
title="列表视图"
>
<List className="w-4 h-4" />
</button>
</div>
{/* 上传按钮 */}
<button
onClick={() => setShowUploader(!showUploader)}
disabled={isUploading}
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Plus className="w-4 h-4 mr-2" />
</button>
</div>
</div>
{/* 上传组件 */}
{showUploader && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<ModelImageUploader
onImagesSelect={handleUpload}
isUploading={isUploading}
/>
</div>
)}
{/* 照片展示 */}
{filteredPhotos.length === 0 ? (
<div className="text-center py-12 bg-gray-50 rounded-xl">
<div className="text-6xl mb-4 opacity-50">📷</div>
<p className="text-lg font-medium text-gray-600 mb-2">
{photos.length === 0 ? '暂无照片' : '没有找到匹配的照片'}
</p>
<p className="text-sm text-gray-500">
{photos.length === 0 ? '点击上传按钮添加第一张照片' : '尝试调整搜索条件或过滤器'}
</p>
</div>
) : viewMode === 'grid' ? (
/* 网格视图 */
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{filteredPhotos.map((photo) => (
<div
key={photo.id}
className="group relative bg-white rounded-xl border border-gray-200 overflow-hidden hover:shadow-lg transition-all duration-200"
>
{/* 图片 */}
<div className="aspect-square overflow-hidden">
<img
src={convertFileSrc(photo.file_path)}
alt={photo.file_name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
</div>
{/* 悬停操作按钮 */}
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-50 transition-all duration-200 flex items-center justify-center opacity-0 group-hover:opacity-100">
<div className="flex space-x-2">
<button
onClick={() => openPreview(photo)}
className="p-2 bg-white text-gray-700 rounded-full hover:bg-gray-100 transition-colors"
title="预览"
>
<Eye className="w-4 h-4" />
</button>
{onToggleFavorite && (
<button
onClick={() => onToggleFavorite(photo)}
className={`p-2 rounded-full transition-colors ${
photo.is_cover
? 'bg-red-500 text-white hover:bg-red-600'
: 'bg-white text-gray-700 hover:bg-gray-100'
}`}
title="收藏"
>
<Heart className={`w-4 h-4 ${photo.is_cover ? 'fill-current' : ''}`} />
</button>
)}
<button
onClick={() => handleDeleteClick(photo)}
className="p-2 bg-white text-red-600 rounded-full hover:bg-red-50 transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{/* 照片信息 */}
<div className="p-3">
<div className="flex items-center justify-between mb-2">
<span className={`px-2 py-1 text-xs rounded-full ${getPhotoTypeColor(photo.photo_type)}`}>
{getPhotoTypeLabel(photo.photo_type)}
</span>
{photo.is_cover && (
<Heart className="w-4 h-4 text-red-500 fill-current" />
)}
</div>
<p className="text-sm font-medium text-gray-900 truncate" title={photo.file_name}>
{photo.file_name}
</p>
<p className="text-xs text-gray-500">
{formatFileSize(photo.file_size)}
</p>
</div>
</div>
))}
</div>
) : (
/* 列表视图 */
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="divide-y divide-gray-200">
{filteredPhotos.map((photo) => (
<div key={photo.id} className="p-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center space-x-4">
{/* 缩略图 */}
<div className="w-16 h-16 rounded-lg overflow-hidden flex-shrink-0">
<img
src={convertFileSrc(photo.file_path)}
alt={photo.file_name}
className="w-full h-full object-cover"
/>
</div>
{/* 信息 */}
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2 mb-1">
<h3 className="text-sm font-medium text-gray-900 truncate">
{photo.file_name}
</h3>
<span className={`px-2 py-1 text-xs rounded-full ${getPhotoTypeColor(photo.photo_type)}`}>
{getPhotoTypeLabel(photo.photo_type)}
</span>
{photo.is_cover && (
<Heart className="w-4 h-4 text-red-500 fill-current" />
)}
</div>
<p className="text-sm text-gray-500">
{formatFileSize(photo.file_size)} {new Date(photo.created_at).toLocaleDateString()}
</p>
{photo.description && (
<p className="text-sm text-gray-600 mt-1 truncate">
{photo.description}
</p>
)}
</div>
{/* 操作按钮 */}
<div className="flex items-center space-x-2">
<button
onClick={() => openPreview(photo)}
className="p-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
title="预览"
>
<Eye className="w-4 h-4" />
</button>
{onToggleFavorite && (
<button
onClick={() => onToggleFavorite(photo)}
className={`p-2 rounded-lg transition-colors ${
photo.is_cover
? 'text-red-500 hover:bg-red-50'
: 'text-gray-600 hover:bg-gray-100'
}`}
title="收藏"
>
<Heart className={`w-4 h-4 ${photo.is_cover ? 'fill-current' : ''}`} />
</button>
)}
<button
onClick={() => handleDeleteClick(photo)}
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* 图片预览模态框 */}
<ModelImagePreviewModal
photos={previewModal.photos}
initialIndex={previewModal.initialIndex}
isOpen={previewModal.isOpen}
onClose={closePreview}
onDelete={handleDeleteClick}
onToggleFavorite={onToggleFavorite}
/>
{/* 删除确认对话框 */}
<DeleteConfirmDialog
isOpen={deleteConfirm.show}
title="删除照片"
message={`确定要删除照片 "${deleteConfirm.photo?.file_name}" 吗?此操作无法撤销。`}
onConfirm={confirmDelete}
onCancel={cancelDelete}
deleting={deleteConfirm.deleting}
/>
</div>
);
};
export default ModelImageGallery;