feat: Update admin panel

This commit is contained in:
2025-10-22 02:55:04 +03:00
parent 1b8fc3d215
commit 9e47ab667f
8 changed files with 287 additions and 84 deletions

View File

@@ -5,6 +5,7 @@ export interface NavigationItem {
label: string;
icon: LucideIcon;
path?: string;
for_admin?: boolean;
onClick?: () => void;
nestedItems?: NavigationItem[];
}

View File

@@ -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<NavigationItemProps> = ({
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<NavigationItemProps> = ({
},
]}
/>
{item.nestedItems &&
{filteredNestedItems &&
filteredNestedItems.length > 0 &&
open &&
(isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />)}
</ListItemButton>
</ListItem>
{item.nestedItems && (
{filteredNestedItems && filteredNestedItems.length > 0 && (
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
<List component="div" disablePadding>
{item.nestedItems.map((nestedItem) => (
{filteredNestedItems.map((nestedItem) => (
<NavigationItemComponent
key={nestedItem.id}
item={nestedItem}

View File

@@ -1,41 +1,62 @@
import List from "@mui/material/List";
import Divider from "@mui/material/Divider";
import { NAVIGATION_ITEMS } from "@shared";
import { authStore, NAVIGATION_ITEMS } from "@shared";
import { NavigationItem, NavigationItemComponent } from "@entities";
import { observer } from "mobx-react-lite";
interface NavigationListProps {
open: boolean;
onDrawerOpen?: () => 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 (
<>
<List>
{primaryItems.map((item) => (
<NavigationItemComponent
key={item.id}
item={item as NavigationItem}
open={open}
onDrawerOpen={onDrawerOpen}
/>
))}
</List>
<Divider />
<List>
{secondaryItems.map((item) => (
<NavigationItemComponent
key={item.id}
item={item as NavigationItem}
open={open}
onClick={item.onClick ? item.onClick : undefined}
onDrawerOpen={onDrawerOpen}
/>
))}
</List>
</>
);
};
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 (
<>
<List>
{primaryItems.map((item) => (
<NavigationItemComponent
key={item.id}
item={item as NavigationItem}
open={open}
onDrawerOpen={onDrawerOpen}
/>
))}
</List>
<Divider />
<List>
{NAVIGATION_ITEMS.secondary.map((item) => (
<NavigationItemComponent
key={item.id}
item={item as NavigationItem}
open={open}
onClick={item.onClick ? item.onClick : undefined}
onDrawerOpen={onDrawerOpen}
/>
))}
</List>
</>
);
}
);

View File

@@ -52,7 +52,12 @@ export const LoginPage = () => {
}
navigate("/map");
await getUsers();
try {
await getUsers();
} catch (err) {
console.error(err);
}
toast.success("Вход в систему выполнен успешно");
} catch (err) {
setError(

View File

@@ -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<Geometry> | undefined =
this.map.forEachFeatureAtPixel(
@@ -2137,14 +2266,21 @@ const MapSightbar: React.FC<MapSightbarProps> = 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,

View File

@@ -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}
>
<MenuItem value="">Не выбрано</MenuItem>
{carrierStore.carriers[
language as keyof typeof carrierStore.carriers
].data?.map((carrier) => (
{filteredCarriers.map((carrier: any) => (
<MenuItem key={carrier.id} value={carrier.id}>
{carrier.full_name}
</MenuItem>

View File

@@ -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,
},
],
},

View File

@@ -316,31 +316,35 @@ export const LeftWidgetTab = observer(
}}
fullWidth
/>
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
sight.common.watermark_lu
}/download?token=${token}`}
alt="preview"
className="absolute top-4 left-4 z-10"
style={{
width: "30px",
height: "30px",
objectFit: "contain",
}}
/>
{sight.common.watermark_lu && (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
sight.common.watermark_lu
}/download?token=${token}`}
alt="preview"
className="absolute top-4 left-4 z-10"
style={{
width: "30px",
height: "30px",
objectFit: "contain",
}}
/>
)}
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
sight.common.watermark_rd
}/download?token=${token}`}
alt="preview"
className="absolute bottom-4 right-4 z-10"
style={{
width: "30px",
height: "30px",
objectFit: "contain",
}}
/>
{sight.common.watermark_rd && (
<img
src={`${import.meta.env.VITE_KRBL_MEDIA}${
sight.common.watermark_rd
}/download?token=${token}`}
alt="preview"
className="absolute bottom-4 right-4 z-10"
style={{
width: "30px",
height: "30px",
objectFit: "contain",
}}
/>
)}
</>
) : (
<ImagePlus size={48} color="white" />