Files
mixvideo-v2/apps/desktop/src/components/outfit/OutfitItemList.tsx

433 lines
16 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 {
PlusIcon,
PencilIcon,
TrashIcon,
EyeIcon,
MagnifyingGlassIcon,
FunnelIcon
} from '@heroicons/react/24/outline';
import { invoke } from '@tauri-apps/api/core';
import { useNotifications } from '../NotificationSystem';
import { PROJECT_ID } from './const';
interface OutfitItem {
id: string;
project_id: string;
analysis_id?: string;
name: string;
category: string;
description?: string;
color_pattern?: any;
design_styles?: string[];
brand?: string;
size?: string;
price?: number;
purchase_date?: string;
image_path?: string;
tags?: string[];
notes?: string;
created_at: string;
updated_at: string;
}
interface OutfitItemListProps {
projectId: string;
onCreateItem?: () => void;
onEditItem?: (item: OutfitItem) => void;
}
const OutfitItemList: React.FC<OutfitItemListProps> = ({
projectId,
onCreateItem,
onEditItem
}) => {
const [items, setItems] = useState<OutfitItem[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('');
const [selectedItem, setSelectedItem] = useState<OutfitItem | null>(null);
const { addNotification } = useNotifications();
// 加载服装单品列表
const loadItems = async () => {
try {
setLoading(true);
const options = {
project_id: PROJECT_ID,
category: selectedCategory || null,
limit: 100,
offset: 0
};
const result = await invoke('list_outfit_items', { options });
setItems(result as OutfitItem[]);
} catch (error) {
console.error('加载服装单品失败:', error);
addNotification({
type: 'error',
title: '加载失败',
message: '无法加载服装单品列表'
});
} finally {
setLoading(false);
}
};
// 删除服装单品
const handleDeleteItem = async (itemId: string) => {
if (!window.confirm('确定要删除这个服装单品吗?')) {
return;
}
try {
await invoke('delete_outfit_item', { id: itemId });
addNotification({
type: 'success',
title: '删除成功',
message: '服装单品已删除'
});
// 重新加载列表
loadItems();
} catch (error) {
console.error('删除服装单品失败:', error);
addNotification({
type: 'error',
title: '删除失败',
message: error instanceof Error ? error.message : '删除服装单品失败'
});
}
};
// 过滤服装单品
const filteredItems = items.filter(item => {
const matchesSearch = !searchTerm ||
item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.brand?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = !selectedCategory || item.category === selectedCategory;
return matchesSearch && matchesCategory;
});
// 获取所有类别
const categories = Array.from(new Set(items.map(item => item.category))).filter(Boolean);
// 格式化价格
const formatPrice = (price?: number) => {
if (!price) return '';
return `¥${price.toFixed(2)}`;
};
// 格式化时间
const formatTime = (timeString: string) => {
return new Date(timeString).toLocaleString('zh-CN');
};
useEffect(() => {
if (projectId) {
loadItems();
}
}, [projectId, selectedCategory]);
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
<span className="ml-2 text-gray-600">...</span>
</div>
);
}
return (
<div className="space-y-6">
{/* 工具栏 */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<div className="flex flex-col sm:flex-row gap-4 flex-1">
{/* 搜索框 */}
<div className="relative flex-1 max-w-md">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="搜索服装单品..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
{/* 类别筛选 */}
<div className="relative">
<FunnelIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="pl-10 pr-8 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-primary-500 appearance-none bg-white"
>
<option value=""></option>
{categories.map(category => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
</div>
</div>
{/* 添加按钮 */}
<button
onClick={onCreateItem}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<PlusIcon className="h-4 w-4 mr-2" />
</button>
</div>
{/* 统计信息 */}
<div className="bg-gray-50 rounded-lg p-4">
<div className="flex items-center justify-between text-sm text-gray-600">
<span> {filteredItems.length} </span>
{searchTerm || selectedCategory ? (
<span> {items.length} </span>
) : null}
</div>
</div>
{/* 服装单品列表 */}
{filteredItems.length === 0 ? (
<div className="text-center py-12">
<div className="mx-auto h-12 w-12 text-gray-400 mb-4">
<PlusIcon className="h-full w-full" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
{searchTerm || selectedCategory ? '没有找到匹配的服装单品' : '暂无服装单品'}
</h3>
<p className="text-gray-500 mb-4">
{searchTerm || selectedCategory ? '请尝试调整搜索条件' : '请先上传图片进行AI分析或手动添加服装单品'}
</p>
{!searchTerm && !selectedCategory && (
<button
onClick={onCreateItem}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<PlusIcon className="h-4 w-4 mr-2" />
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredItems.map((item) => (
<div
key={item.id}
className="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition-shadow overflow-hidden"
>
{/* 图片预览 */}
<div className="aspect-square bg-gray-100 relative">
{item.image_path ? (
<img
src={`file://${item.image_path}`}
alt={item.name}
className="w-full h-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<div className="text-gray-400 text-center">
<div className="w-12 h-12 mx-auto mb-2 bg-gray-200 rounded-full flex items-center justify-center">
<PlusIcon className="w-6 h-6" />
</div>
<span className="text-sm"></span>
</div>
</div>
)}
<div className="absolute top-2 right-2 flex space-x-1">
<button
onClick={() => setSelectedItem(item)}
className="p-1.5 bg-white rounded-full shadow-md hover:shadow-lg transition-shadow"
>
<EyeIcon className="w-3 h-3 text-gray-600" />
</button>
</div>
</div>
{/* 单品信息 */}
<div className="p-4">
<div className="flex items-start justify-between mb-2">
<h4 className="font-medium text-gray-900 truncate flex-1">
{item.name}
</h4>
<span className="ml-2 px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 rounded-full">
{item.category}
</span>
</div>
{item.brand && (
<p className="text-sm text-gray-600 mb-1">
: {item.brand}
</p>
)}
{item.price && (
<p className="text-sm font-medium text-green-600 mb-2">
{formatPrice(item.price)}
</p>
)}
{item.description && (
<p className="text-sm text-gray-500 mb-3 line-clamp-2">
{item.description}
</p>
)}
<p className="text-xs text-gray-400 mb-3">
: {formatTime(item.created_at)}
</p>
{/* 操作按钮 */}
<div className="flex space-x-2">
<button
onClick={() => onEditItem?.(item)}
className="flex-1 px-3 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
>
<PencilIcon className="w-4 h-4 inline mr-1" />
</button>
<button
onClick={() => handleDeleteItem(item.id)}
className="px-3 py-2 text-sm font-medium text-red-700 bg-red-100 rounded-md hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* 详情模态框 */}
{selectedItem && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mt-3 text-center sm:mt-0 sm:text-left w-full">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
</h3>
<div className="space-y-3">
<div>
<span className="font-medium text-gray-700">:</span>
<span className="ml-2 text-gray-600">{selectedItem.name}</span>
</div>
<div>
<span className="font-medium text-gray-700">:</span>
<span className="ml-2 text-gray-600">{selectedItem.category}</span>
</div>
{selectedItem.brand && (
<div>
<span className="font-medium text-gray-700">:</span>
<span className="ml-2 text-gray-600">{selectedItem.brand}</span>
</div>
)}
{selectedItem.size && (
<div>
<span className="font-medium text-gray-700">:</span>
<span className="ml-2 text-gray-600">{selectedItem.size}</span>
</div>
)}
{selectedItem.price && (
<div>
<span className="font-medium text-gray-700">:</span>
<span className="ml-2 text-gray-600">{formatPrice(selectedItem.price)}</span>
</div>
)}
{selectedItem.description && (
<div>
<span className="font-medium text-gray-700">:</span>
<p className="mt-1 text-gray-600">{selectedItem.description}</p>
</div>
)}
{selectedItem.design_styles && selectedItem.design_styles.length > 0 && (
<div>
<span className="font-medium text-gray-700">:</span>
<div className="mt-1 flex flex-wrap gap-1">
{selectedItem.design_styles.map((style, index) => (
<span
key={index}
className="px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded-full"
>
{style}
</span>
))}
</div>
</div>
)}
{selectedItem.tags && selectedItem.tags.length > 0 && (
<div>
<span className="font-medium text-gray-700">:</span>
<div className="mt-1 flex flex-wrap gap-1">
{selectedItem.tags.map((tag, index) => (
<span
key={index}
className="px-2 py-1 text-xs bg-gray-100 text-gray-800 rounded-full"
>
{tag}
</span>
))}
</div>
</div>
)}
<div>
<span className="font-medium text-gray-700">:</span>
<span className="ml-2 text-gray-600">{formatTime(selectedItem.created_at)}</span>
</div>
<div>
<span className="font-medium text-gray-700">:</span>
<span className="ml-2 text-gray-600">{formatTime(selectedItem.updated_at)}</span>
</div>
</div>
</div>
</div>
</div>
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
onClick={() => setSelectedItem(null)}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm"
>
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default OutfitItemList;