diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d299e01..ae2b53f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -59,6 +59,28 @@ model Category { @@map("categories") } +model NavigationCategory { + id String @id @default(cuid()) + + // Привязка к категории PartsIndex + partsIndexCatalogId String // ID каталога из PartsIndex API + partsIndexGroupId String? // ID группы из PartsIndex API (необязательно) + + // Только иконка - название берем из PartsIndex + icon String? // URL иконки в S3 + + // Настройки отображения + isHidden Boolean @default(false) + sortOrder Int @default(0) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Уникальная комбинация catalogId + groupId + @@unique([partsIndexCatalogId, partsIndexGroupId]) + @@map("navigation_categories") +} + model Product { id String @id @default(cuid()) name String diff --git a/src/app/api/debug-partsindex/route.ts b/src/app/api/debug-partsindex/route.ts new file mode 100644 index 0000000..367e7a1 --- /dev/null +++ b/src/app/api/debug-partsindex/route.ts @@ -0,0 +1,123 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { partsIndexService } from '@/lib/partsindex-service'; + +export async function GET(request: NextRequest) { + try { + const url = new URL(request.url); + const action = url.searchParams.get('action'); + const prefix = url.searchParams.get('prefix'); + + switch (action) { + case 'stats': + // Возвращаем статистику кэша + const stats = partsIndexService.getCacheStats(); + return NextResponse.json({ + success: true, + data: { + ...stats, + formattedEntries: stats.entries.map(entry => ({ + ...entry, + sizeKB: Math.round(entry.size / 1024 * 100) / 100, + ageMinutes: Math.round(entry.age / (1000 * 60) * 100) / 100, + ttlMinutes: Math.round(entry.ttl / (1000 * 60) * 100) / 100, + isExpired: entry.age > entry.ttl + })) + } + }); + + case 'clear': + if (prefix) { + partsIndexService.clearCacheByPrefix(prefix); + return NextResponse.json({ + success: true, + message: `Кэш с префиксом "${prefix}" очищен` + }); + } else { + partsIndexService.clearCache(); + return NextResponse.json({ + success: true, + message: 'Весь кэш PartsIndex очищен' + }); + } + + case 'test': + // Тестовый запрос для проверки работы кэша + const catalogs = await partsIndexService.getCatalogs('ru'); + return NextResponse.json({ + success: true, + data: { + catalogsCount: catalogs.length, + catalogs: catalogs.slice(0, 3).map(c => ({ id: c.id, name: c.name })) + } + }); + + default: + return NextResponse.json({ + success: false, + error: 'Неизвестное действие. Доступные: stats, clear, test' + }, { status: 400 }); + } + } catch (error) { + console.error('❌ Ошибка в debug-partsindex API:', error); + return NextResponse.json({ + success: false, + error: 'Внутренняя ошибка сервера' + }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { action, catalogId, lang = 'ru' } = body; + + switch (action) { + case 'preload': + // Предзагрузка данных в кэш + console.log('🔄 Предзагрузка данных PartsIndex в кэш...'); + + const catalogs = await partsIndexService.getCatalogs(lang); + console.log(`✅ Загружено ${catalogs.length} каталогов`); + + if (catalogId) { + // Загружаем группы конкретного каталога + const groups = await partsIndexService.getCatalogGroups(catalogId, lang); + console.log(`✅ Загружено ${groups.length} групп для каталога ${catalogId}`); + + return NextResponse.json({ + success: true, + data: { + catalogsCount: catalogs.length, + groupsCount: groups.length, + catalogId + } + }); + } else { + // Загружаем полную структуру + const categoriesWithGroups = await partsIndexService.getCategoriesWithGroups(lang); + const totalGroups = categoriesWithGroups.reduce((acc, cat) => acc + cat.groups.length, 0); + + return NextResponse.json({ + success: true, + data: { + catalogsCount: catalogs.length, + categoriesWithGroupsCount: categoriesWithGroups.length, + totalGroups + } + }); + } + + default: + return NextResponse.json({ + success: false, + error: 'Неизвестное действие. Доступные: preload' + }, { status: 400 }); + } + } catch (error) { + console.error('❌ Ошибка в debug-partsindex POST API:', error); + return NextResponse.json({ + success: false, + error: 'Внутренняя ошибка сервера' + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/dashboard/navigation/page.tsx b/src/app/dashboard/navigation/page.tsx new file mode 100644 index 0000000..7b8cf15 --- /dev/null +++ b/src/app/dashboard/navigation/page.tsx @@ -0,0 +1,11 @@ +"use client" + +import NavigationCategoryTree from '@/components/navigation/NavigationCategoryTree' + +export default function NavigationPage() { + return ( +
+ +
+ ) +} \ No newline at end of file diff --git a/src/components/catalog/CategoryForm.tsx b/src/components/catalog/CategoryForm.tsx index 853d269..fbe1f17 100644 --- a/src/components/catalog/CategoryForm.tsx +++ b/src/components/catalog/CategoryForm.tsx @@ -302,6 +302,8 @@ export const CategoryForm = ({ + + {/* Настройки */}

Настройки

diff --git a/src/components/navigation/NavigationCategoryForm.tsx b/src/components/navigation/NavigationCategoryForm.tsx new file mode 100644 index 0000000..012a93a --- /dev/null +++ b/src/components/navigation/NavigationCategoryForm.tsx @@ -0,0 +1,381 @@ +'use client' + +import { useState, useEffect } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { useQuery } from '@apollo/client' +import { GET_PARTSINDEX_CATEGORIES } from '@/lib/graphql/queries' +import { Loader2, Upload, X, Image as ImageIcon, Folder, FolderOpen } from 'lucide-react' + +interface PartsIndexCategory { + id: string + name: string + image?: string + groups?: PartsIndexGroup[] +} + +interface PartsIndexGroup { + id: string + name: string + image?: string + subgroups?: PartsIndexGroup[] +} + +interface NavigationCategory { + id?: string + partsIndexCatalogId: string + partsIndexGroupId?: string + icon?: string + isHidden: boolean + sortOrder: number +} + +interface NavigationCategoryFormProps { + category?: NavigationCategory | null + onSubmit: (data: any) => void + onCancel: () => void + isLoading?: boolean +} + +export default function NavigationCategoryForm({ + category, + onSubmit, + onCancel, + isLoading = false +}: NavigationCategoryFormProps) { + const [formData, setFormData] = useState({ + partsIndexCatalogId: '', + partsIndexGroupId: '', + icon: '', + isHidden: false, + sortOrder: 0 + }) + + const [selectedCatalog, setSelectedCatalog] = useState(null) + const [selectedGroup, setSelectedGroup] = useState(null) + + // Загрузка категорий PartsIndex + const { data: categoriesData, loading: categoriesLoading, error: categoriesError } = useQuery( + GET_PARTSINDEX_CATEGORIES, + { + variables: { lang: 'ru' }, + errorPolicy: 'all' + } + ) + + const categories = categoriesData?.partsIndexCategoriesWithGroups || [] + + // Заполнение формы при редактировании + useEffect(() => { + if (category) { + setFormData({ + partsIndexCatalogId: category.partsIndexCatalogId || '', + partsIndexGroupId: category.partsIndexGroupId || '', + icon: category.icon || '', + isHidden: category.isHidden || false, + sortOrder: category.sortOrder || 0 + }) + + // Находим выбранный каталог и группу + const catalog = categories.find(c => c.id === category.partsIndexCatalogId) + if (catalog) { + setSelectedCatalog(catalog) + + if (category.partsIndexGroupId && catalog.groups) { + const group = findGroupById(catalog.groups, category.partsIndexGroupId) + setSelectedGroup(group || null) + } + } + } + }, [category, categories]) + + // Рекурсивный поиск группы по ID + const findGroupById = (groups: PartsIndexGroup[], groupId: string): PartsIndexGroup | null => { + for (const group of groups) { + if (group.id === groupId) { + return group + } + if (group.subgroups && group.subgroups.length > 0) { + const found = findGroupById(group.subgroups, groupId) + if (found) return found + } + } + return null + } + + // Получение всех групп из каталога (включая подгруппы) + const getAllGroups = (groups: PartsIndexGroup[], level = 0): Array => { + const result: Array = [] + + groups.forEach(group => { + result.push({ ...group, level }) + if (group.subgroups && group.subgroups.length > 0) { + result.push(...getAllGroups(group.subgroups, level + 1)) + } + }) + + return result + } + + const handleInputChange = (field: string, value: any) => { + setFormData(prev => ({ ...prev, [field]: value })) + } + + const handleCatalogSelect = (catalogId: string) => { + const catalog = categories.find(c => c.id === catalogId) + setSelectedCatalog(catalog || null) + setSelectedGroup(null) + + handleInputChange('partsIndexCatalogId', catalogId) + handleInputChange('partsIndexGroupId', '') + } + + const handleGroupSelect = (groupId: string) => { + if (groupId === '__CATALOG_ROOT__') { + setSelectedGroup(null) + handleInputChange('partsIndexGroupId', '') + } else if (selectedCatalog?.groups) { + const group = findGroupById(selectedCatalog.groups, groupId) + setSelectedGroup(group || null) + handleInputChange('partsIndexGroupId', groupId) + } + } + + const handleImageUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + const reader = new FileReader() + reader.onload = (e) => { + const result = e.target?.result as string + handleInputChange('icon', result) + } + reader.readAsDataURL(file) + } + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + + if (!formData.partsIndexCatalogId) { + alert('Выберите каталог') + return + } + + onSubmit(formData) + } + + const getDisplayName = () => { + if (selectedGroup) { + return `${selectedCatalog?.name} → ${selectedGroup.name}` + } + return selectedCatalog?.name || 'Выберите категорию' + } + + if (categoriesLoading) { + return ( + + + + Загрузка категорий PartsIndex... + + + ) + } + + if (categoriesError) { + return ( + + +
+ Ошибка загрузки категорий PartsIndex: {categoriesError.message} +
+
+
+ ) + } + + return ( + + + + {category ? 'Редактировать иконку категории' : 'Добавить иконку для категории'} + +

+ Выберите категорию из PartsIndex и загрузите иконку для отображения в навигации сайта +

+
+ +
+ {/* Выбор каталога */} +
+ + +
+ + {/* Выбор группы (если есть группы в каталоге) */} + {selectedCatalog?.groups && selectedCatalog.groups.length > 0 && ( +
+ +

+ Оставьте пустым для добавления иконки всему каталогу +

+ +
+ )} + + {/* Предварительный просмотр выбранной категории */} + {formData.partsIndexCatalogId && ( +
+
+ + Выбранная категория: +
+
+ {getDisplayName()} +
+
+ )} + + {/* Загрузка иконки */} +
+ +

+ Небольшая иконка для отображения в навигационном меню (рекомендуется 32x32 пикселя) +

+
+ {formData.icon && ( +
+ Превью иконки + +
+ )} +
+ + +
+
+
+ + {/* Настройки отображения */} +
+
+ handleInputChange('isHidden', checked)} + /> + +
+ +
+ + handleInputChange('sortOrder', parseInt(e.target.value) || 0)} + className="mt-1" + placeholder="0" + /> +

