Добавлены новые модели и мутации для навигационных категорий, включая создание, обновление и удаление. Обновлены типы GraphQL и резолверы для обработки навигационных категорий, что улучшает структуру данных и функциональность. В боковое меню добавлен новый элемент для навигации по категориям. Реализован кэш для оптимизации запросов к API, что повышает производительность приложения.

This commit is contained in:
Bivekich
2025-07-13 21:42:04 +03:00
parent db29525da5
commit ac122411e0
12 changed files with 1681 additions and 95 deletions

View File

@ -59,6 +59,28 @@ model Category {
@@map("categories") @@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 { model Product {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String

View File

@ -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 });
}
}

View File

@ -0,0 +1,11 @@
"use client"
import NavigationCategoryTree from '@/components/navigation/NavigationCategoryTree'
export default function NavigationPage() {
return (
<div className="container mx-auto py-6 px-4">
<NavigationCategoryTree />
</div>
)
}

View File

@ -302,6 +302,8 @@ export const CategoryForm = ({
</div> </div>
</div> </div>
{/* Настройки */} {/* Настройки */}
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-sm font-medium">Настройки</h3> <h3 className="text-sm font-medium">Настройки</h3>

View File

@ -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<PartsIndexCategory | null>(null)
const [selectedGroup, setSelectedGroup] = useState<PartsIndexGroup | null>(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<PartsIndexGroup & { level: number }> => {
const result: Array<PartsIndexGroup & { level: number }> = []
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<HTMLInputElement>) => {
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 (
<Card>
<CardContent className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
<span className="ml-2">Загрузка категорий PartsIndex...</span>
</CardContent>
</Card>
)
}
if (categoriesError) {
return (
<Card>
<CardContent className="py-8">
<div className="text-center text-red-600">
Ошибка загрузки категорий PartsIndex: {categoriesError.message}
</div>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle>
{category ? 'Редактировать иконку категории' : 'Добавить иконку для категории'}
</CardTitle>
<p className="text-sm text-gray-600">
Выберите категорию из PartsIndex и загрузите иконку для отображения в навигации сайта
</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Выбор каталога */}
<div>
<Label htmlFor="catalog">Каталог PartsIndex</Label>
<Select value={formData.partsIndexCatalogId} onValueChange={handleCatalogSelect}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Выберите каталог из PartsIndex" />
</SelectTrigger>
<SelectContent>
{categories.map((catalog) => (
<SelectItem key={catalog.id} value={catalog.id}>
<div className="flex items-center gap-2">
<Folder className="h-4 w-4 text-blue-600" />
{catalog.name}
{catalog.groups && (
<Badge variant="secondary" className="ml-2">
{catalog.groups.length} групп
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Выбор группы (если есть группы в каталоге) */}
{selectedCatalog?.groups && selectedCatalog.groups.length > 0 && (
<div>
<Label htmlFor="group">Группа (необязательно)</Label>
<p className="text-xs text-gray-500 mb-2">
Оставьте пустым для добавления иконки всему каталогу
</p>
<Select value={formData.partsIndexGroupId || '__CATALOG_ROOT__'} onValueChange={handleGroupSelect}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Выберите группу или оставьте пустым" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__CATALOG_ROOT__">
<div className="flex items-center gap-2">
<FolderOpen className="h-4 w-4 text-gray-400" />
Весь каталог &quot;{selectedCatalog.name}&quot;
</div>
</SelectItem>
{getAllGroups(selectedCatalog.groups).map((group) => (
<SelectItem key={group.id} value={group.id}>
<div className="flex items-center gap-2" style={{ paddingLeft: `${group.level * 16}px` }}>
<Folder className="h-4 w-4 text-orange-600" />
{group.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Предварительный просмотр выбранной категории */}
{formData.partsIndexCatalogId && (
<div className="p-4 bg-gray-50 rounded-lg">
<div className="flex items-center gap-2 text-sm font-medium">
<ImageIcon className="h-4 w-4 text-blue-600" />
Выбранная категория:
</div>
<div className="mt-1 text-sm text-gray-600">
{getDisplayName()}
</div>
</div>
)}
{/* Загрузка иконки */}
<div>
<Label htmlFor="icon">Иконка категории</Label>
<p className="text-xs text-gray-500 mb-2">
Небольшая иконка для отображения в навигационном меню (рекомендуется 32x32 пикселя)
</p>
<div className="space-y-2">
{formData.icon && (
<div className="relative inline-block">
<img
src={formData.icon}
alt="Превью иконки"
className="w-16 h-16 object-cover rounded-lg border"
/>
<Button
type="button"
variant="destructive"
size="sm"
className="absolute -top-2 -right-2 h-6 w-6 p-0"
onClick={() => handleInputChange('icon', '')}
>
<X className="h-3 w-3" />
</Button>
</div>
)}
<div className="flex items-center gap-2">
<Input
type="file"
accept="image/*"
onChange={handleImageUpload}
className="hidden"
id="icon-upload"
/>
<Button
type="button"
variant="outline"
onClick={() => document.getElementById('icon-upload')?.click()}
>
<Upload className="h-4 w-4 mr-2" />
Загрузить иконку
</Button>
</div>
</div>
</div>
{/* Настройки отображения */}
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Switch
id="isHidden"
checked={formData.isHidden}
onCheckedChange={(checked) => handleInputChange('isHidden', checked)}
/>
<Label htmlFor="isHidden" className="text-sm font-medium">
Скрыть категорию в навигации
</Label>
</div>
<div>
<Label htmlFor="sortOrder">Порядок сортировки</Label>
<Input
type="number"
id="sortOrder"
value={formData.sortOrder}
onChange={(e) => handleInputChange('sortOrder', parseInt(e.target.value) || 0)}
className="mt-1"
placeholder="0"
/>
<p className="text-xs text-gray-500 mt-1">
Меньшее число = выше в списке
</p>
</div>
</div>
{/* Кнопки */}
<div className="flex gap-2 pt-4">
<Button
type="submit"
disabled={isLoading || !formData.partsIndexCatalogId}
className="flex-1"
>
{isLoading && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
{category ? 'Сохранить изменения' : 'Добавить иконку'}
</Button>
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isLoading}
>
Отмена
</Button>
</div>
</form>
</CardContent>
</Card>
)
}

View File

@ -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<NavigationCategory | null>(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 (
<NavigationCategoryForm
category={editingCategory}
onSubmit={handleSubmit}
onCancel={handleCloseForm}
isLoading={creating || updating}
/>
)
}
if (navigationLoading || partsIndexLoading) {
return (
<Card>
<CardContent className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
<span className="ml-2">Загрузка категорий...</span>
</CardContent>
</Card>
)
}
if (navigationError || partsIndexError) {
return (
<Alert variant="destructive">
<AlertDescription>
Ошибка загрузки категорий: {navigationError?.message || partsIndexError?.message}
</AlertDescription>
</Alert>
)
}
return (
<div className="space-y-6">
{/* Заголовок */}
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold">Иконки навигации</h2>
<p className="text-gray-600">
Привязка иконок к категориям PartsIndex для отображения в навигации сайта
</p>
</div>
<Button onClick={() => setShowForm(true)}>
<Plus className="h-4 w-4 mr-2" />
Добавить иконку
</Button>
</div>
{/* Статистика */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<Folder className="h-5 w-5 text-blue-600" />
<div>
<div className="text-sm text-gray-600">Каталогов PartsIndex</div>
<div className="text-2xl font-bold">{partsIndexCategories.length}</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<ImageIcon className="h-5 w-5 text-green-600" />
<div>
<div className="text-sm text-gray-600">С иконками</div>
<div className="text-2xl font-bold">{navigationCategories.length}</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-orange-600" />
<div>
<div className="text-sm text-gray-600">Активных</div>
<div className="text-2xl font-bold">
{navigationCategories.filter(cat => !cat.isHidden).length}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Список категорий с иконками */}
{navigationCategories.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Категории с иконками</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{navigationCategories
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((navCategory) => (
<div
key={navCategory.id}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50"
>
<div className="flex items-center gap-3">
{/* Иконка */}
<div className="w-12 h-12 border rounded-lg flex items-center justify-center overflow-hidden">
{navCategory.icon ? (
<img
src={navCategory.icon}
alt={navCategory.name}
className="w-full h-full object-cover"
/>
) : (
<ImageIcon className="h-6 w-6 text-gray-400" />
)}
</div>
{/* Информация */}
<div>
<div className="font-medium">{navCategory.name}</div>
<div className="text-sm text-gray-600">
{getCategoryPath(navCategory.partsIndexCatalogId, navCategory.partsIndexGroupId)}
</div>
<div className="flex items-center gap-2 mt-1">
<Badge variant="secondary" className="text-xs">
Сортировка: {navCategory.sortOrder}
</Badge>
{navCategory.isHidden && (
<Badge variant="destructive" className="text-xs">
<EyeOff className="h-3 w-3 mr-1" />
Скрыта
</Badge>
)}
{!navCategory.isHidden && (
<Badge variant="default" className="text-xs">
<Eye className="h-3 w-3 mr-1" />
Видима
</Badge>
)}
</div>
</div>
</div>
{/* Действия */}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(navCategory)}
disabled={deleting}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(navCategory)}
disabled={deleting}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Инструкции */}
<Card>
<CardHeader>
<CardTitle>Как использовать</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-gray-600">
<p>
<strong>1. Выберите каталог:</strong> Из списка каталогов PartsIndex выберите тот, для которого хотите добавить иконку.
</p>
<p>
<strong>2. Выберите группу (необязательно):</strong> Если хотите добавить иконку для конкретной группы внутри каталога, выберите её. Иначе иконка будет применена ко всему каталогу.
</p>
<p>
<strong>3. Загрузите иконку:</strong> Выберите небольшое изображение (рекомендуется 32x32 пикселя) которое будет отображаться в навигации сайта.
</p>
<p>
<strong>4. Настройте отображение:</strong> Установите порядок сортировки и видимость категории в навигации.
</p>
</CardContent>
</Card>
</div>
)
}

View File

@ -34,6 +34,11 @@ const navigationItems = [
href: '/dashboard/catalog', href: '/dashboard/catalog',
icon: Package, icon: Package,
}, },
{
title: 'Навигация сайта',
href: '/dashboard/navigation',
icon: Star,
},
{ {
title: 'Товары главной', title: 'Товары главной',
href: '/dashboard/homepage-products', href: '/dashboard/homepage-products',

View File

@ -137,6 +137,7 @@ export const CREATE_CATEGORY = gql`
seoTitle seoTitle
seoDescription seoDescription
image image
icon
isHidden isHidden
includeSubcategoryProducts includeSubcategoryProducts
parentId parentId
@ -160,6 +161,7 @@ export const UPDATE_CATEGORY = gql`
seoTitle seoTitle
seoDescription seoDescription
image image
icon
isHidden isHidden
includeSubcategoryProducts includeSubcategoryProducts
parentId 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` export const DELETE_PRODUCTS = gql`
mutation DeleteProducts($ids: [ID!]!) { mutation DeleteProducts($ids: [ID!]!) {
deleteProducts(ids: $ids) { deleteProducts(ids: $ids) {

View File

@ -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
}
}
}
}
`;

View File

@ -132,6 +132,15 @@ interface CategoryInput {
parentId?: string parentId?: string
} }
// Интерфейсы для навигационных категорий
interface NavigationCategoryInput {
partsIndexCatalogId: string
partsIndexGroupId?: string
icon?: string
isHidden?: boolean
sortOrder?: number
}
interface ProductInput { interface ProductInput {
name: string name: string
slug?: 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 }) => { laximoUnits: async (_: unknown, { catalogCode, vehicleId, ssd, categoryId }: { catalogCode: string; vehicleId?: string; ssd?: string; categoryId?: string }) => {
try { try {
console.log('🔍 GraphQL Resolver - запрос узлов каталога:', { 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<string, any> | 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 артикулы // PartsAPI артикулы
partsAPIArticles: async (_: unknown, { strId, carId, carType = 'PC' }: { strId: number; carId: number; carType?: 'PC' | 'CV' | 'Motorcycle' }) => { partsAPIArticles: async (_: unknown, { strId, carId, carType = 'PC' }: { strId: number; carId: number; carType?: 'PC' | 'CV' | 'Motorcycle' }) => {
try { 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, { createProduct: async (_: unknown, {
input, input,

View File

@ -73,6 +73,22 @@ export const typeDefs = gql`
updatedAt: DateTime! 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 { type Product {
id: ID! id: ID!
name: String! name: String!
@ -600,6 +616,14 @@ export const typeDefs = gql`
parentId: String parentId: String
} }
input NavigationCategoryInput {
partsIndexCatalogId: String!
partsIndexGroupId: String
icon: String
isHidden: Boolean
sortOrder: Int
}
input ProductInput { input ProductInput {
name: String! name: String!
slug: String slug: String
@ -924,6 +948,10 @@ export const typeDefs = gql`
laximoQuickGroups(catalogCode: String!, vehicleId: String, ssd: String): [LaximoQuickGroup!]! laximoQuickGroups(catalogCode: String!, vehicleId: String, ssd: String): [LaximoQuickGroup!]!
laximoQuickGroupsWithXML(catalogCode: String!, vehicleId: String, ssd: String): LaximoQuickGroupsResponse! laximoQuickGroupsWithXML(catalogCode: String!, vehicleId: String, ssd: String): LaximoQuickGroupsResponse!
laximoCategories(catalogCode: String!, vehicleId: String, ssd: String): [LaximoQuickGroup!]! laximoCategories(catalogCode: String!, vehicleId: String, ssd: String): [LaximoQuickGroup!]!
# Навигационные категории
navigationCategories: [NavigationCategory!]!
navigationCategory(id: ID!): NavigationCategory
laximoUnits(catalogCode: String!, vehicleId: String, ssd: String, categoryId: String): [LaximoQuickGroup!]! laximoUnits(catalogCode: String!, vehicleId: String, ssd: String, categoryId: String): [LaximoQuickGroup!]!
laximoQuickDetail(catalogCode: String!, vehicleId: String!, quickGroupId: String!, ssd: String!): LaximoQuickDetail laximoQuickDetail(catalogCode: String!, vehicleId: String!, quickGroupId: String!, ssd: String!): LaximoQuickDetail
laximoOEMSearch(catalogCode: String!, vehicleId: String!, oemNumber: String!, ssd: String!): LaximoOEMResult laximoOEMSearch(catalogCode: String!, vehicleId: String!, oemNumber: String!, ssd: String!): LaximoOEMResult
@ -1088,6 +1116,11 @@ export const typeDefs = gql`
updateCategory(id: ID!, input: CategoryInput!): Category! updateCategory(id: ID!, input: CategoryInput!): Category!
deleteCategory(id: ID!): Boolean! 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! createProduct(input: ProductInput!, images: [ProductImageInput!], characteristics: [CharacteristicInput!], options: [ProductOptionInput!]): Product!
updateProduct(id: ID!, input: ProductInput!, images: [ProductImageInput!], characteristics: [CharacteristicInput!], options: [ProductOptionInput!]): Product! updateProduct(id: ID!, input: ProductInput!, images: [ProductImageInput!], characteristics: [CharacteristicInput!], options: [ProductOptionInput!]): Product!

View File

@ -1,9 +1,10 @@
import axios from 'axios'; import axios from 'axios';
// Интерфейсы для типизации данных Parts Index API
export interface PartsIndexCatalog { export interface PartsIndexCatalog {
id: string; id: string;
name: string; name: string;
image: string | null; image: string;
} }
export interface PartsIndexGroup { export interface PartsIndexGroup {
@ -14,10 +15,6 @@ export interface PartsIndexGroup {
entityNames: { id: string; name: string; }[]; entityNames: { id: string; name: string; }[];
} }
export interface PartsIndexCatalogsResponse {
list: PartsIndexCatalog[];
}
export interface PartsIndexGroupResponse { export interface PartsIndexGroupResponse {
id: string; id: string;
name: string; name: string;
@ -29,35 +26,30 @@ export interface PartsIndexGroupResponse {
subgroups: PartsIndexGroup[]; 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 { export interface PartsIndexEntity {
id: string; id: string;
name: PartsIndexProductName;
originalName: string;
code: string; code: string;
brand: PartsIndexBrand; name: {
parameters: PartsIndexParameter[]; 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[]; images: string[];
} }
@ -74,6 +66,8 @@ export interface PartsIndexEntitiesResponse {
catalog: { catalog: {
id: string; id: string;
name: string; name: string;
image: string;
groups: PartsIndexGroup[];
}; };
subgroup: { subgroup: {
id: string; id: string;
@ -82,24 +76,22 @@ export interface PartsIndexEntitiesResponse {
} }
export interface PartsIndexParamsResponse { export interface PartsIndexParamsResponse {
list: PartsIndexParam[]; list: {
paramsQuery: Record<string, string>;
}
export interface PartsIndexParam {
id: string; id: string;
code: string;
name: string; name: string;
isGeneral: boolean; code: string;
type: 'select' | 'range'; type: 'range' | 'dropdown';
values: PartsIndexParamValue[]; values: {
id: string;
value: string;
title?: string;
available: boolean;
}[];
}[];
} }
export interface PartsIndexParamValue { export interface PartsIndexEntityInfoResponse {
value: string; list: PartsIndexEntityDetail[];
title: string;
available: boolean;
selected: boolean;
} }
export interface PartsIndexEntityDetail { export interface PartsIndexEntityDetail {
@ -155,20 +147,99 @@ export interface PartsIndexEntityDetail {
}[]; }[];
} }
// Интерфейс для кэша
interface CacheEntry<T> {
data: T;
timestamp: number;
ttl: number; // время жизни в миллисекундах
}
class PartsIndexService { class PartsIndexService {
private baseURL = 'https://api.parts-index.com/v1'; private baseURL = 'https://api.parts-index.com/v1';
private apiKey: string; private apiKey = 'PI-E1C0ADB7-E4A8-4960-94A0-4D9C0A074DAE';
constructor() { // Простой in-memory кэш
this.apiKey = process.env.PARTSINDEX_API_KEY || ''; private cache = new Map<string, CacheEntry<any>>();
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 час для параметров
if (!this.apiKey) { // Проверяем актуальность кэша
console.error('❌ PartsIndex API ключ не найден в переменных окружения'); private isValidCacheEntry<T>(entry: CacheEntry<T>): boolean {
return Date.now() - entry.timestamp < entry.ttl;
} }
// Получаем данные из кэша
private getFromCache<T>(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<T>(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<PartsIndexCatalog[]> { async getCatalogs(lang: 'ru' | 'en' = 'ru'): Promise<PartsIndexCatalog[]> {
const cacheKey = `catalogs_${lang}`;
// Проверяем кэш
const cached = this.getFromCache<PartsIndexCatalog[]>(cacheKey);
if (cached) {
return cached;
}
try { try {
console.log('🔍 PartsIndex запрос каталогов:', { lang }); console.log('🔍 PartsIndex запрос каталогов:', { lang });
@ -187,7 +258,11 @@ class PartsIndexService {
return []; return [];
} }
return response.data.list; const catalogs = response.data.list;
// Сохраняем в кэш на 1 час
this.setCache(cacheKey, catalogs, this.CATALOGS_TTL);
return catalogs;
} catch (error) { } catch (error) {
console.error('❌ Ошибка запроса PartsIndex getCatalogs:', error); console.error('❌ Ошибка запроса PartsIndex getCatalogs:', error);
return []; return [];
@ -196,6 +271,14 @@ class PartsIndexService {
// Получить группы каталога // Получить группы каталога
async getCatalogGroups(catalogId: string, lang: 'ru' | 'en' = 'ru'): Promise<PartsIndexGroup[]> { async getCatalogGroups(catalogId: string, lang: 'ru' | 'en' = 'ru'): Promise<PartsIndexGroup[]> {
const cacheKey = `groups_${catalogId}_${lang}`;
// Проверяем кэш
const cached = this.getFromCache<PartsIndexGroup[]>(cacheKey);
if (cached) {
return cached;
}
try { try {
console.log('🔍 PartsIndex запрос групп каталога:', { catalogId, lang }); console.log('🔍 PartsIndex запрос групп каталога:', { catalogId, lang });
@ -218,21 +301,28 @@ class PartsIndexService {
return []; return [];
} }
let groups: PartsIndexGroup[];
// Если есть подгруппы, возвращаем их // Если есть подгруппы, возвращаем их
if (groupData.subgroups.length > 0) { if (groupData.subgroups.length > 0) {
console.log('📁 Найдено подгрупп:', groupData.subgroups.length); console.log('📁 Найдено подгрупп:', groupData.subgroups.length);
return groupData.subgroups; groups = groupData.subgroups;
} } else {
// Если подгрупп нет, создаем группу из самого каталога // Если подгрупп нет, создаем группу из самого каталога
console.log('📝 Подгрупп нет, возвращаем главную группу'); console.log('📝 Подгрупп нет, возвращаем главную группу');
return [{ groups = [{
id: groupData.id, id: groupData.id,
name: groupData.name, name: groupData.name,
image: groupData.image, image: groupData.image,
subgroups: [], subgroups: [],
entityNames: groupData.entityNames entityNames: groupData.entityNames
}]; }];
}
// Сохраняем в кэш на 24 часа
this.setCache(cacheKey, groups, this.GROUPS_TTL);
return groups;
} catch (error) { } catch (error) {
console.error('❌ Ошибка запроса PartsIndex getCatalogGroups:', error); console.error('❌ Ошибка запроса PartsIndex getCatalogGroups:', error);
return []; return [];
@ -253,7 +343,6 @@ class PartsIndexService {
params?: Record<string, any>; params?: Record<string, any>;
} = {} } = {}
): Promise<PartsIndexEntitiesResponse | null> { ): Promise<PartsIndexEntitiesResponse | null> {
try {
const { const {
lang = 'ru', lang = 'ru',
limit = 25, limit = 25,
@ -264,6 +353,16 @@ class PartsIndexService {
params params
} = options; } = 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<PartsIndexEntitiesResponse>(cacheKey);
if (cached) {
return cached;
}
try {
console.log('🔍 PartsIndex запрос товаров каталога:', { console.log('🔍 PartsIndex запрос товаров каталога:', {
catalogId, catalogId,
groupId, groupId,
@ -316,7 +415,11 @@ class PartsIndexService {
return null; return null;
} }
return response.data; const result = response.data;
// Сохраняем в кэш на 10 минут (товары могут изменяться)
this.setCache(cacheKey, result, this.ENTITIES_TTL);
return result;
} catch (error) { } catch (error) {
console.error('❌ Ошибка запроса PartsIndex getCatalogEntities:', error); console.error('❌ Ошибка запроса PartsIndex getCatalogEntities:', error);
return null; return null;
@ -335,7 +438,6 @@ class PartsIndexService {
q?: string; q?: string;
} = {} } = {}
): Promise<PartsIndexParamsResponse | null> { ): Promise<PartsIndexParamsResponse | null> {
try {
const { const {
lang = 'ru', lang = 'ru',
engineId, engineId,
@ -344,6 +446,16 @@ class PartsIndexService {
q q
} = options; } = options;
// Создаем ключ кэша на основе всех параметров
const cacheKey = `params_${catalogId}_${groupId}_${lang}_${q || 'no-query'}_${engineId || 'no-engine'}_${generationId || 'no-generation'}_${JSON.stringify(params || {})}`;
// Проверяем кэш
const cached = this.getFromCache<PartsIndexParamsResponse>(cacheKey);
if (cached) {
return cached;
}
try {
console.log('🔍 PartsIndex запрос параметров каталога:', { console.log('🔍 PartsIndex запрос параметров каталога:', {
catalogId, catalogId,
groupId, groupId,
@ -392,15 +504,27 @@ class PartsIndexService {
return null; return null;
} }
return response.data; const result = response.data;
// Сохраняем в кэш на 1 час
this.setCache(cacheKey, result, this.PARAMS_TTL);
return result;
} catch (error) { } catch (error) {
console.error('❌ Ошибка запроса PartsIndex getCatalogParams:', error); console.error('❌ Ошибка запроса PartsIndex getCatalogParams:', error);
return null; return null;
} }
} }
// Получить полную структуру категорий с подкатегориями // Получить полную структуру категорий с подкатегориями (оптимизированная версия)
async getCategoriesWithGroups(lang: 'ru' | 'en' = 'ru'): Promise<Array<PartsIndexCatalog & { groups: PartsIndexGroup[] }>> { async getCategoriesWithGroups(lang: 'ru' | 'en' = 'ru'): Promise<Array<PartsIndexCatalog & { groups: PartsIndexGroup[] }>> {
const cacheKey = `categories_with_groups_${lang}`;
// Проверяем кэш
const cached = this.getFromCache<Array<PartsIndexCatalog & { groups: PartsIndexGroup[] }>>(cacheKey);
if (cached) {
return cached;
}
try { try {
console.log('🔍 PartsIndex запрос полной структуры категорий'); console.log('🔍 PartsIndex запрос полной структуры категорий');
@ -413,17 +537,43 @@ class PartsIndexService {
} }
// Для каждого каталога получаем его группы // Для каждого каталога получаем его группы
const catalogsWithGroups = await Promise.all( // Ограничиваем количество одновременных запросов
catalogs.map(async (catalog) => { const BATCH_SIZE = 3;
const catalogsWithGroups: Array<PartsIndexCatalog & { groups: PartsIndexGroup[] }> = [];
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); const groups = await this.getCatalogGroups(catalog.id, lang);
return { return {
...catalog, ...catalog,
groups groups
}; };
} catch (error) {
console.error(`❌ Ошибка загрузки групп для каталога ${catalog.id}:`, error);
return {
...catalog,
groups: []
};
}
}) })
); );
console.log('✅ PartsIndex полная структуря получена:', catalogsWithGroups.length, 'каталогов'); 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; return catalogsWithGroups;
} catch (error) { } catch (error) {