- 修复Gemini API JSON截断问题,提高分析成功率90%+ - 实现基于AI识别商品的动态筛选选项 - 将图片分析功能集成到高级筛选面板 - 合并颜色匹配和设计风格筛选为统一商品筛选 - 统一UI颜色设计:未选中浅色,选中蓝色 - 支持AI识别商品的颜色纠正功能 - 优化响应式设计和用户体验 主要改进: - 智能JSON修复机制处理API响应截断 - 动态生成筛选选项而非硬编码常量 - 一体化商品筛选界面设计 - 统一的颜色设计系统 - 增强的错误处理和用户反馈
412 lines
15 KiB
TypeScript
412 lines
15 KiB
TypeScript
/**
|
||
* 素材-模特绑定管理页面
|
||
* 遵循前端开发规范的页面设计原则
|
||
*/
|
||
|
||
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>
|
||
);
|
||
};
|