feat: 将穿搭图片生成功能改为Modal弹框形式

- 创建OutfitImageGenerationModal组件,使用Portal渲染到modal-root容器
- 修改ModelDetail页面,将穿搭生成选项卡改为按钮触发Modal
- 更新ModelDetailTabs组件,为穿搭生成选项卡添加特殊处理
- 修复OutfitImageGallery和OutfitImageGenerator的图片路径处理
- 优化UI设计,使用渐变背景和美观的按钮界面
- 支持键盘ESC关闭和背景点击关闭Modal功能
This commit is contained in:
imeepos
2025-07-30 15:16:10 +08:00
parent e4c49126f5
commit 26353f49a7
6 changed files with 315 additions and 51 deletions

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import {
X,
ChevronLeft,
@@ -180,7 +181,12 @@ export const ModelImagePreviewModal: React.FC<ModelImagePreviewModalProps> = ({
const imageUrl = getImageSrc(currentPhoto.file_path);
return (
// 获取modal-root容器
const modalRoot = document.getElementById('modal-root');
if (!modalRoot) return null;
// 使用Portal渲染到modal-root容器
return createPortal(
<div className={`fixed inset-0 z-50 flex items-center justify-center bg-black ${
isFullscreen ? 'bg-opacity-100' : 'bg-opacity-75'
}`}>
@@ -435,7 +441,8 @@ export const ModelImagePreviewModal: React.FC<ModelImagePreviewModalProps> = ({
</div>
)}
</div>
</div>
</div>,
modalRoot
);
};

View File

@@ -9,10 +9,14 @@ import {
List,
Search,
Calendar,
Sparkles
Sparkles,
Eye
} from 'lucide-react';
import { OutfitImageRecord, OutfitImageStatus } from '../types/outfitImage';
import { DeleteConfirmDialog } from './DeleteConfirmDialog';
import { getImageSrc } from '../utils/imagePathUtils';
import { ImagePreviewModal } from './ImagePreviewModal';
import { GroundingSource } from '../types/ragGrounding';
interface OutfitImageGalleryProps {
records: OutfitImageRecord[];
@@ -39,7 +43,16 @@ export const OutfitImageGallery: React.FC<OutfitImageGalleryProps> = ({
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [filter, setFilter] = useState<FilterType>('all');
const [searchQuery, setSearchQuery] = useState('');
// 图片预览模态框状态
const [previewModal, setPreviewModal] = useState<{
isOpen: boolean;
source: GroundingSource | null;
}>({
isOpen: false,
source: null
});
// 删除确认对话框状态
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
@@ -88,6 +101,27 @@ export const OutfitImageGallery: React.FC<OutfitImageGalleryProps> = ({
setDeleteConfirm({ show: false, record: null, deleting: false });
}, []);
// 打开图片预览
const openImagePreview = useCallback((imageUrl: string, title: string) => {
const source: GroundingSource = {
uri: imageUrl,
title: title,
content: { description: '穿搭生成图片' }
};
setPreviewModal({
isOpen: true,
source
});
}, []);
// 关闭图片预览
const closeImagePreview = useCallback(() => {
setPreviewModal({
isOpen: false,
source: null
});
}, []);
const getStatusIcon = (status: OutfitImageStatus) => {
switch (status) {
case OutfitImageStatus.Pending:
@@ -298,12 +332,17 @@ export const OutfitImageGallery: React.FC<OutfitImageGalleryProps> = ({
{record.status === OutfitImageStatus.Completed && record.outfit_images.length > 0 && (
<div className="grid grid-cols-2 gap-2">
{record.outfit_images.slice(0, 4).map((image, index) => (
<div key={image.id} className="aspect-square rounded-lg overflow-hidden">
<div key={image.id} className="aspect-square rounded-lg overflow-hidden relative group cursor-pointer">
<img
src={image.image_url}
src={getImageSrc(image.image_url)}
alt={`穿搭图片 ${index + 1}`}
className="w-full h-full object-cover hover:scale-105 transition-transform duration-200"
onClick={() => openImagePreview(image.image_url, `穿搭图片 ${index + 1}`)}
/>
{/* 预览按钮覆盖层 */}
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all duration-200 flex items-center justify-center">
<Eye className="w-6 h-6 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-200" />
</div>
</div>
))}
{record.outfit_images.length > 4 && (
@@ -381,9 +420,13 @@ export const OutfitImageGallery: React.FC<OutfitImageGalleryProps> = ({
{record.status === OutfitImageStatus.Completed && record.outfit_images.length > 0 && (
<div className="flex -space-x-2">
{record.outfit_images.slice(0, 3).map((image, index) => (
<div key={image.id} className="w-10 h-10 rounded-lg overflow-hidden border-2 border-white">
<div
key={image.id}
className="w-10 h-10 rounded-lg overflow-hidden border-2 border-white cursor-pointer hover:scale-110 transition-transform duration-200"
onClick={() => openImagePreview(image.image_url, `穿搭图片 ${index + 1}`)}
>
<img
src={image.image_url}
src={getImageSrc(image.image_url)}
alt={`穿搭图片 ${index + 1}`}
className="w-full h-full object-cover"
/>
@@ -416,6 +459,13 @@ export const OutfitImageGallery: React.FC<OutfitImageGalleryProps> = ({
onCancel={cancelDelete}
deleting={deleteConfirm.deleting}
/>
{/* 图片预览模态框 */}
<ImagePreviewModal
isOpen={previewModal.isOpen}
source={previewModal.source}
onClose={closeImagePreview}
/>
</div>
);
};

View File

@@ -0,0 +1,146 @@
import React, { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X, Sparkles } from 'lucide-react';
import { Model } from '../types/model';
import { OutfitImageRecord, OutfitImageGenerationRequest } from '../types/outfitImage';
import { OutfitImageGenerator } from './OutfitImageGenerator';
import { OutfitImageGallery } from './OutfitImageGallery';
interface OutfitImageGenerationModalProps {
isOpen: boolean;
onClose: () => void;
model: Model;
outfitRecords: OutfitImageRecord[];
onGenerate: (request: OutfitImageGenerationRequest) => Promise<void>;
onDeleteRecord: (record: OutfitImageRecord) => Promise<void>;
onRefreshRecords: () => Promise<void>;
isGenerating?: boolean;
recordsLoading?: boolean;
}
/**
* 穿搭图片生成模态框组件
* 使用Portal渲染到modal-root容器避免复杂容器结构影响
*/
export const OutfitImageGenerationModal: React.FC<OutfitImageGenerationModalProps> = ({
isOpen,
onClose,
model,
outfitRecords,
onGenerate,
onDeleteRecord,
onRefreshRecords,
isGenerating = false,
recordsLoading = false
}) => {
// 键盘事件处理
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
// 防止背景滚动
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'unset';
};
}, [isOpen, onClose]);
if (!isOpen) return null;
// 获取modal-root容器
const modalRoot = document.getElementById('modal-root');
if (!modalRoot) return null;
// 使用Portal渲染到modal-root容器
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
{/* 模态框背景 */}
<div
className="absolute inset-0 cursor-pointer"
onClick={onClose}
/>
{/* 模态框内容 */}
<div className="relative bg-white rounded-2xl shadow-2xl max-w-7xl max-h-[95vh] w-full mx-4 flex flex-col overflow-hidden">
{/* 头部 */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 bg-gradient-to-r from-purple-50 to-pink-50">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-purple-500 to-pink-500 rounded-xl flex items-center justify-center">
<Sparkles className="w-6 h-6 text-white" />
</div>
<div>
<h2 className="text-xl font-bold text-gray-900">穿</h2>
<p className="text-sm text-gray-600"> {model.name} AI穿搭效果图</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
title="关闭 (Esc)"
>
<X className="w-6 h-6" />
</button>
</div>
{/* 主要内容区域 */}
<div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full">
{/* 穿搭图片生成器 */}
<div className="space-y-6">
<OutfitImageGenerator
modelId={model.id}
modelPhotos={model.photos}
onGenerate={onGenerate}
isGenerating={isGenerating}
/>
</div>
{/* 穿搭图片生成记录 */}
<div className="space-y-6">
<OutfitImageGallery
records={outfitRecords}
onDelete={onDeleteRecord}
onRefresh={onRefreshRecords}
loading={recordsLoading}
/>
</div>
</div>
</div>
{/* 底部操作栏(可选) */}
<div className="flex items-center justify-between p-6 border-t border-gray-200 bg-gray-50">
<div className="text-sm text-gray-500">
{outfitRecords.length}
</div>
<div className="flex items-center space-x-3">
<button
onClick={onRefreshRecords}
className="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-200 rounded-lg transition-colors text-sm"
>
</button>
<button
onClick={onClose}
className="px-6 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors text-sm"
>
</button>
</div>
</div>
</div>
</div>,
modalRoot
);
};
export default OutfitImageGenerationModal;

View File

@@ -12,7 +12,7 @@ import {
} from 'lucide-react';
import { ModelPhoto, PhotoType } from '../types/model';
import { OutfitImageGenerationRequest } from '../types/outfitImage';
import { convertFileSrc } from '@tauri-apps/api/core';
import { getImageSrc } from '../utils/imagePathUtils';
interface OutfitImageGeneratorProps {
modelId: string;
@@ -208,7 +208,7 @@ export const OutfitImageGenerator: React.FC<OutfitImageGeneratorProps> = ({
onClick={() => setSelectedModelImageId(photo.id)}
>
<img
src={convertFileSrc(photo.file_path)}
src={getImageSrc(photo.file_path)}
alt={photo.file_name}
className="w-full h-full object-cover"
/>
@@ -280,7 +280,7 @@ export const OutfitImageGenerator: React.FC<OutfitImageGeneratorProps> = ({
<div key={index} className="relative group">
<div className="aspect-square rounded-lg overflow-hidden border border-gray-200">
<img
src={imagePath.startsWith('http') ? imagePath : `file://${imagePath}`}
src={getImageSrc(imagePath)}
alt={`商品图片 ${index + 1}`}
className="w-full h-full object-cover"
/>

View File

@@ -20,6 +20,7 @@ interface ModelDetailTabsProps {
dynamics: ModelDynamic[];
videoTasks: VideoGenerationTask[];
outfitRecords: OutfitImageRecord[];
onOpenOutfitModal?: () => void;
}
/**
@@ -32,7 +33,8 @@ export const ModelDetailTabs: React.FC<ModelDetailTabsProps> = ({
model,
dynamics,
videoTasks,
outfitRecords
outfitRecords,
onOpenOutfitModal
}) => {
// 键盘快捷键支持
useEffect(() => {
@@ -120,28 +122,60 @@ export const ModelDetailTabs: React.FC<ModelDetailTabsProps> = ({
<div className="bg-white rounded-2xl shadow-sm border border-gray-200/50 mb-6">
<div className="border-b border-gray-200">
<nav className="flex space-x-8 px-6" aria-label="Tabs">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
title={`${tab.name} (${tab.shortcut})`}
className={`${activeTab === tab.id
? 'border-primary-500 text-primary-600 bg-primary-50'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 hover:bg-gray-50'
} whitespace-nowrap py-4 px-4 border-b-2 font-medium text-sm transition-all duration-200 rounded-t-lg flex items-center gap-2 relative group`}
>
<span className="text-base">{tab.icon}</span>
<span>{tab.name}</span>
{tab.badge !== null && tab.badge > 0 && (
<span className={`${activeTab === tab.id
? 'bg-primary-500 text-white'
: 'bg-gray-400 text-white group-hover:bg-gray-500'
} text-xs px-2 py-0.5 rounded-full font-semibold transition-colors duration-200`}>
{tab.badge}
</span>
)}
</button>
))}
{tabs.map((tab) => {
// 穿搭生成选项卡特殊处理 - 显示为按钮形式
if (tab.id === 'outfits') {
return (
<button
key={tab.id}
onClick={() => {
onTabChange(tab.id);
onOpenOutfitModal?.();
}}
title={`${tab.name} (${tab.shortcut})`}
className={`${activeTab === tab.id
? 'border-purple-500 text-purple-600 bg-purple-50'
: 'border-transparent text-gray-500 hover:text-purple-600 hover:border-purple-300 hover:bg-purple-50'
} whitespace-nowrap py-4 px-4 border-b-2 font-medium text-sm transition-all duration-200 rounded-t-lg flex items-center gap-2 relative group`}
>
<span className="text-base">{tab.icon}</span>
<span>{tab.name}</span>
{tab.badge !== null && tab.badge > 0 && (
<span className={`${activeTab === tab.id
? 'bg-purple-500 text-white'
: 'bg-gray-400 text-white group-hover:bg-purple-500'
} text-xs px-2 py-0.5 rounded-full font-semibold transition-colors duration-200`}>
{tab.badge}
</span>
)}
</button>
);
}
// 其他选项卡正常处理
return (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
title={`${tab.name} (${tab.shortcut})`}
className={`${activeTab === tab.id
? 'border-primary-500 text-primary-600 bg-primary-50'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 hover:bg-gray-50'
} whitespace-nowrap py-4 px-4 border-b-2 font-medium text-sm transition-all duration-200 rounded-t-lg flex items-center gap-2 relative group`}
>
<span className="text-base">{tab.icon}</span>
<span>{tab.name}</span>
{tab.badge !== null && tab.badge > 0 && (
<span className={`${activeTab === tab.id
? 'bg-primary-500 text-white'
: 'bg-gray-400 text-white group-hover:bg-gray-500'
} text-xs px-2 py-0.5 rounded-full font-semibold transition-colors duration-200`}>
{tab.badge}
</span>
)}
</button>
);
})}
</nav>
</div>
</div>

View File

@@ -14,8 +14,7 @@ import { DeleteConfirmDialog } from '../components/DeleteConfirmDialog';
import { OutfitImageService } from '../services/outfitImageService';
import { ModelDashboardStats, OutfitImageRecord, OutfitImageGenerationRequest } from '../types/outfitImage';
import { ModelImageGallery } from '../components/ModelImageGallery';
import { OutfitImageGenerator } from '../components/OutfitImageGenerator';
import { OutfitImageGallery } from '../components/OutfitImageGallery';
import { OutfitImageGenerationModal } from '../components/OutfitImageGenerationModal';
import { ModelDetailHeader } from '../components/model-detail/ModelDetailHeader';
import { ModelDetailTabs, TabId } from '../components/model-detail/ModelDetailTabs';
import { ModelOverviewTab } from '../components/model-detail/ModelOverviewTab';
@@ -50,6 +49,9 @@ const ModelDetail: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabId>('overview');
const [dynamics] = useState<ModelDynamic[]>([]);
// 穿搭图片生成Modal状态
const [showOutfitModal, setShowOutfitModal] = useState(false);
const [deletePhotoConfirm, setDeletePhotoConfirm] = useState<{
show: boolean;
@@ -451,6 +453,7 @@ const ModelDetail: React.FC = () => {
dynamics={dynamics}
videoTasks={videoTasks}
outfitRecords={outfitRecords}
onOpenOutfitModal={() => setShowOutfitModal(false)}
/>
{/* Tab内容区域 */}
@@ -503,22 +506,31 @@ const ModelDetail: React.FC = () => {
{/* 穿搭生成选项卡 */}
{activeTab === 'outfits' && (
<div className="animate-fade-in">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 穿搭图片生成 */}
<OutfitImageGenerator
modelId={model.id}
modelPhotos={model.photos}
onGenerate={handleGenerateOutfitImages}
isGenerating={generatingOutfit}
/>
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-8">
<div className="text-center">
<div className="w-20 h-20 bg-gradient-to-br from-purple-500 to-pink-500 rounded-2xl flex items-center justify-center mx-auto mb-6">
<span className="text-3xl">👗</span>
</div>
{/* 穿搭图片生成记录 */}
<OutfitImageGallery
records={outfitRecords}
onDelete={handleDeleteOutfitRecord}
onRefresh={loadOutfitRecords}
loading={outfitRecordsLoading}
/>
<h3 className="text-2xl font-bold text-gray-900 mb-4">AI穿搭图片生成</h3>
<p className="text-gray-600 mb-8 max-w-md mx-auto">
{model.name} AI穿搭效果图
</p>
<div className="space-y-4">
<button
onClick={() => setShowOutfitModal(true)}
className="inline-flex items-center px-8 py-4 bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold rounded-xl hover:from-purple-600 hover:to-pink-600 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:scale-105"
>
<span className="text-xl mr-3"></span>
穿
</button>
<div className="text-sm text-gray-500">
{outfitRecords.length}
</div>
</div>
</div>
</div>
</div>
)}
@@ -544,6 +556,21 @@ const ModelDetail: React.FC = () => {
onConfirm={confirmDeletePhoto}
onCancel={cancelDeletePhoto}
/>
{/* 穿搭图片生成Modal */}
{model && (
<OutfitImageGenerationModal
isOpen={showOutfitModal}
onClose={() => setShowOutfitModal(false)}
model={model}
outfitRecords={outfitRecords}
onGenerate={handleGenerateOutfitImages}
onDeleteRecord={handleDeleteOutfitRecord}
onRefreshRecords={loadOutfitRecords}
isGenerating={generatingOutfit}
recordsLoading={outfitRecordsLoading}
/>
)}
</div>
</div>
);