fix: 01.11.25 MapPage update + sight/station relation + preview base
This commit is contained in:
@@ -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 =
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
1792
src/pages/Route/route-preview/web-gl/web-gl-version.tsx
Normal file
1792
src/pages/Route/route-preview/web-gl/web-gl-version.tsx
Normal file
File diff suppressed because it is too large
Load Diff
321
src/pages/Sight/LinkedStations.tsx
Normal file
321
src/pages/Sight/LinkedStations.tsx
Normal 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;
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./SightListPage";
|
||||
export { LinkedStations } from "./LinkedStations";
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user