+ Меньшее число = выше в списке +

+
+
+ + {/* Кнопки */} +
+ + +
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/navigation/NavigationCategoryTree.tsx b/src/components/navigation/NavigationCategoryTree.tsx new file mode 100644 index 0000000..6a09841 --- /dev/null +++ b/src/components/navigation/NavigationCategoryTree.tsx @@ -0,0 +1,377 @@ +'use client' + +import { useState } from 'react' +import { useQuery, useMutation } from '@apollo/client' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { + GET_NAVIGATION_CATEGORIES, + GET_PARTSINDEX_CATEGORIES +} from '@/lib/graphql/queries' +import { + CREATE_NAVIGATION_CATEGORY, + UPDATE_NAVIGATION_CATEGORY, + DELETE_NAVIGATION_CATEGORY +} from '@/lib/graphql/mutations' +import { Loader2, Plus, Edit, Trash2, Image as ImageIcon, Folder, Settings, Eye, EyeOff } from 'lucide-react' +import NavigationCategoryForm from './NavigationCategoryForm' + +interface NavigationCategory { + id: string + partsIndexCatalogId: string + partsIndexGroupId?: string + icon?: string + isHidden: boolean + sortOrder: number + name: string + catalogName: string + groupName?: string +} + +interface PartsIndexCategory { + id: string + name: string + image?: string + groups?: PartsIndexGroup[] +} + +interface PartsIndexGroup { + id: string + name: string + image?: string + subgroups?: PartsIndexGroup[] +} + +export default function NavigationCategoryTree() { + const [editingCategory, setEditingCategory] = useState(null) + const [showForm, setShowForm] = useState(false) + + // Загрузка навигационных категорий (с иконками) + const { + data: navigationData, + loading: navigationLoading, + error: navigationError, + refetch: refetchNavigation + } = useQuery(GET_NAVIGATION_CATEGORIES, { + errorPolicy: 'all' + }) + + // Загрузка категорий PartsIndex + const { + data: partsIndexData, + loading: partsIndexLoading, + error: partsIndexError + } = useQuery(GET_PARTSINDEX_CATEGORIES, { + variables: { lang: 'ru' }, + errorPolicy: 'all' + }) + + // Мутации + const [createCategory, { loading: creating }] = useMutation(CREATE_NAVIGATION_CATEGORY, { + onCompleted: () => { + refetchNavigation() + handleCloseForm() + }, + onError: (error) => { + console.error('Ошибка создания категории:', error) + alert('Не удалось создать иконку для категории') + } + }) + + const [updateCategory, { loading: updating }] = useMutation(UPDATE_NAVIGATION_CATEGORY, { + onCompleted: () => { + refetchNavigation() + handleCloseForm() + }, + onError: (error) => { + console.error('Ошибка обновления категории:', error) + alert('Не удалось обновить иконку категории') + } + }) + + const [deleteCategory, { loading: deleting }] = useMutation(DELETE_NAVIGATION_CATEGORY, { + onCompleted: () => { + refetchNavigation() + }, + onError: (error) => { + console.error('Ошибка удаления категории:', error) + alert('Не удалось удалить иконку категории') + } + }) + + const navigationCategories = navigationData?.navigationCategories || [] + const partsIndexCategories = partsIndexData?.partsIndexCategoriesWithGroups || [] + + const handleSubmit = async (formData: any) => { + try { + if (editingCategory) { + await updateCategory({ + variables: { + id: editingCategory.id, + input: formData + } + }) + } else { + await createCategory({ + variables: { + input: formData + } + }) + } + } catch (error) { + console.error('Ошибка сохранения:', error) + } + } + + const handleEdit = (category: NavigationCategory) => { + setEditingCategory(category) + setShowForm(true) + } + + const handleDelete = async (category: NavigationCategory) => { + if (confirm(`Удалить иконку для категории "${category.name}"?`)) { + await deleteCategory({ + variables: { id: category.id } + }) + } + } + + const handleCloseForm = () => { + setEditingCategory(null) + setShowForm(false) + } + + // Функция для получения полного пути категории + const getCategoryPath = (catalogId: string, groupId?: string) => { + const catalog = partsIndexCategories.find(c => c.id === catalogId) + if (!catalog) return 'Неизвестная категория' + + if (!groupId) return catalog.name + + // Рекурсивный поиск группы + const findGroup = (groups: PartsIndexGroup[]): PartsIndexGroup | null => { + for (const group of groups) { + if (group.id === groupId) return group + if (group.subgroups) { + const found = findGroup(group.subgroups) + if (found) return found + } + } + return null + } + + const group = catalog.groups ? findGroup(catalog.groups) : null + return group ? `${catalog.name} → ${group.name}` : catalog.name + } + + // Проверка есть ли иконка для категории + const hasIcon = (catalogId: string, groupId?: string) => { + return navigationCategories.some(nav => + nav.partsIndexCatalogId === catalogId && + nav.partsIndexGroupId === groupId + ) + } + + // Получение иконки для категории + const getIcon = (catalogId: string, groupId?: string) => { + return navigationCategories.find(nav => + nav.partsIndexCatalogId === catalogId && + nav.partsIndexGroupId === groupId + ) + } + + if (showForm) { + return ( + + ) + } + + if (navigationLoading || partsIndexLoading) { + return ( + + + + Загрузка категорий... + + + ) + } + + if (navigationError || partsIndexError) { + return ( + + + Ошибка загрузки категорий: {navigationError?.message || partsIndexError?.message} + + + ) + } + + return ( +
+ {/* Заголовок */} +
+
+

Иконки навигации

+

+ Привязка иконок к категориям PartsIndex для отображения в навигации сайта +

+
+ +
+ + {/* Статистика */} +
+ + +
+ +
+
Каталогов PartsIndex
+
{partsIndexCategories.length}
+
+
+
+
+ + + +
+ +
+
С иконками
+
{navigationCategories.length}
+
+
+
+
+ + + +
+ +
+
Активных
+
+ {navigationCategories.filter(cat => !cat.isHidden).length} +
+
+
+
+
+
+ + {/* Список категорий с иконками */} + {navigationCategories.length > 0 && ( + + + Категории с иконками + + +
+ {navigationCategories + .sort((a, b) => a.sortOrder - b.sortOrder) + .map((navCategory) => ( +
+
+ {/* Иконка */} +
+ {navCategory.icon ? ( + {navCategory.name} + ) : ( + + )} +
+ + {/* Информация */} +
+
{navCategory.name}
+
+ {getCategoryPath(navCategory.partsIndexCatalogId, navCategory.partsIndexGroupId)} +
+
+ + Сортировка: {navCategory.sortOrder} + + {navCategory.isHidden && ( + + + Скрыта + + )} + {!navCategory.isHidden && ( + + + Видима + + )} +
+
+
+ + {/* Действия */} +
+ + +
+
+ ))} +
+
+
+ )} + + {/* Инструкции */} + + + Как использовать + + +

+ 1. Выберите каталог: Из списка каталогов PartsIndex выберите тот, для которого хотите добавить иконку. +

+

+ 2. Выберите группу (необязательно): Если хотите добавить иконку для конкретной группы внутри каталога, выберите её. Иначе иконка будет применена ко всему каталогу. +

+

+ 3. Загрузите иконку: Выберите небольшое изображение (рекомендуется 32x32 пикселя) которое будет отображаться в навигации сайта. +

+

+ 4. Настройте отображение: Установите порядок сортировки и видимость категории в навигации. +

+
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index 34c9725..8cfc530 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -34,6 +34,11 @@ const navigationItems = [ href: '/dashboard/catalog', icon: Package, }, + { + title: 'Навигация сайта', + href: '/dashboard/navigation', + icon: Star, + }, { title: 'Товары главной', href: '/dashboard/homepage-products', diff --git a/src/lib/graphql/mutations.ts b/src/lib/graphql/mutations.ts index 01f637d..41d5cee 100644 --- a/src/lib/graphql/mutations.ts +++ b/src/lib/graphql/mutations.ts @@ -137,6 +137,7 @@ export const CREATE_CATEGORY = gql` seoTitle seoDescription image + icon isHidden includeSubcategoryProducts parentId @@ -160,6 +161,7 @@ export const UPDATE_CATEGORY = gql` seoTitle seoDescription image + icon isHidden includeSubcategoryProducts parentId @@ -179,6 +181,49 @@ export const DELETE_CATEGORY = gql` } ` +// Навигационные категории +export const CREATE_NAVIGATION_CATEGORY = gql` + mutation CreateNavigationCategory($input: NavigationCategoryInput!) { + createNavigationCategory(input: $input) { + id + partsIndexCatalogId + partsIndexGroupId + icon + isHidden + sortOrder + createdAt + updatedAt + name + catalogName + groupName + } + } +` + +export const UPDATE_NAVIGATION_CATEGORY = gql` + mutation UpdateNavigationCategory($id: ID!, $input: NavigationCategoryInput!) { + updateNavigationCategory(id: $id, input: $input) { + id + partsIndexCatalogId + partsIndexGroupId + icon + isHidden + sortOrder + createdAt + updatedAt + name + catalogName + groupName + } + } +` + +export const DELETE_NAVIGATION_CATEGORY = gql` + mutation DeleteNavigationCategory($id: ID!) { + deleteNavigationCategory(id: $id) + } +` + export const DELETE_PRODUCTS = gql` mutation DeleteProducts($ids: [ID!]!) { deleteProducts(ids: $ids) { diff --git a/src/lib/graphql/queries.ts b/src/lib/graphql/queries.ts index 84cb795..0e8a74c 100644 --- a/src/lib/graphql/queries.ts +++ b/src/lib/graphql/queries.ts @@ -1401,4 +1401,70 @@ export const GET_PAYMENT = gql` } ` +// Навигационные категории +export const GET_NAVIGATION_CATEGORIES = gql` + query GetNavigationCategories { + navigationCategories { + id + partsIndexCatalogId + partsIndexGroupId + icon + isHidden + sortOrder + createdAt + updatedAt + name + catalogName + groupName + } + } +`; + +export const GET_NAVIGATION_CATEGORY = gql` + query GetNavigationCategory($id: ID!) { + navigationCategory(id: $id) { + id + partsIndexCatalogId + partsIndexGroupId + icon + isHidden + sortOrder + createdAt + updatedAt + name + catalogName + groupName + } + } +`; + +// PartsIndex категории автотоваров +export const GET_PARTSINDEX_CATEGORIES = gql` + query GetPartsIndexCategories($lang: String) { + partsIndexCategoriesWithGroups(lang: $lang) { + id + name + image + groups { + id + name + image + subgroups { + id + name + image + entityNames { + id + name + } + } + entityNames { + id + name + } + } + } + } +`; + \ No newline at end of file diff --git a/src/lib/graphql/resolvers.ts b/src/lib/graphql/resolvers.ts index 7ef2fc2..aeb01d6 100644 --- a/src/lib/graphql/resolvers.ts +++ b/src/lib/graphql/resolvers.ts @@ -132,6 +132,15 @@ interface CategoryInput { parentId?: string } +// Интерфейсы для навигационных категорий +interface NavigationCategoryInput { + partsIndexCatalogId: string + partsIndexGroupId?: string + icon?: string + isHidden?: boolean + sortOrder?: number +} + interface ProductInput { name: string slug?: string @@ -1870,6 +1879,90 @@ export const resolvers = { } }, + // Навигационные категории + navigationCategories: async () => { + try { + const categories = await prisma.navigationCategory.findMany({ + where: { isHidden: false }, + orderBy: { sortOrder: 'asc' } + }) + + // Получаем данные из PartsIndex для каждой категории + const categoriesWithData = await Promise.all( + categories.map(async (category) => { + try { + // Получаем каталоги PartsIndex + const catalogs = await partsIndexService.getCatalogs('ru') + const catalog = catalogs.find(c => c.id === category.partsIndexCatalogId) + + let groupName = null + + // Если есть groupId, получаем группы + if (category.partsIndexGroupId && catalog) { + const groups = await partsIndexService.getCatalogGroups(category.partsIndexCatalogId, 'ru') + const group = groups.find(g => g.id === category.partsIndexGroupId) + groupName = group?.name || null + } + + return { + ...category, + name: groupName || catalog?.name || 'Неизвестная категория', + catalogName: catalog?.name || 'Неизвестный каталог', + groupName + } + } catch (error) { + console.error('Ошибка получения данных PartsIndex для категории:', category.id, error) + return { + ...category, + name: 'Ошибка загрузки', + catalogName: 'Ошибка загрузки', + groupName: null + } + } + }) + ) + + return categoriesWithData + } catch (error) { + console.error('Ошибка получения навигационных категорий:', error) + return [] + } + }, + + navigationCategory: async (_: unknown, { id }: { id: string }) => { + try { + const category = await prisma.navigationCategory.findUnique({ + where: { id } + }) + + if (!category) { + throw new Error('Навигационная категория не найдена') + } + + // Получаем данные из PartsIndex + const catalogs = await partsIndexService.getCatalogs('ru') + const catalog = catalogs.find(c => c.id === category.partsIndexCatalogId) + + let groupName = null + + if (category.partsIndexGroupId && catalog) { + const groups = await partsIndexService.getCatalogGroups(category.partsIndexCatalogId, 'ru') + const group = groups.find(g => g.id === category.partsIndexGroupId) + groupName = group?.name || null + } + + return { + ...category, + name: groupName || catalog?.name || 'Неизвестная категория', + catalogName: catalog?.name || 'Неизвестный каталог', + groupName + } + } catch (error) { + console.error('Ошибка получения навигационной категории:', error) + throw error + } + }, + laximoUnits: async (_: unknown, { catalogCode, vehicleId, ssd, categoryId }: { catalogCode: string; vehicleId?: string; ssd?: string; categoryId?: string }) => { try { console.log('🔍 GraphQL Resolver - запрос узлов каталога:', { @@ -3080,6 +3173,67 @@ export const resolvers = { } }, + // Получить параметры каталога PartsIndex для фильтрации + partsIndexCatalogParams: async (_: unknown, { + catalogId, + groupId, + lang = 'ru', + engineId, + generationId, + params, + q + }: { + catalogId: string; + groupId: string; + lang?: 'ru' | 'en'; + engineId?: string; + generationId?: string; + params?: string; + q?: string; + }) => { + try { + console.log('🔍 GraphQL resolver partsIndexCatalogParams вызван с параметрами:', { + catalogId, + groupId, + lang, + q + }) + + // Преобразуем строку params в объект если передан + let parsedParams: Record | undefined; + if (params) { + try { + parsedParams = JSON.parse(params); + } catch (error) { + console.warn('⚠️ Не удалось разобрать параметры фильтрации:', params); + } + } + + const paramsData = await partsIndexService.getCatalogParams(catalogId, groupId, { + lang, + engineId, + generationId, + params: parsedParams, + q + }) + + if (!paramsData) { + console.warn('⚠️ Не удалось получить параметры каталога') + return { + list: [], + paramsQuery: {} + } + } + + console.log('✅ Получены параметры каталога:', paramsData.list.length) + + return paramsData + } catch (error) { + console.error('❌ Ошибка в GraphQL resolver partsIndexCatalogParams:', error) + throw new Error('Не удалось получить параметры каталога') + } + }, + // PartsAPI артикулы partsAPIArticles: async (_: unknown, { strId, carId, carType = 'PC' }: { strId: number; carId: number; carType?: 'PC' | 'CV' | 'Motorcycle' }) => { try { @@ -4442,6 +4596,223 @@ export const resolvers = { } }, + // Навигационные категории + createNavigationCategory: async (_: unknown, { input }: { input: NavigationCategoryInput }, context: Context) => { + try { + if (!context.userId) { + throw new Error('Пользователь не авторизован') + } + + // Проверяем что такой комбинации еще нет + const existing = await prisma.navigationCategory.findFirst({ + where: { + partsIndexCatalogId: input.partsIndexCatalogId, + partsIndexGroupId: input.partsIndexGroupId ?? null + } + }) + + if (existing) { + throw new Error('Иконка для этой категории уже существует') + } + + // Загружаем иконку в S3 если есть + let iconUrl: string | null = null + if (input.icon) { + try { + const iconData = input.icon.replace(/^data:image\/[a-z]+;base64,/, '') + const buffer = Buffer.from(iconData, 'base64') + + const fileKey = generateFileKey('navigation-icons', 'png') + const uploadResult = await uploadBuffer(buffer, fileKey, 'image/png') + iconUrl = uploadResult.url + } catch (error) { + console.error('Ошибка загрузки иконки:', error) + throw new Error('Не удалось загрузить иконку') + } + } + + const category = await prisma.navigationCategory.create({ + data: { + partsIndexCatalogId: input.partsIndexCatalogId, + partsIndexGroupId: input.partsIndexGroupId ?? null, + icon: iconUrl, + isHidden: input.isHidden || false, + sortOrder: input.sortOrder || 0 + } + }) + + // Получаем данные из PartsIndex для ответа + const catalogs = await partsIndexService.getCatalogs('ru') + const catalog = catalogs.find(c => c.id === category.partsIndexCatalogId) + + let groupName = null + if (category.partsIndexGroupId && catalog) { + const groups = await partsIndexService.getCatalogGroups(category.partsIndexCatalogId, 'ru') + const group = groups.find(g => g.id === category.partsIndexGroupId) + groupName = group?.name || null + } + + const result = { + ...category, + name: groupName || catalog?.name || 'Неизвестная категория', + catalogName: catalog?.name || 'Неизвестный каталог', + groupName + } + + // Логируем действие + if (context.headers) { + const { ipAddress, userAgent } = getClientInfo(context.headers) + await createAuditLog({ + userId: context.userId, + action: AuditAction.CATEGORY_CREATE, + details: `Навигационная категория: ${result.name}`, + ipAddress, + userAgent + }) + } + + return result + } catch (error) { + console.error('Ошибка создания навигационной категории:', error) + throw error + } + }, + + updateNavigationCategory: async (_: unknown, { id, input }: { id: string; input: NavigationCategoryInput }, context: Context) => { + try { + if (!context.userId) { + throw new Error('Пользователь не авторизован') + } + + const existingCategory = await prisma.navigationCategory.findUnique({ + where: { id } + }) + + if (!existingCategory) { + throw new Error('Навигационная категория не найдена') + } + + // Проверяем уникальность если изменяются partsIndex поля + if (input.partsIndexCatalogId || input.partsIndexGroupId !== undefined) { + const catalogId = input.partsIndexCatalogId || existingCategory.partsIndexCatalogId + const groupId = input.partsIndexGroupId !== undefined ? input.partsIndexGroupId : existingCategory.partsIndexGroupId + + const conflicting = await prisma.navigationCategory.findFirst({ + where: { + partsIndexCatalogId: catalogId, + partsIndexGroupId: groupId + } + }) + + if (conflicting && conflicting.id !== id) { + throw new Error('Иконка для этой категории уже существует') + } + } + + // Загружаем новую иконку если есть + let iconUrl = existingCategory.icon + if (input.icon) { + try { + const iconData = input.icon.replace(/^data:image\/[a-z]+;base64,/, '') + const buffer = Buffer.from(iconData, 'base64') + + const fileKey = generateFileKey('navigation-icons', 'png') + const uploadResult = await uploadBuffer(buffer, fileKey, 'image/png') + iconUrl = uploadResult.url + } catch (error) { + console.error('Ошибка загрузки иконки:', error) + throw new Error('Не удалось загрузить иконку') + } + } + + const category = await prisma.navigationCategory.update({ + where: { id }, + data: { + partsIndexCatalogId: input.partsIndexCatalogId || existingCategory.partsIndexCatalogId, + partsIndexGroupId: input.partsIndexGroupId !== undefined ? (input.partsIndexGroupId ?? null) : existingCategory.partsIndexGroupId, + icon: iconUrl, + isHidden: input.isHidden !== undefined ? input.isHidden : existingCategory.isHidden, + sortOrder: input.sortOrder !== undefined ? input.sortOrder : existingCategory.sortOrder + } + }) + + // Получаем данные из PartsIndex для ответа + const catalogs = await partsIndexService.getCatalogs('ru') + const catalog = catalogs.find(c => c.id === category.partsIndexCatalogId) + + let groupName = null + if (category.partsIndexGroupId && catalog) { + const groups = await partsIndexService.getCatalogGroups(category.partsIndexCatalogId, 'ru') + const group = groups.find(g => g.id === category.partsIndexGroupId) + groupName = group?.name || null + } + + const result = { + ...category, + name: groupName || catalog?.name || 'Неизвестная категория', + catalogName: catalog?.name || 'Неизвестный каталог', + groupName + } + + // Логируем действие + if (context.headers) { + const { ipAddress, userAgent } = getClientInfo(context.headers) + await createAuditLog({ + userId: context.userId, + action: AuditAction.CATEGORY_UPDATE, + details: `Навигационная категория: ${result.name}`, + ipAddress, + userAgent + }) + } + + return result + } catch (error) { + console.error('Ошибка обновления навигационной категории:', error) + throw error + } + }, + + deleteNavigationCategory: async (_: unknown, { id }: { id: string }, context: Context) => { + try { + if (!context.userId) { + throw new Error('Пользователь не авторизован') + } + + const category = await prisma.navigationCategory.findUnique({ + where: { id } + }) + + if (!category) { + throw new Error('Навигационная категория не найдена') + } + + await prisma.navigationCategory.delete({ + where: { id } + }) + + // Логируем действие + if (context.headers) { + const { ipAddress, userAgent } = getClientInfo(context.headers) + await createAuditLog({ + userId: context.userId, + action: AuditAction.CATEGORY_DELETE, + details: `Навигационная категория ID: ${category.id}`, + ipAddress, + userAgent + }) + } + + return true + } catch (error) { + console.error('Ошибка удаления навигационной категории:', error) + if (error instanceof Error) { + throw error + } + throw new Error('Не удалось удалить навигационную категорию') + } + }, + // Товары createProduct: async (_: unknown, { input, diff --git a/src/lib/graphql/typeDefs.ts b/src/lib/graphql/typeDefs.ts index 35c7e91..ab1bcbd 100644 --- a/src/lib/graphql/typeDefs.ts +++ b/src/lib/graphql/typeDefs.ts @@ -73,6 +73,22 @@ export const typeDefs = gql` updatedAt: DateTime! } + type NavigationCategory { + id: ID! + partsIndexCatalogId: String! + partsIndexGroupId: String + icon: String + isHidden: Boolean! + sortOrder: Int! + createdAt: DateTime! + updatedAt: DateTime! + + # Виртуальные поля - получаем данные из PartsIndex API + name: String! + catalogName: String! + groupName: String + } + type Product { id: ID! name: String! @@ -600,6 +616,14 @@ export const typeDefs = gql` parentId: String } + input NavigationCategoryInput { + partsIndexCatalogId: String! + partsIndexGroupId: String + icon: String + isHidden: Boolean + sortOrder: Int + } + input ProductInput { name: String! slug: String @@ -924,6 +948,10 @@ export const typeDefs = gql` laximoQuickGroups(catalogCode: String!, vehicleId: String, ssd: String): [LaximoQuickGroup!]! laximoQuickGroupsWithXML(catalogCode: String!, vehicleId: String, ssd: String): LaximoQuickGroupsResponse! laximoCategories(catalogCode: String!, vehicleId: String, ssd: String): [LaximoQuickGroup!]! + + # Навигационные категории + navigationCategories: [NavigationCategory!]! + navigationCategory(id: ID!): NavigationCategory laximoUnits(catalogCode: String!, vehicleId: String, ssd: String, categoryId: String): [LaximoQuickGroup!]! laximoQuickDetail(catalogCode: String!, vehicleId: String!, quickGroupId: String!, ssd: String!): LaximoQuickDetail laximoOEMSearch(catalogCode: String!, vehicleId: String!, oemNumber: String!, ssd: String!): LaximoOEMResult @@ -1088,6 +1116,11 @@ export const typeDefs = gql` updateCategory(id: ID!, input: CategoryInput!): Category! deleteCategory(id: ID!): Boolean! + # Навигационные категории + createNavigationCategory(input: NavigationCategoryInput!): NavigationCategory! + updateNavigationCategory(id: ID!, input: NavigationCategoryInput!): NavigationCategory! + deleteNavigationCategory(id: ID!): Boolean! + # Товары createProduct(input: ProductInput!, images: [ProductImageInput!], characteristics: [CharacteristicInput!], options: [ProductOptionInput!]): Product! updateProduct(id: ID!, input: ProductInput!, images: [ProductImageInput!], characteristics: [CharacteristicInput!], options: [ProductOptionInput!]): Product! diff --git a/src/lib/partsindex-service.ts b/src/lib/partsindex-service.ts index 502f905..63168f6 100644 --- a/src/lib/partsindex-service.ts +++ b/src/lib/partsindex-service.ts @@ -1,9 +1,10 @@ import axios from 'axios'; +// Интерфейсы для типизации данных Parts Index API export interface PartsIndexCatalog { id: string; name: string; - image: string | null; + image: string; } export interface PartsIndexGroup { @@ -14,10 +15,6 @@ export interface PartsIndexGroup { entityNames: { id: string; name: string; }[]; } -export interface PartsIndexCatalogsResponse { - list: PartsIndexCatalog[]; -} - export interface PartsIndexGroupResponse { id: string; name: string; @@ -29,35 +26,30 @@ export interface PartsIndexGroupResponse { subgroups: PartsIndexGroup[]; } -// Новые интерфейсы для товаров каталога -export interface PartsIndexParameter { - id: string; - code: string; - title: string; - type: string; - values: Array<{ - id: string; - value: string; - }>; -} - -export interface PartsIndexBrand { - id: string; - name: string; -} - -export interface PartsIndexProductName { - id: string; - name: string; -} - export interface PartsIndexEntity { id: string; - name: PartsIndexProductName; - originalName: string; code: string; - brand: PartsIndexBrand; - parameters: PartsIndexParameter[]; + name: { + id: string; + name: string; + }; + originalName: string; + brand: { + id: string; + name: string; + }; + barcodes: string[]; + parameters: { + id: string; + title: string; + code: string; + type: string; + values: { + id: string; + value: string; + title?: string; + }[]; + }[]; images: string[]; } @@ -74,6 +66,8 @@ export interface PartsIndexEntitiesResponse { catalog: { id: string; name: string; + image: string; + groups: PartsIndexGroup[]; }; subgroup: { id: string; @@ -82,24 +76,22 @@ export interface PartsIndexEntitiesResponse { } export interface PartsIndexParamsResponse { - list: PartsIndexParam[]; - paramsQuery: Record; + list: { + id: string; + name: string; + code: string; + type: 'range' | 'dropdown'; + values: { + id: string; + value: string; + title?: string; + available: boolean; + }[]; + }[]; } -export interface PartsIndexParam { - id: string; - code: string; - name: string; - isGeneral: boolean; - type: 'select' | 'range'; - values: PartsIndexParamValue[]; -} - -export interface PartsIndexParamValue { - value: string; - title: string; - available: boolean; - selected: boolean; +export interface PartsIndexEntityInfoResponse { + list: PartsIndexEntityDetail[]; } export interface PartsIndexEntityDetail { @@ -155,20 +147,99 @@ export interface PartsIndexEntityDetail { }[]; } +// Интерфейс для кэша +interface CacheEntry { + data: T; + timestamp: number; + ttl: number; // время жизни в миллисекундах +} + class PartsIndexService { private baseURL = 'https://api.parts-index.com/v1'; - private apiKey: string; + private apiKey = 'PI-E1C0ADB7-E4A8-4960-94A0-4D9C0A074DAE'; + + // Простой in-memory кэш + private cache = new Map>(); + private readonly DEFAULT_TTL = 30 * 60 * 1000; // 30 минут + private readonly CATALOGS_TTL = 24 * 60 * 60 * 1000; // 24 часа для каталогов + private readonly GROUPS_TTL = 24 * 60 * 60 * 1000; // 24 часа для групп + private readonly ENTITIES_TTL = 10 * 60 * 1000; // 10 минут для товаров + private readonly PARAMS_TTL = 60 * 60 * 1000; // 1 час для параметров - constructor() { - this.apiKey = process.env.PARTSINDEX_API_KEY || ''; - - if (!this.apiKey) { - console.error('❌ PartsIndex API ключ не найден в переменных окружения'); + // Проверяем актуальность кэша + private isValidCacheEntry(entry: CacheEntry): boolean { + return Date.now() - entry.timestamp < entry.ttl; + } + + // Получаем данные из кэша + private getFromCache(key: string): T | null { + const entry = this.cache.get(key); + if (entry && this.isValidCacheEntry(entry)) { + console.log(`🔥 Используем кэш для ключа: ${key}`); + return entry.data; } + if (entry) { + console.log(`🗑️ Удаляем устаревший кэш для ключа: ${key}`); + this.cache.delete(key); + } + return null; + } + + // Сохраняем данные в кэш + private setCache(key: string, data: T, ttl: number = this.DEFAULT_TTL): void { + this.cache.set(key, { + data, + timestamp: Date.now(), + ttl + }); + console.log(`💾 Сохранено в кэш: ${key} (TTL: ${ttl}ms)`); + } + + // Очистка кэша (для административных целей) + public clearCache(): void { + this.cache.clear(); + console.log('🗑️ Кэш PartsIndex полностью очищен'); + } + + // Очистка конкретного типа кэша + public clearCacheByPrefix(prefix: string): void { + const keysToDelete: string[] = []; + this.cache.forEach((_, key) => { + if (key.startsWith(prefix)) { + keysToDelete.push(key); + } + }); + + keysToDelete.forEach(key => this.cache.delete(key)); + console.log(`🗑️ Очищен кэш PartsIndex с префиксом: ${prefix} (${keysToDelete.length} записей)`); + } + + // Статистика кэша + public getCacheStats(): { size: number; entries: { key: string; size: number; ttl: number; age: number }[] } { + const entries: { key: string; size: number; ttl: number; age: number }[] = []; + + this.cache.forEach((entry, key) => { + const size = JSON.stringify(entry.data).length; + const age = Date.now() - entry.timestamp; + entries.push({ key, size, ttl: entry.ttl, age }); + }); + + return { + size: this.cache.size, + entries: entries.sort((a, b) => b.size - a.size) // Сортируем по размеру + }; } // Получить список каталогов async getCatalogs(lang: 'ru' | 'en' = 'ru'): Promise { + const cacheKey = `catalogs_${lang}`; + + // Проверяем кэш + const cached = this.getFromCache(cacheKey); + if (cached) { + return cached; + } + try { console.log('🔍 PartsIndex запрос каталогов:', { lang }); @@ -187,7 +258,11 @@ class PartsIndexService { return []; } - return response.data.list; + const catalogs = response.data.list; + // Сохраняем в кэш на 1 час + this.setCache(cacheKey, catalogs, this.CATALOGS_TTL); + + return catalogs; } catch (error) { console.error('❌ Ошибка запроса PartsIndex getCatalogs:', error); return []; @@ -196,6 +271,14 @@ class PartsIndexService { // Получить группы каталога async getCatalogGroups(catalogId: string, lang: 'ru' | 'en' = 'ru'): Promise { + const cacheKey = `groups_${catalogId}_${lang}`; + + // Проверяем кэш + const cached = this.getFromCache(cacheKey); + if (cached) { + return cached; + } + try { console.log('🔍 PartsIndex запрос групп каталога:', { catalogId, lang }); @@ -218,21 +301,28 @@ class PartsIndexService { return []; } + let groups: PartsIndexGroup[]; + // Если есть подгруппы, возвращаем их if (groupData.subgroups.length > 0) { console.log('📁 Найдено подгрупп:', groupData.subgroups.length); - return groupData.subgroups; + groups = groupData.subgroups; + } else { + // Если подгрупп нет, создаем группу из самого каталога + console.log('📝 Подгрупп нет, возвращаем главную группу'); + groups = [{ + id: groupData.id, + name: groupData.name, + image: groupData.image, + subgroups: [], + entityNames: groupData.entityNames + }]; } - // Если подгрупп нет, создаем группу из самого каталога - console.log('📝 Подгрупп нет, возвращаем главную группу'); - return [{ - id: groupData.id, - name: groupData.name, - image: groupData.image, - subgroups: [], - entityNames: groupData.entityNames - }]; + // Сохраняем в кэш на 24 часа + this.setCache(cacheKey, groups, this.GROUPS_TTL); + + return groups; } catch (error) { console.error('❌ Ошибка запроса PartsIndex getCatalogGroups:', error); return []; @@ -253,17 +343,26 @@ class PartsIndexService { params?: Record; } = {} ): Promise { - try { - const { - lang = 'ru', - limit = 25, - page = 1, - q, - engineId, - generationId, - params - } = options; + const { + lang = 'ru', + limit = 25, + page = 1, + q, + engineId, + generationId, + params + } = options; + // Создаем ключ кэша на основе всех параметров + const cacheKey = `entities_${catalogId}_${groupId}_${lang}_${limit}_${page}_${q || 'no-query'}_${engineId || 'no-engine'}_${generationId || 'no-generation'}_${JSON.stringify(params || {})}`; + + // Проверяем кэш (кэшируем товары на короткое время) + const cached = this.getFromCache(cacheKey); + if (cached) { + return cached; + } + + try { console.log('🔍 PartsIndex запрос товаров каталога:', { catalogId, groupId, @@ -316,7 +415,11 @@ class PartsIndexService { return null; } - return response.data; + const result = response.data; + // Сохраняем в кэш на 10 минут (товары могут изменяться) + this.setCache(cacheKey, result, this.ENTITIES_TTL); + + return result; } catch (error) { console.error('❌ Ошибка запроса PartsIndex getCatalogEntities:', error); return null; @@ -335,15 +438,24 @@ class PartsIndexService { q?: string; } = {} ): Promise { - try { - const { - lang = 'ru', - engineId, - generationId, - params, - q - } = options; + const { + lang = 'ru', + engineId, + generationId, + params, + q + } = options; + // Создаем ключ кэша на основе всех параметров + const cacheKey = `params_${catalogId}_${groupId}_${lang}_${q || 'no-query'}_${engineId || 'no-engine'}_${generationId || 'no-generation'}_${JSON.stringify(params || {})}`; + + // Проверяем кэш + const cached = this.getFromCache(cacheKey); + if (cached) { + return cached; + } + + try { console.log('🔍 PartsIndex запрос параметров каталога:', { catalogId, groupId, @@ -392,15 +504,27 @@ class PartsIndexService { return null; } - return response.data; + const result = response.data; + // Сохраняем в кэш на 1 час + this.setCache(cacheKey, result, this.PARAMS_TTL); + + return result; } catch (error) { console.error('❌ Ошибка запроса PartsIndex getCatalogParams:', error); return null; } } - // Получить полную структуру категорий с подкатегориями + // Получить полную структуру категорий с подкатегориями (оптимизированная версия) async getCategoriesWithGroups(lang: 'ru' | 'en' = 'ru'): Promise> { + const cacheKey = `categories_with_groups_${lang}`; + + // Проверяем кэш + const cached = this.getFromCache>(cacheKey); + if (cached) { + return cached; + } + try { console.log('🔍 PartsIndex запрос полной структуры категорий'); @@ -413,17 +537,43 @@ class PartsIndexService { } // Для каждого каталога получаем его группы - const catalogsWithGroups = await Promise.all( - catalogs.map(async (catalog) => { - const groups = await this.getCatalogGroups(catalog.id, lang); - return { - ...catalog, - groups - }; - }) - ); + // Ограничиваем количество одновременных запросов + const BATCH_SIZE = 3; + const catalogsWithGroups: Array = []; - console.log('✅ PartsIndex полная структуря получена:', catalogsWithGroups.length, 'каталогов'); + for (let i = 0; i < catalogs.length; i += BATCH_SIZE) { + const batch = catalogs.slice(i, i + BATCH_SIZE); + + const batchResults = await Promise.all( + batch.map(async (catalog) => { + try { + const groups = await this.getCatalogGroups(catalog.id, lang); + return { + ...catalog, + groups + }; + } catch (error) { + console.error(`❌ Ошибка загрузки групп для каталога ${catalog.id}:`, error); + return { + ...catalog, + groups: [] + }; + } + }) + ); + + catalogsWithGroups.push(...batchResults); + + // Небольшая задержка между батчами для снижения нагрузки на API + if (i + BATCH_SIZE < catalogs.length) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + } + + console.log('✅ PartsIndex полная структура получена:', catalogsWithGroups.length, 'каталогов'); + + // Сохраняем в кэш на 24 часа + this.setCache(cacheKey, catalogsWithGroups, this.CATALOGS_TTL); return catalogsWithGroups; } catch (error) {