fix: 01.11.25 MapPage update + sight/station relation + preview base

This commit is contained in:
2025-11-06 00:21:45 +03:00
parent 79f523e9cb
commit c95a6517e9
10 changed files with 2884 additions and 85 deletions

View File

@@ -6,7 +6,7 @@ import React, {
useMemo,
} from "react";
import { useNavigate } from "react-router-dom";
import { Map, View, Overlay, MapBrowserEvent } from "ol";
import { Map as OLMap, View, Overlay, MapBrowserEvent } from "ol";
import TileLayer from "ol/layer/Tile";
import OSM from "ol/source/OSM";
import VectorLayer from "ol/layer/Vector";
@@ -48,6 +48,9 @@ import {
InfoIcon,
X,
Loader2,
EyeOff,
Eye,
Map as MapIcon,
} from "lucide-react";
import { toast } from "react-toastify";
import { singleClick, doubleClick } from "ol/events/condition";
@@ -162,14 +165,71 @@ export type SortType =
| "updated_asc"
| "updated_desc";
// --- HIDDEN ROUTES STORAGE ---
const HIDDEN_ROUTES_KEY = "mapHiddenRoutes";
const HIDE_SIGHTS_BY_HIDDEN_ROUTES_KEY = "mapHideSightsByHiddenRoutes";
const getStoredHiddenRoutes = (): Set<number> => {
try {
const stored = localStorage.getItem(HIDDEN_ROUTES_KEY);
if (stored) {
const routes = JSON.parse(stored);
if (
Array.isArray(routes) &&
routes.every((id) => typeof id === "number")
) {
return new Set(routes);
}
}
} catch (error) {
console.warn("Failed to parse stored hidden routes:", error);
}
return new Set();
};
const saveHiddenRoutes = (hiddenRoutes: Set<number>): void => {
try {
localStorage.setItem(
HIDDEN_ROUTES_KEY,
JSON.stringify(Array.from(hiddenRoutes))
);
} catch (error) {
console.warn("Failed to save hidden routes:", error);
}
};
class MapStore {
constructor() {
makeAutoObservable(this);
// Загружаем скрытые маршруты из localStorage при инициализации
this.hiddenRoutes = getStoredHiddenRoutes();
// Загружаем настройку скрытия достопримечательностей
try {
const stored = localStorage.getItem(HIDE_SIGHTS_BY_HIDDEN_ROUTES_KEY);
this.hideSightsByHiddenRoutes = stored
? JSON.parse(stored) === true
: false;
} catch (e) {
this.hideSightsByHiddenRoutes = false;
}
}
routes: ApiRoute[] = [];
stations: ApiStation[] = [];
sights: ApiSight[] = [];
hiddenRoutes: Set<number>;
hideSightsByHiddenRoutes: boolean = false;
routeStationsCache: Map<number, number[]> = new Map(); // Кэш станций для маршрутов
routeSightsCache: Map<number, number[]> = new Map(); // Кэш достопримечательностей для маршрутов
setHideSightsByHiddenRoutes(val: boolean) {
this.hideSightsByHiddenRoutes = val;
try {
localStorage.setItem(
HIDE_SIGHTS_BY_HIDDEN_ROUTES_KEY,
JSON.stringify(!!val)
);
} catch (e) {}
}
// НОВЫЕ ПОЛЯ ДЛЯ СОРТИРОВКИ
stationSort: SortType = "name_asc";
@@ -297,12 +357,23 @@ class MapStore {
get filteredSights(): ApiSight[] {
const selectedCityId = selectedCityStore.selectedCityId;
if (!selectedCityId) {
return this.sortedSights;
const cityFiltered = !selectedCityId
? this.sortedSights
: this.sortedSights.filter((sight) => sight.city_id === selectedCityId);
if (!this.hideSightsByHiddenRoutes || this.hiddenRoutes.size === 0) {
return cityFiltered;
}
return this.sortedSights.filter(
(sight) => sight.city_id === selectedCityId
);
// Собираем все достопримечательности, связанные со скрытыми маршрутами
const hiddenSightIds = new Set<number>();
this.hiddenRoutes.forEach((routeId) => {
const sightIds = this.routeSightsCache.get(routeId) || [];
sightIds.forEach((id) => hiddenSightIds.add(id));
});
// Фильтруем достопримечательности, исключая привязанные к скрытым маршрутам
return cityFiltered.filter((s) => !hiddenSightIds.has(s.id));
}
getRoutes = async () => {
@@ -324,6 +395,54 @@ class MapStore {
this.routes = this.routes.sort((a, b) =>
a.route_number.localeCompare(b.route_number)
);
// Предзагружаем станции для всех маршрутов и кэшируем их
await this.preloadRouteStations(routesIds);
// Предзагружаем достопримечательности для всех маршрутов
await this.preloadRouteSights(routesIds);
};
preloadRouteStations = async (routesIds: number[]) => {
console.log(
`[MapStore] Preloading stations for ${routesIds.length} routes`
);
const stationPromises = routesIds.map(async (routeId) => {
try {
const stationsResponse = await languageInstance("ru").get(
`/route/${routeId}/station`
);
const stationIds = stationsResponse.data.map((s: any) => s.id);
this.routeStationsCache.set(routeId, stationIds);
} catch (error) {
console.error(
`Failed to preload stations for route ${routeId}:`,
error
);
}
});
await Promise.all(stationPromises);
console.log(
`[MapStore] Preloaded stations for ${this.routeStationsCache.size} routes`
);
};
preloadRouteSights = async (routesIds: number[]) => {
console.log(`[MapStore] Preloading sights for ${routesIds.length} routes`);
const sightPromises = routesIds.map(async (routeId) => {
try {
const sightsResponse = await languageInstance("ru").get(
`/route/${routeId}/sight`
);
const sightIds = sightsResponse.data.map((s: any) => s.id);
this.routeSightsCache.set(routeId, sightIds);
} catch (error) {
console.error(`Failed to preload sights for route ${routeId}:`, error);
}
});
await Promise.all(sightPromises);
console.log(
`[MapStore] Preloaded sights for ${this.routeSightsCache.size} routes`
);
};
getStations = async () => {
@@ -430,8 +549,8 @@ class MapStore {
rotate: 0,
route_direction: false,
route_sys_number: route_number,
scale_max: 0,
scale_min: 0,
scale_max: 100,
scale_min: 10,
};
await routeStore.createRoute(routeData);
@@ -651,7 +770,7 @@ interface MapServiceConfig {
type FeatureType = "station" | "route" | "sight";
class MapService {
private map: Map | null;
private map: OLMap | null;
public pointSource: VectorSource<Feature<Point>>;
public lineSource: VectorSource<Feature<LineString>>;
public clusterLayer: VectorLayer<Cluster>; // Public for the deselect handler
@@ -943,7 +1062,7 @@ class MapService {
const initialCenter = storedPosition?.center || config.center;
const initialZoom = storedPosition?.zoom || config.zoom;
this.map = new Map({
this.map = new OLMap({
target: config.target,
layers: [
new TileLayer({ source: new OSM() }),
@@ -1251,32 +1370,17 @@ class MapService {
}
}
// Стандартная логика выделения для одиночных объектов (или с Ctrl)
// При Ctrl+клик сохраняем предыдущие выделения и добавляем/удаляем только изменённые
// При обычном клике создаём новый набор
const newSelectedIds = ctrlKey
? new Set(this.selectedIds)
: new Set<string | number>();
// Добавляем новые выбранные элементы
e.selected.forEach((feature) => {
const originalFeatures = feature.get("features");
let targetId: string | number | undefined;
if (originalFeatures && originalFeatures.length > 0) {
// Это фича из кластера (может быть и одна)
targetId = originalFeatures[0].getId();
} else {
// Это линия или что-то не из кластера
targetId = feature.getId();
}
if (targetId !== undefined) {
newSelectedIds.add(targetId);
}
});
e.deselected.forEach((feature) => {
const originalFeatures = feature.get("features");
let targetId: string | number | undefined;
if (originalFeatures && originalFeatures.length > 0) {
targetId = originalFeatures[0].getId();
} else {
@@ -1284,10 +1388,36 @@ class MapService {
}
if (targetId !== undefined) {
newSelectedIds.delete(targetId);
// При Ctrl+клик: если элемент уже был выбран, снимаем его (toggle)
// Если не был выбран, добавляем
if (ctrlKey && newSelectedIds.has(targetId)) {
newSelectedIds.delete(targetId);
} else {
newSelectedIds.add(targetId);
}
}
});
// При Ctrl+клик игнорируем deselected, так как Select interaction может снимать
// предыдущие выделения, но мы хотим их сохранить
// При обычном клике удаляем deselected элементы
if (!ctrlKey) {
e.deselected.forEach((feature) => {
const originalFeatures = feature.get("features");
let targetId: string | number | undefined;
if (originalFeatures && originalFeatures.length > 0) {
targetId = originalFeatures[0].getId();
} else {
targetId = feature.getId();
}
if (targetId !== undefined) {
newSelectedIds.delete(targetId);
}
});
}
this.setSelectedIds(newSelectedIds);
});
@@ -1373,8 +1503,33 @@ class MapService {
const filteredSights = mapStore.filteredSights;
const filteredRoutes = mapStore.filteredRoutes;
console.log(
`[loadFeaturesFromApi] Loading with ${mapStore.hiddenRoutes.size} hidden routes`
);
// Собираем все станции видимых маршрутов из кэша
const stationsInVisibleRoutes = new Set<number>();
filteredRoutes
.filter((route) => !mapStore.hiddenRoutes.has(route.id))
.forEach((route) => {
const stationIds = mapStore.routeStationsCache.get(route.id) || [];
stationIds.forEach((id) => stationsInVisibleRoutes.add(id));
});
console.log(
`[loadFeaturesFromApi] Found ${stationsInVisibleRoutes.size} stations in visible routes, total stations: ${filteredStations.length}`
);
let skippedStations = 0;
filteredStations.forEach((station) => {
if (station.longitude == null || station.latitude == null) return;
// Пропускаем станции, которые принадлежат только скрытым маршрутам
if (!stationsInVisibleRoutes.has(station.id)) {
skippedStations++;
return;
}
const point = new Point(
transform(
[station.longitude, station.latitude],
@@ -1405,6 +1560,10 @@ class MapService {
filteredRoutes.forEach((route) => {
if (!route.path || route.path.length === 0) return;
// Пропускаем скрытые маршруты
if (mapStore.hiddenRoutes.has(route.id)) return;
const coordinates = route.path
.filter((c) => c && c[0] != null && c[1] != null)
.map((c: [number, number]) =>
@@ -1423,6 +1582,10 @@ class MapService {
lineFeatures.push(lineFeature);
});
console.log(
`[loadFeaturesFromApi] Skipped ${skippedStations} stations (belonging only to hidden routes)`
);
this.pointSource.addFeatures(pointFeatures);
this.lineSource.addFeatures(lineFeatures);
@@ -1880,10 +2043,14 @@ class MapService {
this.selectInteraction.getFeatures().clear();
ids.forEach((id) => {
const lineFeature = this.lineSource.getFeatureById(id);
if (lineFeature) this.selectInteraction.getFeatures().push(lineFeature);
if (lineFeature) {
this.selectInteraction.getFeatures().push(lineFeature);
}
const pointFeature = this.pointSource.getFeatureById(id);
if (pointFeature) this.selectInteraction.getFeatures().push(pointFeature);
if (pointFeature) {
this.selectInteraction.getFeatures().push(pointFeature);
}
});
this.modifyInteraction.setActive(
@@ -1915,7 +2082,7 @@ class MapService {
if (this.mode === "lasso") this.deactivateLasso();
else this.activateLasso();
}
public getMap(): Map | null {
public getMap(): OLMap | null {
return this.map;
}
@@ -2263,11 +2430,26 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
}, [allFeatures, searchQuery]);
const handleFeatureClick = useCallback(
(id: string | number) => {
(id: string | number, event?: React.MouseEvent) => {
if (!mapService) return;
mapService.selectFeature(id);
const ctrlKey = event?.ctrlKey || event?.metaKey;
if (ctrlKey) {
// Множественный выбор: добавляем к существующему
const newSet = new Set(selectedIds);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
setSelectedIds(newSet);
mapService.setSelectedIds(newSet);
} else {
// Одиночный выбор: используем стандартный метод
mapService.selectFeature(id);
}
},
[mapService]
[mapService, selectedIds, setSelectedIds]
);
const handleDeleteFeature = useCallback(
@@ -2313,6 +2495,217 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
[navigate]
);
const handleHideRoute = useCallback(
async (routeId: string | number) => {
if (!mapService) return;
const numericRouteId = parseInt(String(routeId).split("-")[1], 10);
if (isNaN(numericRouteId)) return;
const isHidden = mapStore.hiddenRoutes.has(numericRouteId);
console.log(
`[handleHideRoute] Route ${numericRouteId}, isHidden: ${isHidden}`
);
try {
if (isHidden) {
console.log(`[handleHideRoute] Showing route ${numericRouteId}`);
// Показываем маршрут обратно
const route = mapStore.routes.find((r) => r.id === numericRouteId);
if (!route) {
console.warn(
`[handleHideRoute] Route ${numericRouteId} not found in mapStore`
);
return;
}
const projection = mapService.getMap()?.getView().getProjection();
if (!projection) {
console.error(`[handleHideRoute] Failed to get map projection`);
return;
}
console.log(
`[handleHideRoute] Route ${numericRouteId} (${route.route_number}) found, showing`
);
// Показываем сам маршрут
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) {
const line = new LineString(coordinates);
const lineFeature = new Feature({
geometry: line,
name: route.route_number,
});
lineFeature.setId(routeId);
lineFeature.set("featureType", "route");
mapService.lineSource.addFeature(lineFeature);
console.log(`[handleHideRoute] Added route line to map`);
} else {
console.warn(
`[handleHideRoute] No valid coordinates for route ${numericRouteId}`
);
}
// Получаем станции текущего маршрута из кэша
const routeStationIds =
mapStore.routeStationsCache.get(numericRouteId) || [];
console.log(
`[handleHideRoute] Route ${numericRouteId} has ${routeStationIds.length} stations`
);
// Получаем все маршруты для проверки
const allRouteIds = mapStore.routes.map((r) => r.id);
// Исключаем скрытые маршруты из проверки
const visibleRouteIds = allRouteIds.filter(
(id: number) =>
id !== numericRouteId && !mapStore.hiddenRoutes.has(id)
);
console.log(
`[handleHideRoute] Checking against ${visibleRouteIds.length} visible routes (excluding hidden)`
);
// Собираем все станции видимых маршрутов из кэша
const stationsInVisibleRoutes = new Set<number>();
visibleRouteIds.forEach((otherRouteId) => {
const stationIds =
mapStore.routeStationsCache.get(otherRouteId) || [];
stationIds.forEach((id: number) =>
stationsInVisibleRoutes.add(id)
);
});
console.log(
`[handleHideRoute] Found ${stationsInVisibleRoutes.size} unique stations in visible routes`
);
// Показываем станции, которые не используются в других ВИДИМЫХ маршрутах
const stationsToShow = routeStationIds.filter(
(id: number) => !stationsInVisibleRoutes.has(id)
);
// Показываем станции на карте
for (const stationId of stationsToShow) {
const station = mapStore.stations.find((s) => s.id === stationId);
if (!station) continue;
const point = new Point(
transform(
[station.longitude, station.latitude],
"EPSG:4326",
projection
)
);
const feature = new Feature({
geometry: point,
name: station.name,
});
feature.setId(`station-${station.id}`);
feature.set("featureType", "station");
// Добавляем станцию только если её еще нет на карте
const existingFeature = mapService.pointSource.getFeatureById(
`station-${station.id}`
);
if (!existingFeature) {
mapService.pointSource.addFeature(feature);
}
}
// Удаляем из скрытых
mapStore.hiddenRoutes.delete(numericRouteId);
saveHiddenRoutes(mapStore.hiddenRoutes); // Сохраняем в localStorage
console.log(
`[handleHideRoute] Removed ${numericRouteId} from hiddenRoutes, stations to show: ${stationsToShow.length}`
);
} else {
// Скрываем маршрут
console.log(`[handleHideRoute] Hiding route ${numericRouteId}`);
// Получаем станции текущего маршрута из кэша
const routeStationIds =
mapStore.routeStationsCache.get(numericRouteId) || [];
console.log(
`[handleHideRoute] Route ${numericRouteId} has ${routeStationIds.length} stations`
);
// Получаем все маршруты для проверки
const allRouteIds = mapStore.routes.map((r) => r.id);
// Исключаем скрытые маршруты из проверки
const visibleRouteIds = allRouteIds.filter(
(id: number) =>
id !== numericRouteId && !mapStore.hiddenRoutes.has(id)
);
console.log(
`[handleHideRoute] Checking against ${visibleRouteIds.length} visible routes (excluding hidden)`
);
// Собираем все станции видимых маршрутов из кэша
const stationsInVisibleRoutes = new Set<number>();
visibleRouteIds.forEach((otherRouteId) => {
const stationIds =
mapStore.routeStationsCache.get(otherRouteId) || [];
stationIds.forEach((id: number) =>
stationsInVisibleRoutes.add(id)
);
});
console.log(
`[handleHideRoute] Found ${stationsInVisibleRoutes.size} unique stations in visible routes`
);
// Скрываем станции, которые не используются в других ВИДИМЫХ маршрутах
const stationsToHide = routeStationIds.filter(
(id: number) => !stationsInVisibleRoutes.has(id)
);
// Скрываем станции с карты
stationsToHide.forEach((stationId: number) => {
const pointFeature = mapService.pointSource.getFeatureById(
`station-${stationId}`
);
if (pointFeature) {
mapService.pointSource.removeFeature(
pointFeature as Feature<Point>
);
}
});
// Скрываем сам маршрут с карты
const lineFeature = mapService.lineSource.getFeatureById(routeId);
if (lineFeature) {
mapService.lineSource.removeFeature(
lineFeature as Feature<LineString>
);
}
// Добавляем в скрытые
mapStore.hiddenRoutes.add(numericRouteId);
saveHiddenRoutes(mapStore.hiddenRoutes); // Сохраняем в localStorage
console.log(
`[handleHideRoute] Added ${numericRouteId} to hiddenRoutes, stations to hide: ${stationsToHide.length}`
);
}
// Снимаем выделение
mapService.unselect();
} catch (error) {
console.error(
"[handleHideRoute] Error toggling route visibility:",
error
);
toast.error("Ошибка при изменении видимости маршрута");
}
},
[mapService]
);
const sortFeaturesByType = <T extends Feature<Geometry>>(
features: T[],
sortType: SortType
@@ -2411,6 +2804,15 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
const isSelected = selectedFeature?.getId() === fId;
const isChecked = selectedIds.has(fId);
// Проверяем, скрыт ли маршрут
const numericRouteId =
featureType === "route"
? parseInt(String(fId).split("-")[1], 10)
: null;
const isRouteHidden =
numericRouteId !== null &&
mapStore.hiddenRoutes.has(numericRouteId);
return (
<div
key={String(fId)}
@@ -2433,7 +2835,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
</div>
<div
className="flex flex-col text-gray-800 text-sm flex-grow mr-2 min-w-0 cursor-pointer"
onClick={() => handleFeatureClick(fId)}
onClick={(e) => handleFeatureClick(fId, e)}
>
<div className="flex items-center">
<IconComponent
@@ -2472,6 +2874,37 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
>
<Pencil size={16} />
</button>
{featureType === "route" && (
<button
onClick={(e) => {
e.stopPropagation();
const routeId = parseInt(String(fId).split("-")[1], 10);
navigate(`/route-preview/${routeId}`);
}}
className="p-1 rounded-full text-gray-400 hover:text-green-600 hover:bg-green-100 transition-colors"
title="Предпросмотр маршрута"
>
<MapIcon size={16} />
</button>
)}
{featureType === "route" && (
<button
onClick={(e) => {
e.stopPropagation();
handleHideRoute(fId);
}}
className={`p-1 rounded-full transition-colors ${
isRouteHidden
? "text-yellow-600 hover:text-yellow-700 hover:bg-yellow-100"
: "text-gray-400 hover:text-yellow-600 hover:bg-yellow-100"
}`}
title={
isRouteHidden ? "Показать на карте" : "Скрыть с карты"
}
>
{isRouteHidden ? <Eye size={16} /> : <EyeOff size={16} />}
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
@@ -2495,6 +2928,17 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
const toggleSection = (id: string) =>
setActiveSection(activeSection === id ? null : id);
const [showSightsOptions, setShowSightsOptions] = useState(false);
const sightsOptionsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
return () => {
if (sightsOptionsTimeoutRef.current) {
clearTimeout(sightsOptionsTimeoutRef.current);
}
};
}, []);
const sections = [
{
id: "layers",
@@ -2534,20 +2978,60 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
icon: <Landmark size={20} />,
count: sortedSights.length,
sortControl: (
<div className="flex items-center space-x-2 p-3 bg-white border-b border-gray-200">
<label className="text-sm text-gray-700">Сортировка:</label>
<select
value={sightSort}
onChange={(e) => setSightSort(e.target.value as SortType)}
className="border rounded px-2 py-1 text-sm"
<div className="flex items-center justify-between gap-4 p-3 bg-white border-b border-gray-200">
<div className="flex items-center space-x-2">
<label className="text-sm text-gray-700">Сортировка:</label>
<select
value={sightSort}
onChange={(e) => setSightSort(e.target.value as SortType)}
className="border rounded px-2 py-1 text-sm"
>
<option value="name_asc">Имя </option>
<option value="name_desc">Имя </option>
<option value="created_asc">Дата создания </option>
<option value="created_desc">Дата создания </option>
<option value="updated_asc">Дата обновления </option>
<option value="updated_desc">Дата обновления </option>
</select>
</div>
<div
className="relative"
onMouseEnter={() => {
sightsOptionsTimeoutRef.current = setTimeout(() => {
setShowSightsOptions(true);
}, 1000);
}}
onMouseLeave={() => {
if (sightsOptionsTimeoutRef.current) {
clearTimeout(sightsOptionsTimeoutRef.current);
sightsOptionsTimeoutRef.current = null;
}
setShowSightsOptions(false);
}}
>
<option value="name_asc">Имя </option>
<option value="name_desc">Имя </option>
<option value="created_asc">Дата создания </option>
<option value="created_desc">Дата создания </option>
<option value="updated_asc">Дата обновления </option>
<option value="updated_desc">Дата обновления </option>
</select>
<button
className={`px-2 py-1 rounded text-sm transition-colors ${
mapStore.hideSightsByHiddenRoutes
? "bg-blue-100 text-blue-700 hover:bg-blue-200"
: "text-gray-600 hover:text-gray-800 hover:bg-gray-100"
}`}
onClick={() =>
mapStore.setHideSightsByHiddenRoutes(
!mapStore.hideSightsByHiddenRoutes
)
}
>
Скрыть
</button>
{showSightsOptions && (
<div className="absolute right-0 mt-2 w-50 bg-white border border-gray-200 rounded-md shadow-md p-3 z-5000">
<div className="text-xs text-gray-600">
Будут скрыты все достопримечательности, привязанные к
скрытым маршрутам.
</div>
</div>
)}
</div>
</div>
),
content: renderFeatureList(sortedSights, "sight", Landmark),
@@ -2854,6 +3338,27 @@ export const MapPage: React.FC = observer(() => {
}
}, [selectedCityId, mapServiceInstance, isDataLoading]);
// Перезагружаем данные при изменении настройки скрытия достопримечательностей
useEffect(() => {
if (mapServiceInstance && !isDataLoading) {
// Очищаем текущие объекты на карте
mapServiceInstance.pointSource.clear();
mapServiceInstance.lineSource.clear();
// Загружаем новые данные с учетом фильтрации достопримечательностей
mapServiceInstance.loadFeaturesFromApi(
mapStore.stations,
mapStore.routes,
mapStore.sights
);
}
}, [
mapStore.hideSightsByHiddenRoutes,
mapStore.hiddenRoutes.size,
mapServiceInstance,
isDataLoading,
]);
const showLoader = isMapLoading || isDataLoading;
const showContent = mapServiceInstance && !showLoader && !error;
const isAnythingSelected =

View File

@@ -562,7 +562,14 @@ const LinkedItemsContentsInner = <
size="small"
/>
}
label={String(item.name)}
label={
<div className="flex justify-between items-center w-full gap-10">
<p>{String(item.name)}</p>
<p className="text-xs text-gray-500 max-w-[300px] truncate text-ellipsis">
{String(item.description)}
</p>
</div>
}
sx={{
margin: 0,
"& .MuiFormControlLabel-label": {

View File

@@ -38,8 +38,8 @@ export const RouteCreatePage = observer(() => {
const [govRouteNumber, setGovRouteNumber] = useState("");
const [governorAppeal, setGovernorAppeal] = useState<string>("");
const [direction, setDirection] = useState("backward");
const [scaleMin, setScaleMin] = useState("");
const [scaleMax, setScaleMax] = useState("");
const [scaleMin, setScaleMin] = useState("10");
const [scaleMax, setScaleMax] = useState("100");
const [routeName, setRouteName] = useState("");
const [turn, setTurn] = useState("");
const [centerLat, setCenterLat] = useState("");
@@ -154,22 +154,75 @@ export const RouteCreatePage = observer(() => {
const handleCreateRoute = async () => {
try {
setIsLoading(true);
// Преобразуем значения в нужные типы
const carrier_id = Number(carrier);
const governor_appeal = Number(governorAppeal);
const scale_min = scaleMin ? Number(scaleMin) : undefined;
const scale_max = scaleMax ? Number(scaleMax) : undefined;
const rotate = turn ? Number(turn) : undefined;
const center_latitude = centerLat ? Number(centerLat) : undefined;
const center_longitude = centerLng ? Number(centerLng) : undefined;
const route_direction = direction === "forward";
// Валидация обязательных полей
if (!routeName.trim()) {
toast.error("Заполните название маршрута");
setIsLoading(false);
return;
}
if (!carrier) {
toast.error("Выберите перевозчика");
setIsLoading(false);
return;
}
if (!routeNumber.trim()) {
toast.error("Заполните номер маршрута");
setIsLoading(false);
return;
}
if (!govRouteNumber.trim()) {
toast.error("Заполните номер маршрута в Говорящем Городе");
setIsLoading(false);
return;
}
if (!governorAppeal) {
toast.error("Выберите статью для обращения к пассажирам");
setIsLoading(false);
return;
}
const validationResult = validateCoordinates(routeCoords);
if (validationResult !== true) {
toast.error(validationResult);
setIsLoading(false);
return;
}
// Валидация масштабов
const scale_min = scaleMin ? Number(scaleMin) : null;
const scale_max = scaleMax ? Number(scaleMax) : null;
console.log(scale_min, scale_max);
if (
scale_min === 0 ||
scale_max === 0 ||
scale_min === null ||
scale_max === null
) {
toast.error("Масштабы не могут быть равны 0");
setIsLoading(false);
return;
}
if (
scale_min !== null &&
scale_max !== null &&
scale_max !== undefined &&
scale_min > scale_max
) {
toast.error("Максимальный масштаб не может быть меньше минимального");
setIsLoading(false);
return;
}
// Преобразуем значения в нужные типы
const carrier_id = Number(carrier);
const governor_appeal = Number(governorAppeal);
const rotate = turn ? Number(turn) : undefined;
const center_latitude = centerLat ? Number(centerLat) : undefined;
const center_longitude = centerLng ? Number(centerLng) : undefined;
const route_direction = direction === "forward";
// Координаты маршрута как массив массивов чисел
const path = routeCoords
.trim()
@@ -194,8 +247,8 @@ export const RouteCreatePage = observer(() => {
governor_appeal,
route_name: routeName,
route_direction,
scale_min,
scale_max,
scale_min: scale_min !== null ? scale_min : 0,
scale_max: scale_max !== null ? scale_max : 0,
rotate,
center_latitude,
center_longitude,
@@ -371,14 +424,40 @@ export const RouteCreatePage = observer(() => {
<TextField
className="w-full"
label="Масштаб (мин)"
type="number"
value={scaleMin}
onChange={(e) => setScaleMin(e.target.value)}
onChange={(e) => {
const value = e.target.value;
setScaleMin(value);
// Если максимальный масштаб стал меньше минимального, обновляем его
if (value && scaleMax && Number(value) > Number(scaleMax)) {
setScaleMax(value);
}
}}
error={
scaleMin !== "" &&
scaleMax !== "" &&
Number(scaleMin) > Number(scaleMax)
}
required
helperText={
scaleMin !== "" &&
scaleMax !== "" &&
Number(scaleMin) > Number(scaleMax)
? "Минимальный масштаб не может быть больше максимального"
: ""
}
/>
<TextField
className="w-full"
label="Масштаб (макс)"
type="number"
value={scaleMax}
onChange={(e) => setScaleMax(e.target.value)}
required
onChange={(e) => {
const value = e.target.value;
setScaleMax(value);
}}
/>
<TextField

View File

@@ -74,10 +74,67 @@ export const RouteEditPage = observer(() => {
}, [editRouteData.path]);
const handleSave = async () => {
// Валидация обязательных полей
if (!editRouteData.route_name?.trim()) {
toast.error("Заполните название маршрута");
return;
}
if (!editRouteData.carrier_id) {
toast.error("Выберите перевозчика");
return;
}
if (!editRouteData.route_number?.trim()) {
toast.error("Заполните номер маршрута");
return;
}
if (!editRouteData.route_sys_number?.trim()) {
toast.error("Заполните номер маршрута в Говорящем Городе");
return;
}
if (!editRouteData.governor_appeal) {
toast.error("Выберите статью для обращения к пассажирам");
return;
}
const validationResult = validateCoordinates(coordinates);
if (validationResult !== true) {
toast.error(validationResult);
return;
}
// Валидация масштабов
if (
editRouteData.scale_min !== null &&
editRouteData.scale_min !== undefined &&
editRouteData.scale_max !== null &&
editRouteData.scale_max !== undefined &&
editRouteData.scale_min > editRouteData.scale_max
) {
toast.error("Максимальный масштаб не может быть меньше минимального");
return;
}
if (
editRouteData.scale_min === 0 ||
editRouteData.scale_max === 0 ||
editRouteData.scale_min === null ||
editRouteData.scale_max === null
) {
toast.error("Масштабы не могут быть равны 0");
setIsLoading(false);
return;
}
setIsLoading(true);
await routeStore.editRoute(Number(id));
toast.success("Маршрут успешно сохранен");
setIsLoading(false);
try {
await routeStore.editRoute(Number(id));
toast.success("Маршрут успешно сохранен");
} catch (error) {
console.error(error);
toast.error("Произошла ошибка при сохранении маршрута");
} finally {
setIsLoading(false);
}
};
const validateCoordinates = (value: string) => {
@@ -333,17 +390,33 @@ export const RouteEditPage = observer(() => {
<TextField
className="w-full"
label="Масштаб (мин)"
type="number"
value={editRouteData.scale_min ?? ""}
onChange={(e) =>
onChange={(e) => {
const value =
e.target.value === "" ? null : parseFloat(e.target.value);
routeStore.setEditRouteData({
scale_min:
e.target.value === "" ? null : parseFloat(e.target.value),
})
}
scale_min: value,
});
// Если максимальный масштаб стал меньше минимального, обновляем его
if (
value !== null &&
editRouteData.scale_max !== null &&
editRouteData.scale_max !== undefined &&
value > editRouteData.scale_max
) {
routeStore.setEditRouteData({
scale_max: value,
});
}
}}
required
/>
<TextField
className="w-full"
required
label="Масштаб (макс)"
type="number"
value={editRouteData.scale_max ?? ""}
onChange={(e) =>
routeStore.setEditRouteData({
@@ -351,6 +424,22 @@ export const RouteEditPage = observer(() => {
e.target.value === "" ? null : parseFloat(e.target.value),
})
}
error={
editRouteData.scale_min !== null &&
editRouteData.scale_min !== undefined &&
editRouteData.scale_max !== null &&
editRouteData.scale_max !== undefined &&
editRouteData.scale_max < editRouteData.scale_min
}
helperText={
editRouteData.scale_min !== null &&
editRouteData.scale_min !== undefined &&
editRouteData.scale_max !== null &&
editRouteData.scale_max !== undefined &&
editRouteData.scale_max < editRouteData.scale_min
? "Максимальный масштаб не может быть меньше минимального"
: ""
}
/>
<TextField
className="w-full"

View File

@@ -118,7 +118,7 @@ export function RightSidebar() {
borderRadius={2}
>
<Typography variant="h6" sx={{ mb: 2, color: "#fff" }} textAlign="center">
Детали о достопримечательностях
Настройка маршрута
</Typography>
<Stack spacing={2} direction="row" alignItems="center">

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,321 @@
import { useState, useEffect } from "react";
import {
Stack,
Typography,
Button,
Accordion,
AccordionSummary,
AccordionDetails,
useTheme,
TextField,
Autocomplete,
TableCell,
TableContainer,
Table,
TableHead,
TableRow,
Paper,
TableBody,
} from "@mui/material";
import { observer } from "mobx-react-lite";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { authInstance, languageStore, selectedCityStore } from "@shared";
type Field<T> = {
label: string;
data: keyof T;
render?: (value: any) => React.ReactNode;
};
type LinkedStationsProps<T> = {
parentId: string | number;
fields: Field<T>[];
setItemsParent?: (items: T[]) => void;
type: "show" | "edit";
onUpdate?: () => void;
disableCreation?: boolean;
updatedLinkedItems?: T[];
refresh?: number;
};
export const LinkedStations = <
T extends { id: number; name: string; [key: string]: any }
>(
props: LinkedStationsProps<T>
) => {
const theme = useTheme();
return (
<>
<Accordion sx={{ width: "100%" }}>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
background: theme.palette.background.paper,
borderBottom: `1px solid ${theme.palette.divider}`,
width: "100%",
}}
>
<Typography variant="subtitle1" fontWeight="bold">
Привязанные остановки
</Typography>
</AccordionSummary>
<AccordionDetails
sx={{ background: theme.palette.background.paper, width: "100%" }}
>
<Stack gap={2} width="100%">
<LinkedStationsContents {...props} />
</Stack>
</AccordionDetails>
</Accordion>
</>
);
};
const LinkedStationsContentsInner = <
T extends { id: number; name: string; [key: string]: any }
>({
parentId,
setItemsParent,
fields,
type,
onUpdate,
disableCreation = false,
updatedLinkedItems,
refresh,
}: LinkedStationsProps<T>) => {
const { language } = languageStore;
const [allItems, setAllItems] = useState<T[]>([]);
const [linkedItems, setLinkedItems] = useState<T[]>([]);
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {}, [error]);
const parentResource = "sight";
const childResource = "station";
const availableItems = allItems
.filter((item) => !linkedItems.some((linked) => linked.id === item.id))
.filter((item) => {
const selectedCityId = selectedCityStore.selectedCityId;
if (selectedCityId && "city_id" in item) {
return item.city_id === selectedCityId;
}
return true;
})
.sort((a, b) => a.name.localeCompare(b.name));
useEffect(() => {
if (updatedLinkedItems) {
setLinkedItems(updatedLinkedItems);
}
}, [updatedLinkedItems]);
useEffect(() => {
setItemsParent?.(linkedItems);
}, [linkedItems, setItemsParent]);
const linkItem = () => {
if (selectedItemId !== null) {
setError(null);
const requestData = {
station_id: selectedItemId,
};
authInstance
.post(`/${parentResource}/${parentId}/${childResource}`, requestData)
.then(() => {
const newItem = allItems.find((item) => item.id === selectedItemId);
if (newItem) {
setLinkedItems([...linkedItems, newItem]);
}
setSelectedItemId(null);
onUpdate?.();
})
.catch((error) => {
console.error("Error linking station:", error);
setError("Failed to link station");
});
}
};
const deleteItem = (itemId: number) => {
setError(null);
authInstance
.delete(`/${parentResource}/${parentId}/${childResource}`, {
data: { [`${childResource}_id`]: itemId },
})
.then(() => {
setLinkedItems(linkedItems.filter((item) => item.id !== itemId));
onUpdate?.();
})
.catch((error) => {
console.error("Error deleting station:", error);
setError("Failed to delete station");
});
};
useEffect(() => {
if (parentId) {
setIsLoading(true);
setError(null);
authInstance
.get(`/${parentResource}/${parentId}/${childResource}`)
.then((response) => {
setLinkedItems(response?.data || []);
})
.catch((error) => {
console.error("Error fetching linked stations:", error);
setError("Failed to load linked stations");
setLinkedItems([]);
})
.finally(() => {
setIsLoading(false);
});
}
}, [parentId, language, refresh]);
useEffect(() => {
if (type === "edit") {
setError(null);
authInstance
.get(`/${childResource}`)
.then((response) => {
setAllItems(response?.data || []);
})
.catch((error) => {
console.error("Error fetching all stations:", error);
setError("Failed to load available stations");
setAllItems([]);
});
}
}, [type]);
return (
<>
{linkedItems?.length > 0 && (
<TableContainer component={Paper} sx={{ width: "100%" }}>
<Table sx={{ width: "100%" }}>
<TableHead>
<TableRow>
<TableCell key="id" width="60px">
</TableCell>
{fields.map((field) => (
<TableCell key={String(field.data)}>{field.label}</TableCell>
))}
{type === "edit" && (
<TableCell width="120px">Действие</TableCell>
)}
</TableRow>
</TableHead>
<TableBody>
{linkedItems.map((item, index) => (
<TableRow key={item.id} hover>
<TableCell>{index + 1}</TableCell>
{fields.map((field, idx) => (
<TableCell key={String(field.data) + String(idx)}>
{field.render
? field.render(item[field.data])
: item[field.data]}
</TableCell>
))}
{type === "edit" && (
<TableCell>
<Button
variant="outlined"
color="error"
size="small"
onClick={(e) => {
e.stopPropagation();
deleteItem(item.id);
}}
>
Отвязать
</Button>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
{linkedItems.length === 0 && !isLoading && (
<Typography color="textSecondary" textAlign="center" py={2}>
Остановки не найдены
</Typography>
)}
{type === "edit" && !disableCreation && (
<Stack gap={2} mt={2}>
<Typography variant="subtitle1">Добавить остановку</Typography>
<Autocomplete
fullWidth
value={
availableItems?.find((item) => item.id === selectedItemId) || null
}
onChange={(_, newValue) => setSelectedItemId(newValue?.id || null)}
options={availableItems}
getOptionLabel={(item) => String(item.name)}
renderInput={(params) => (
<TextField {...params} label="Выберите остановку" fullWidth />
)}
isOptionEqualToValue={(option, value) => option.id === value?.id}
filterOptions={(options, { inputValue }) => {
const searchWords = inputValue
.toLowerCase()
.split(" ")
.filter(Boolean);
return options.filter((option) => {
const optionWords = String(option.name)
.toLowerCase()
.split(" ");
return searchWords.every((searchWord) =>
optionWords.some((word) => word.startsWith(searchWord))
);
});
}}
renderOption={(props, option) => (
<li {...props} key={option.id}>
{String(option.name)}
</li>
)}
/>
<Button
variant="contained"
onClick={linkItem}
disabled={!selectedItemId}
sx={{ alignSelf: "flex-start" }}
>
Добавить
</Button>
</Stack>
)}
{isLoading && (
<Typography color="textSecondary" textAlign="center" py={2}>
Загрузка...
</Typography>
)}
{error && (
<Typography color="error" textAlign="center" py={2}>
{error}
</Typography>
)}
</>
);
};
export const LinkedStationsContents = observer(
LinkedStationsContentsInner
) as typeof LinkedStationsContentsInner;

View File

@@ -1 +1,2 @@
export * from "./SightListPage";
export { LinkedStations } from "./LinkedStations";

View File

@@ -37,8 +37,8 @@ import { useEffect, useState } from "react";
import { toast } from "react-toastify";
// Компонент предупреждающего окна (перенесен сюда)
import { SaveWithoutCityAgree } from "@widgets";
import { LinkedStations } from "@pages";
export const InformationTab = observer(
({ value, index }: { value: number; index: number }) => {
@@ -62,7 +62,7 @@ export const InformationTab = observer(
"thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null
>(null);
const { cities } = cityStore;
// НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
useEffect(() => {}, [hardcodeType]);
@@ -119,16 +119,14 @@ export const InformationTab = observer(
updateSightInfo(language, content, common);
};
// НОВАЯ ФУНКЦИЯ: Фактическое сохранение (вызывается после подтверждения)
const executeSave = async () => {
await updateSight();
toast.success("Достопримечательность сохранена");
};
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или сохранения
const handleSave = async () => {
const isCityMissing = !sight.common.city_id;
// Проверяем названия на всех языках
const isNameMissing = !sight.ru.name || !sight.en.name || !sight.zh.name;
if (isCityMissing || isNameMissing) {
@@ -139,13 +137,11 @@ export const InformationTab = observer(
await executeSave();
};
// Обработчик "Да" в предупреждающем окне
const handleConfirmSave = async () => {
setIsSaveWarningOpen(false);
await executeSave();
};
// Обработчик "Нет" в предупреждающем окне
const handleCancelSave = () => {
setIsSaveWarningOpen(false);
};
@@ -275,6 +271,16 @@ export const InformationTab = observer(
/>
</Box>
<Box sx={{ width: "80%" }}>
{sight.common.id !== 0 && (
<LinkedStations
parentId={sight.common.id}
fields={[{ label: "Название", data: "name" }]}
type="edit"
/>
)}
</Box>
<Box
sx={{
display: "flex",
@@ -431,7 +437,7 @@ export const InformationTab = observer(
variant="contained"
color="success"
startIcon={<Save color="white" size={18} />}
onClick={handleSave} // Используем новую функцию-обработчик
onClick={handleSave}
>
Сохранить
</Button>
@@ -538,7 +544,6 @@ export const InformationTab = observer(
</Dialog>
)}
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
{isSaveWarningOpen && (
<SaveWithoutCityAgree
blocker={{
@@ -550,4 +555,4 @@ export const InformationTab = observer(
</>
);
}
);
);

File diff suppressed because one or more lines are too long