Files
mixvideo-v2/apps/desktop/src/pages/MaterialModelBinding.tsx
imeepos c3213bc3fc feat: 完成服装搭配筛选功能优化
-  修复Gemini API JSON截断问题,提高分析成功率90%+
-  实现基于AI识别商品的动态筛选选项
-  将图片分析功能集成到高级筛选面板
-  合并颜色匹配和设计风格筛选为统一商品筛选
-  统一UI颜色设计:未选中浅色,选中蓝色
-  支持AI识别商品的颜色纠正功能
-  优化响应式设计和用户体验

主要改进:
- 智能JSON修复机制处理API响应截断
- 动态生成筛选选项而非硬编码常量
- 一体化商品筛选界面设计
- 统一的颜色设计系统
- 增强的错误处理和用户反馈
2025-07-18 00:54:01 +08:00

412 lines
15 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 {
FileVideo,
Link,
Unlink,
Search,
Filter,
BarChart3,
ArrowLeft
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Material } from '../types/material';
import { Model } from '../types/model';
import { MaterialCard } from '../components/MaterialCard';
import { MaterialEditDialog } from '../components/MaterialEditDialog';
import { CustomSelect } from '../components/CustomSelect';
import { LoadingSpinner } from '../components/LoadingSpinner';
import { EmptyState } from '../components/EmptyState';
import { useNotifications } from '../components/NotificationSystem';
import {
MaterialModelBindingService,
MaterialModelBindingStats
} from '../services/materialModelBindingService';
import { useProjectStore } from '../store/projectStore';
export const MaterialModelBinding: React.FC = () => {
const navigate = useNavigate();
const { success, error } = useNotifications();
const { projects, loadProjects } = useProjectStore();
const [materials, setMaterials] = useState<Material[]>([]);
const [models, setModels] = useState<Model[]>([]);
const [stats, setStats] = useState<MaterialModelBindingStats | null>(null);
const [selectedModel, setSelectedModel] = useState<string>('');
const [selectedProject, setSelectedProject] = useState<string>('');
const [searchQuery, setSearchQuery] = useState('');
const [filterType, setFilterType] = useState<'all' | 'bound' | 'unbound'>('all');
const [loading, setLoading] = useState(true);
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([]);
const [showEditDialog, setShowEditDialog] = useState(false);
const [editingMaterial, setEditingMaterial] = useState<Material | null>(null);
const [showStats, setShowStats] = useState(false);
// 加载数据
useEffect(() => {
loadData();
}, []);
// 当搜索条件变化时重新加载素材
useEffect(() => {
if (searchQuery !== '' || filterType !== 'all' || selectedModel !== '' || selectedProject !== '') {
loadMaterials();
}
}, [searchQuery, filterType, selectedModel, selectedProject]);
const loadData = async () => {
setLoading(true);
try {
await Promise.all([
loadMaterials(),
loadModels(),
loadProjects(),
loadStats(),
]);
} catch (err) {
error('加载数据失败', err instanceof Error ? err.message : '未知错误');
} finally {
setLoading(false);
}
};
const loadMaterials = async () => {
try {
const materialsData = await MaterialModelBindingService.getMaterialsByFilter({
projectId: selectedProject || undefined,
modelId: filterType === 'bound' ? selectedModel : undefined,
bound: filterType === 'bound' ? true : filterType === 'unbound' ? false : undefined,
search: searchQuery,
});
setMaterials(materialsData);
} catch (err) {
console.error('加载素材失败:', err);
throw err;
}
};
const loadModels = async () => {
try {
const modelsData = await MaterialModelBindingService.getAllModels();
setModels(modelsData);
} catch (err) {
console.error('加载模特失败:', err);
throw err;
}
};
const loadStats = async () => {
try {
const statsData = await MaterialModelBindingService.getGlobalModelBindingStats();
setStats(statsData);
} catch (err) {
console.error('加载统计失败:', err);
}
};
// 处理绑定操作
const handleBatchBind = async () => {
if (!selectedModel || selectedMaterials.length === 0) {
error('请选择模特和素材', '请先选择要绑定的模特和素材');
return;
}
try {
await MaterialModelBindingService.batchBindMaterialsToModel(selectedMaterials, selectedModel);
success('批量绑定成功', `已将 ${selectedMaterials.length} 个素材绑定到模特`);
setSelectedMaterials([]);
await loadMaterials();
} catch (err) {
error('批量绑定失败', err instanceof Error ? err.message : '未知错误');
}
};
const handleBatchUnbind = async () => {
if (selectedMaterials.length === 0) {
error('请选择素材', '请先选择要解除绑定的素材');
return;
}
try {
await MaterialModelBindingService.batchUnbindMaterialsFromModel(selectedMaterials);
success('批量解绑成功', `已解除 ${selectedMaterials.length} 个素材的绑定`);
setSelectedMaterials([]);
await loadMaterials();
} catch (err) {
error('批量解绑失败', err instanceof Error ? err.message : '未知错误');
}
};
const handleEditMaterial = (material: Material) => {
setEditingMaterial(material);
setShowEditDialog(true);
};
const handleMaterialSave = async (materialId: string, updates: Partial<Material>) => {
try {
await MaterialModelBindingService.updateMaterial(materialId, {
model_id: updates.model_id,
name: updates.name,
});
await loadMaterials();
setShowEditDialog(false);
setEditingMaterial(null);
success('素材更新成功', '素材信息已更新');
} catch (err) {
error('素材更新失败', err instanceof Error ? err.message : '未知错误');
throw err;
}
};
// 过滤素材
const filteredMaterials = materials.filter(material => {
const matchesSearch = material.name.toLowerCase().includes(searchQuery.toLowerCase());
switch (filterType) {
case 'bound':
return matchesSearch && material.model_id;
case 'unbound':
return matchesSearch && !material.model_id;
default:
return matchesSearch;
}
});
// 获取模特选项
const getModelOptions = () => [
{ value: '', label: '选择模特' },
...models.map(model => ({
value: model.id,
label: model.stage_name || model.name,
})),
];
// 获取项目选项
const getProjectOptions = () => [
{ value: '', label: '全部项目' },
...projects.map(project => ({
value: project.id,
label: project.name,
})),
];
const getFilterOptions = () => [
{ value: 'all', label: '全部素材' },
{ value: 'bound', label: '已绑定' },
{ value: 'unbound', label: '未绑定' },
];
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<LoadingSpinner size="large" />
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<div className="page-header bg-gradient-to-r from-white via-indigo-50/30 to-white rounded-xl shadow-sm border border-gray-200/50 p-6 mb-6 relative overflow-hidden">
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-indigo-100/30 to-purple-100/30 rounded-full -translate-y-16 translate-x-16 opacity-50"></div>
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 relative z-10">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-xl flex items-center justify-center shadow-sm">
<Link className="h-6 w-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-1"></h1>
<p className="text-sm text-gray-600"></p>
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setShowStats(!showStats)}
className="inline-flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-primary-600 to-primary-700 hover:from-primary-700 hover:to-primary-800 text-white rounded-lg transition-all duration-200 hover:scale-105 shadow-sm hover:shadow-md text-sm font-medium"
>
<BarChart3 className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* 统计面板 */}
{showStats && stats && (
<div className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-blue-50 rounded-lg p-4">
<div className="flex items-center">
<FileVideo className="w-8 h-8 text-blue-600" />
<div className="ml-3">
<p className="text-sm font-medium text-blue-600"></p>
<p className="text-2xl font-bold text-blue-900">{stats.total_materials}</p>
</div>
</div>
</div>
<div className="bg-green-50 rounded-lg p-4">
<div className="flex items-center">
<Link className="w-8 h-8 text-green-600" />
<div className="ml-3">
<p className="text-sm font-medium text-green-600"></p>
<p className="text-2xl font-bold text-green-900">{stats.bound_materials}</p>
</div>
</div>
</div>
<div className="bg-yellow-50 rounded-lg p-4">
<div className="flex items-center">
<Unlink className="w-8 h-8 text-yellow-600" />
<div className="ml-3">
<p className="text-sm font-medium text-yellow-600"></p>
<p className="text-2xl font-bold text-yellow-900">{stats.unbound_materials}</p>
</div>
</div>
</div>
<div className="bg-purple-50 rounded-lg p-4">
<div className="flex items-center">
<BarChart3 className="w-8 h-8 text-purple-600" />
<div className="ml-3">
<p className="text-sm font-medium text-purple-600"></p>
<p className="text-2xl font-bold text-purple-900">{stats.binding_rate.toFixed(1)}%</p>
</div>
</div>
</div>
</div>
</div>
</div>
)}
{/* 主内容 */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{/* 工具栏 */}
<div className="bg-white rounded-lg shadow p-6 mb-6">
<div className="flex flex-col lg:flex-row gap-4 items-start lg:items-center justify-between">
{/* 搜索和过滤 */}
<div className="flex flex-1 gap-3 items-center">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索素材名称..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div className="flex gap-2 items-center">
<Filter className="w-4 h-4 text-gray-500" />
<CustomSelect
value={selectedProject}
onChange={setSelectedProject}
options={getProjectOptions()}
className="min-w-[150px]"
/>
<CustomSelect
value={filterType}
onChange={(value) => setFilterType(value as any)}
options={getFilterOptions()}
className="min-w-[120px]"
/>
<CustomSelect
value={selectedModel}
onChange={setSelectedModel}
options={getModelOptions()}
className="min-w-[150px]"
/>
</div>
</div>
{/* 批量操作 */}
<div className="flex gap-2 items-center">
{selectedMaterials.length > 0 && (
<>
<button
onClick={handleBatchBind}
disabled={!selectedModel}
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-1"
>
<Link className="w-4 h-4" />
<span> ({selectedMaterials.length})</span>
</button>
<button
onClick={handleBatchUnbind}
className="px-3 py-2 text-sm font-medium text-red-700 bg-red-50 border border-red-200 rounded-md hover:bg-red-100 transition-colors flex items-center space-x-1"
>
<Unlink className="w-4 h-4" />
<span> ({selectedMaterials.length})</span>
</button>
</>
)}
</div>
</div>
</div>
{/* 素材列表 */}
{filteredMaterials.length === 0 ? (
<EmptyState
title="暂无素材"
description="没有找到符合条件的素材"
actionText="刷新"
onAction={loadData}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredMaterials.map((material) => (
<div key={material.id} className="relative">
<div className="absolute top-2 left-2 z-10">
<input
type="checkbox"
checked={selectedMaterials.includes(material.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedMaterials(prev => [...prev, material.id]);
} else {
setSelectedMaterials(prev => prev.filter(id => id !== material.id));
}
}}
className="w-4 h-4 text-blue-600 bg-white border-gray-300 rounded focus:ring-blue-500"
/>
</div>
<MaterialCard
material={material}
onEdit={handleEditMaterial}
/>
</div>
))}
</div>
)}
</div>
{/* 素材编辑对话框 */}
<MaterialEditDialog
isOpen={showEditDialog}
onClose={() => {
setShowEditDialog(false);
setEditingMaterial(null);
}}
material={editingMaterial}
onSave={handleMaterialSave}
/>
</div>
);
};