Добавлены новые модели и мутации для навигационных категорий, включая создание, обновление и удаление. Обновлены типы GraphQL и резолверы для обработки навигационных категорий, что улучшает структуру данных и функциональность. В боковое меню добавлен новый элемент для навигации по категориям. Реализован кэш для оптимизации запросов к API, что повышает производительность приложения.
This commit is contained in:
@ -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
|
||||
|
123
src/app/api/debug-partsindex/route.ts
Normal file
123
src/app/api/debug-partsindex/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
11
src/app/dashboard/navigation/page.tsx
Normal file
11
src/app/dashboard/navigation/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -302,6 +302,8 @@ export const CategoryForm = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Настройки */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">Настройки</h3>
|
||||
|
381
src/components/navigation/NavigationCategoryForm.tsx
Normal file
381
src/components/navigation/NavigationCategoryForm.tsx
Normal 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" />
|
||||
Весь каталог "{selectedCatalog.name}"
|
||||
</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>
|
||||
)
|
||||
}
|
377
src/components/navigation/NavigationCategoryTree.tsx
Normal file
377
src/components/navigation/NavigationCategoryTree.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -34,6 +34,11 @@ const navigationItems = [
|
||||
href: '/dashboard/catalog',
|
||||
icon: Package,
|
||||
},
|
||||
{
|
||||
title: 'Навигация сайта',
|
||||
href: '/dashboard/navigation',
|
||||
icon: Star,
|
||||
},
|
||||
{
|
||||
title: 'Товары главной',
|
||||
href: '/dashboard/homepage-products',
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
@ -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<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 артикулы
|
||||
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,
|
||||
|
@ -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!
|
||||
|
@ -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<string, string>;
|
||||
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<T> {
|
||||
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<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 час для параметров
|
||||
|
||||
constructor() {
|
||||
this.apiKey = process.env.PARTSINDEX_API_KEY || '';
|
||||
|
||||
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[]> {
|
||||
const cacheKey = `catalogs_${lang}`;
|
||||
|
||||
// Проверяем кэш
|
||||
const cached = this.getFromCache<PartsIndexCatalog[]>(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<PartsIndexGroup[]> {
|
||||
const cacheKey = `groups_${catalogId}_${lang}`;
|
||||
|
||||
// Проверяем кэш
|
||||
const cached = this.getFromCache<PartsIndexGroup[]>(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<string, any>;
|
||||
} = {}
|
||||
): Promise<PartsIndexEntitiesResponse | null> {
|
||||
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<PartsIndexEntitiesResponse>(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<PartsIndexParamsResponse | null> {
|
||||
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<PartsIndexParamsResponse>(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<Array<PartsIndexCatalog & { groups: PartsIndexGroup[] }>> {
|
||||
const cacheKey = `categories_with_groups_${lang}`;
|
||||
|
||||
// Проверяем кэш
|
||||
const cached = this.getFromCache<Array<PartsIndexCatalog & { groups: PartsIndexGroup[] }>>(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<PartsIndexCatalog & { groups: PartsIndexGroup[] }> = [];
|
||||
|
||||
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) {
|
||||
|
Reference in New Issue
Block a user