新功能: - 个人看板统计信息展示 (照片数量、穿搭图片、生成记录等) - 个人形象图片管理 (上传、删除、预览、收藏) - 穿搭图片生成功能 (选择模特图片 + 上传商品图片 => AI生成穿搭效果) - 穿搭图片管理界面 (生成记录展示、状态跟踪、结果预览) 技术实现: - 新增穿搭图片相关数据模型和数据库表 - 实现OutfitImageService服务层 - 创建多个UI组件 (ModelDashboardStats, ModelImageGallery, OutfitImageGenerator等) - 优化模特详情页整体布局,采用响应式设计 UI/UX优化: - 遵循promptx/frontend-developer设计规范 - 统一的视觉风格和动画效果 - 支持拖拽上传、图片预览、状态指示等交互 - 响应式布局适配不同屏幕尺寸 测试: - Rust编译检查通过 (cargo check) - 前端构建检查通过 (pnpm run -w tauri:web:build) - 所有TypeScript类型检查通过
439 lines
16 KiB
TypeScript
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;
|