From 9e47ab667f4e2256ffdc61faa2b89833545d7d8a Mon Sep 17 00:00:00 2001 From: fisenko Date: Wed, 22 Oct 2025 02:55:04 +0300 Subject: [PATCH] feat: Update admin panel --- src/entities/navigation/model/index.ts | 1 + src/entities/navigation/ui/index.tsx | 20 ++- src/features/navigation/ui/index.tsx | 83 +++++---- src/pages/LoginPage/index.tsx | 7 +- src/pages/MapPage/index.tsx | 167 ++++++++++++++++-- src/pages/Route/RouteCreatePage/index.tsx | 34 ++-- src/shared/config/constants.tsx | 7 + src/widgets/SightTabs/LeftWidgetTab/index.tsx | 52 +++--- 8 files changed, 287 insertions(+), 84 deletions(-) diff --git a/src/entities/navigation/model/index.ts b/src/entities/navigation/model/index.ts index 99a9f01..5048640 100644 --- a/src/entities/navigation/model/index.ts +++ b/src/entities/navigation/model/index.ts @@ -5,6 +5,7 @@ export interface NavigationItem { label: string; icon: LucideIcon; path?: string; + for_admin?: boolean; onClick?: () => void; nestedItems?: NavigationItem[]; } diff --git a/src/entities/navigation/ui/index.tsx b/src/entities/navigation/ui/index.tsx index 30eeb3c..fb9b91f 100644 --- a/src/entities/navigation/ui/index.tsx +++ b/src/entities/navigation/ui/index.tsx @@ -10,6 +10,7 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import type { NavigationItem } from "../model"; import { useNavigate, useLocation } from "react-router-dom"; import { Plus } from "lucide-react"; +import { authStore } from "@shared"; interface NavigationItemProps { item: NavigationItem; @@ -30,9 +31,21 @@ export const NavigationItemComponent: React.FC = ({ const navigate = useNavigate(); const location = useLocation(); const [isExpanded, setIsExpanded] = React.useState(false); + const { payload } = authStore; + + // @ts-ignore + const isAdmin = payload?.is_admin || false; const isActive = item.path ? location.pathname.startsWith(item.path) : false; + const filteredNestedItems = item.nestedItems?.filter((nestedItem) => { + if (nestedItem.for_admin) { + return isAdmin; + } + + return true; + }); + const handleClick = () => { if (item.id === "all" && !open) { onDrawerOpen?.(); @@ -108,15 +121,16 @@ export const NavigationItemComponent: React.FC = ({ }, ]} /> - {item.nestedItems && + {filteredNestedItems && + filteredNestedItems.length > 0 && open && (isExpanded ? : )} - {item.nestedItems && ( + {filteredNestedItems && filteredNestedItems.length > 0 && ( - {item.nestedItems.map((nestedItem) => ( + {filteredNestedItems.map((nestedItem) => ( void; } -export const NavigationList = ({ open, onDrawerOpen }: NavigationListProps) => { - const primaryItems = NAVIGATION_ITEMS.primary; - const secondaryItems = NAVIGATION_ITEMS.secondary; +export const NavigationList = observer( + ({ open, onDrawerOpen }: NavigationListProps) => { + const { payload } = authStore; + // @ts-ignore + const isAdmin = Boolean(payload?.is_admin) || false; - return ( - <> - - {primaryItems.map((item) => ( - - ))} - - - - {secondaryItems.map((item) => ( - - ))} - - - ); -}; + const primaryItems = NAVIGATION_ITEMS.primary.filter((item) => { + if (item.for_admin) { + return isAdmin; + } + + if (item.nestedItems && item.nestedItems.length > 0) { + return item.nestedItems.some((nestedItem) => { + if (nestedItem.for_admin) { + return isAdmin; + } + return true; + }); + } + + return true; + }); + + return ( + <> + + {primaryItems.map((item) => ( + + ))} + + + + {NAVIGATION_ITEMS.secondary.map((item) => ( + + ))} + + + ); + } +); diff --git a/src/pages/LoginPage/index.tsx b/src/pages/LoginPage/index.tsx index 4318742..ef14c28 100644 --- a/src/pages/LoginPage/index.tsx +++ b/src/pages/LoginPage/index.tsx @@ -52,7 +52,12 @@ export const LoginPage = () => { } navigate("/map"); - await getUsers(); + try { + await getUsers(); + } catch (err) { + console.error(err); + } + toast.success("Вход в систему выполнен успешно"); } catch (err) { setError( diff --git a/src/pages/MapPage/index.tsx b/src/pages/MapPage/index.tsx index 34603d9..b305763 100644 --- a/src/pages/MapPage/index.tsx +++ b/src/pages/MapPage/index.tsx @@ -16,7 +16,12 @@ import { Draw, Modify, Select, - defaults as defaultInteractions, + DragPan, + MouseWheelZoom, + KeyboardPan, + KeyboardZoom, + PinchZoom, + PinchRotate, } from "ol/interaction"; import { DrawEvent } from "ol/interaction/Draw"; import { SelectEvent } from "ol/interaction/Select"; @@ -102,6 +107,7 @@ import { sightsStore, menuStore, selectedCityStore, + carrierStore, } from "@shared"; // Функция для сброса кешей карты @@ -123,6 +129,7 @@ interface ApiRoute { path: [number, number][]; center_latitude: number; center_longitude: number; + carrier_id: number; } interface ApiStation { @@ -270,6 +277,23 @@ class MapStore { ); } + get filteredRoutes(): ApiRoute[] { + const selectedCityId = selectedCityStore.selectedCityId; + if (!selectedCityId) { + return this.routes; + } + + // Получаем carriers для текущего языка + const carriers = carrierStore.carriers.ru.data; + + // Фильтруем маршруты по городу через carriers + return this.routes.filter((route: ApiRoute) => { + // Находим carrier для маршрута + const carrier = carriers.find((c: any) => c.id === route.carrier_id); + return carrier && carrier.city_id === selectedCityId; + }); + } + get filteredSights(): ApiSight[] { const selectedCityId = selectedCityStore.selectedCityId; if (!selectedCityId) { @@ -287,7 +311,14 @@ class MapStore { languageInstance("ru").get(`/route/${id}`) ); const routeResponses = await Promise.all(routePromises); - this.routes = routeResponses.map((res) => res.data); + this.routes = routeResponses.map((res) => ({ + id: res.data.id, + route_number: res.data.route_number, + path: res.data.path, + center_latitude: res.data.center_latitude, + center_longitude: res.data.center_longitude, + carrier_id: res.data.carrier_id, + })); this.routes = this.routes.sort((a, b) => a.route_number.localeCompare(b.route_number) @@ -372,13 +403,28 @@ class MapStore { "EPSG:3857" ); + // Автоматически назначаем перевозчика из выбранного города + let carrier_id = 0; + let carrier = ""; + + if (selectedCityStore.selectedCityId) { + const carriersInCity = carrierStore.carriers.ru.data.filter( + (c: any) => c.city_id === selectedCityStore.selectedCityId + ); + + if (carriersInCity.length > 0) { + carrier_id = carriersInCity[0].id; + carrier = carriersInCity[0].full_name; + } + } + const routeData = { route_number, path, center_latitude, center_longitude, - carrier: "", - carrier_id: 0, + carrier, + carrier_id, governor_appeal: 0, rotate: 0, route_direction: false, @@ -388,6 +434,12 @@ class MapStore { }; await routeStore.createRoute(routeData); + + if (!carrier_id) { + toast.error( + "В выбранном городе нет доступных перевозчиков, маршрут отображается в общем списке" + ); + } createdItem = routeStore.routes.data[routeStore.routes.data.length - 1]; } else if (featureType === "sight") { const name = properties.name || "Достопримечательность 1"; @@ -935,7 +987,33 @@ class MapService { center: transform(initialCenter, "EPSG:4326", "EPSG:3857"), zoom: initialZoom, }), - interactions: defaultInteractions({ doubleClickZoom: false }), + interactions: [ + new MouseWheelZoom(), + new KeyboardPan(), + new KeyboardZoom(), + new PinchZoom(), + new PinchRotate(), + // Отключаем DoubleClickZoom как было изначально + // new DoubleClickZoom(), + new DragPan({ + condition: (event) => { + // Разрешаем перетаскивание только при нажатии средней кнопки мыши (колёсико) + const originalEvent = event.originalEvent; + if (!originalEvent) return false; + + // Проверяем, что это событие мыши и нажата средняя кнопка + if ( + originalEvent.type === "pointerdown" || + originalEvent.type === "pointermove" + ) { + const pointerEvent = originalEvent as PointerEvent; + return pointerEvent.buttons === 4; // 4 = средняя кнопка мыши + } + + return false; + }, + }), + ], controls: [], }); @@ -1249,8 +1327,52 @@ class MapService { this.map.on("pointermove", this.boundHandlePointerMove as any); const targetEl = this.map.getTargetElement(); if (targetEl instanceof HTMLElement) { + // Устанавливаем курсор pointer по умолчанию для всей карты + targetEl.style.cursor = "pointer"; targetEl.addEventListener("contextmenu", this.boundHandleContextMenu); targetEl.addEventListener("pointerleave", this.boundHandlePointerLeave); + + // Добавляем обработчики для изменения курсора при нажатии средней кнопки мыши + targetEl.addEventListener("pointerdown", (e) => { + if (e.buttons === 4) { + // Средняя кнопка мыши + e.preventDefault(); // Предотвращаем скролл страницы + targetEl.style.cursor = "grabbing"; + } + }); + + targetEl.addEventListener("pointerup", (e) => { + if (e.button === 1) { + // Средняя кнопка мыши отпущена + e.preventDefault(); // Предотвращаем скролл страницы + targetEl.style.cursor = "pointer"; + } + }); + + // Также добавляем обработчик для mousedown/mouseup для совместимости + targetEl.addEventListener("mousedown", (e) => { + if (e.button === 1) { + // Средняя кнопка мыши + e.preventDefault(); // Предотвращаем скролл страницы + targetEl.style.cursor = "grabbing"; + } + }); + + targetEl.addEventListener("mouseup", (e) => { + if (e.button === 1) { + // Средняя кнопка мыши отпущена + e.preventDefault(); // Предотвращаем скролл страницы + targetEl.style.cursor = "pointer"; + } + }); + + // Дополнительная защита от нежелательного поведения средней кнопки мыши + targetEl.addEventListener("auxclick", (e) => { + if (e.button === 1) { + // Средняя кнопка мыши + e.preventDefault(); // Предотвращаем открытие ссылки в новой вкладке + } + }); } document.addEventListener("keydown", this.boundHandleKeyDown); this.activateEditMode(); @@ -1270,7 +1392,7 @@ class MapService { public loadFeaturesFromApi( _apiStations: typeof mapStore.stations, - apiRoutes: typeof mapStore.routes, + _apiRoutes: typeof mapStore.routes, _apiSights: typeof mapStore.sights ): void { if (!this.map) return; @@ -1282,6 +1404,7 @@ class MapService { // Используем фильтрованные данные из mapStore const filteredStations = mapStore.filteredStations; const filteredSights = mapStore.filteredSights; + const filteredRoutes = mapStore.filteredRoutes; filteredStations.forEach((station) => { if (station.longitude == null || station.latitude == null) return; @@ -1313,17 +1436,16 @@ class MapService { pointFeatures.push(feature); }); - apiRoutes.forEach((route) => { + filteredRoutes.forEach((route) => { if (!route.path || route.path.length === 0) return; const coordinates = route.path .filter((c) => c && c[0] != null && c[1] != null) .map((c: [number, number]) => transform([c[1], c[0]], "EPSG:4326", projection) ); + if (coordinates.length === 0) return; - const routeId = `route-${route.id}`; - const line = new LineString(coordinates); const lineFeature = new Feature({ geometry: line, @@ -1332,8 +1454,6 @@ class MapService { lineFeature.setId(routeId); lineFeature.set("featureType", "route"); lineFeatures.push(lineFeature); - - // Не создаем прокси-точки для маршрутов - они должны оставаться только линиями }); this.pointSource.addFeatures(pointFeatures); @@ -1359,6 +1479,14 @@ class MapService { this.routeLayer.changed(); } if (this.tooltipOverlay) this.tooltipOverlay.setPosition(undefined); + + // Сбрасываем курсор при покидании области карты + if (this.map) { + const targetEl = this.map.getTargetElement(); + if (targetEl instanceof HTMLElement) { + targetEl.style.cursor = "pointer"; + } + } } private handleKeyDown(event: KeyboardEvent): void { @@ -1565,7 +1693,8 @@ class MapService { layerFilter, hitTolerance: 5, }); - this.map.getTargetElement().style.cursor = hit ? "pointer" : ""; + // Устанавливаем курсор pointer для всей карты, чтобы показать возможность перетаскивания колёсиком + this.map.getTargetElement().style.cursor = hit ? "pointer" : "pointer"; const featureAtPixel: Feature | undefined = this.map.forEachFeatureAtPixel( @@ -2137,14 +2266,21 @@ const MapSightbar: React.FC = observer( return feature; }); - const lines = actualFeatures.filter( - (f) => f.get("featureType") === "route" - ); + const lines = mapStore.filteredRoutes.map((route) => { + const feature = new Feature({ + geometry: new LineString(route.path), + name: route.route_number, + }); + feature.setId(`route-${route.id}`); + feature.set("featureType", "route"); + return feature; + }); return [...stations, ...sights, ...lines]; }, [ mapStore.filteredStations, mapStore.filteredSights, + mapStore.filteredRoutes, actualFeatures, selectedCityId, mapStore, @@ -2613,6 +2749,7 @@ export const MapPage: React.FC = observer(() => { mapStore.getRoutes(), mapStore.getStations(), mapStore.getSights(), + carrierStore.getCarriers("ru"), ]); mapService.loadFeaturesFromApi( mapStore.stations, diff --git a/src/pages/Route/RouteCreatePage/index.tsx b/src/pages/Route/RouteCreatePage/index.tsx index bc9848c..bc0c5d6 100644 --- a/src/pages/Route/RouteCreatePage/index.tsx +++ b/src/pages/Route/RouteCreatePage/index.tsx @@ -16,13 +16,18 @@ import { import { MediaViewer } from "@widgets"; import { observer } from "mobx-react-lite"; import { ArrowLeft, Loader2, Save, Plus } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { toast } from "react-toastify"; import { carrierStore } from "../../../shared/store/CarrierStore"; import { articlesStore } from "../../../shared/store/ArticlesStore"; import { Route, routeStore } from "../../../shared/store/RouteStore"; -import { languageStore, SelectArticleModal, SelectMediaDialog } from "@shared"; +import { + languageStore, + SelectArticleModal, + SelectMediaDialog, + selectedCityStore, +} from "@shared"; export const RouteCreatePage = observer(() => { const navigate = useNavigate(); @@ -50,6 +55,21 @@ export const RouteCreatePage = observer(() => { articlesStore.getArticleList(); }, [language]); + // Фильтруем перевозчиков только из выбранного города + const filteredCarriers = useMemo(() => { + const carriers = + carrierStore.carriers[language as keyof typeof carrierStore.carriers] + .data || []; + + if (!selectedCityStore.selectedCityId) { + return carriers; + } + + return carriers.filter( + (carrier: any) => carrier.city_id === selectedCityStore.selectedCityId + ); + }, [carrierStore.carriers, language, selectedCityStore.selectedCityId]); + const validateCoordinates = (value: string) => { try { const lines = value.trim().split("\n"); @@ -194,16 +214,10 @@ export const RouteCreatePage = observer(() => { value={carrier} label="Выберите перевозчика" onChange={(e) => setCarrier(e.target.value as string)} - disabled={ - carrierStore.carriers[ - language as keyof typeof carrierStore.carriers - ].data?.length === 0 - } + disabled={filteredCarriers.length === 0} > Не выбрано - {carrierStore.carriers[ - language as keyof typeof carrierStore.carriers - ].data?.map((carrier) => ( + {filteredCarriers.map((carrier: any) => ( {carrier.full_name} diff --git a/src/shared/config/constants.tsx b/src/shared/config/constants.tsx index 218d32d..7301060 100644 --- a/src/shared/config/constants.tsx +++ b/src/shared/config/constants.tsx @@ -25,6 +25,7 @@ interface NavigationItem { label: string; icon?: LucideIcon | React.ReactNode; path?: string; + for_admin?: boolean; onClick?: () => void; nestedItems?: NavigationItem[]; isActive?: boolean; @@ -40,6 +41,7 @@ export const NAVIGATION_ITEMS: { label: "Снапшоты", icon: GitBranch, path: "/snapshot", + for_admin: true, }, { id: "map", @@ -52,6 +54,7 @@ export const NAVIGATION_ITEMS: { label: "Устройства", icon: Cpu, path: "/devices", + for_admin: true, }, // { // id: "vehicles", @@ -64,6 +67,7 @@ export const NAVIGATION_ITEMS: { label: "Пользователи", icon: Users, path: "/user", + for_admin: true, }, { id: "all", @@ -106,12 +110,14 @@ export const NAVIGATION_ITEMS: { label: "Страны", icon: Earth, path: "/country", + for_admin: true, }, { id: "cities", label: "Города", icon: Building2, path: "/city", + for_admin: true, }, { id: "carriers", @@ -119,6 +125,7 @@ export const NAVIGATION_ITEMS: { // @ts-ignore icon: CarrierSvg, path: "/carrier", + for_admin: true, }, ], }, diff --git a/src/widgets/SightTabs/LeftWidgetTab/index.tsx b/src/widgets/SightTabs/LeftWidgetTab/index.tsx index d0d97c6..fd3b407 100644 --- a/src/widgets/SightTabs/LeftWidgetTab/index.tsx +++ b/src/widgets/SightTabs/LeftWidgetTab/index.tsx @@ -316,31 +316,35 @@ export const LeftWidgetTab = observer( }} fullWidth /> - preview + {sight.common.watermark_lu && ( + preview + )} - preview + {sight.common.watermark_rd && ( + preview + )} ) : (