4 Commits

Author SHA1 Message Date
5298fb9f60 feat: Add description for stations in sightbar 2025-11-06 00:32:19 +03:00
c95a6517e9 fix: 01.11.25 MapPage update + sight/station relation + preview base 2025-11-06 00:21:45 +03:00
79f523e9cb hotfix-chunks (#17)
Co-authored-by: Микаэл Оганесян <mikaeloganesan@MacBook-Pro-Mikael.local>
Reviewed-on: #17
Reviewed-by: Илья Куприец <kkzemeow@gmail.com>
2025-10-31 14:25:46 +00:00
90f3d66b22 #14 Перепись редактирования и создания маршрута (#16)
Добавлено новое поле route_name:

Текстовые поля на двух страницах
Поле в списке маршрутов

Добавлено выбор видео на двух страниц вместе с редактором статей в виде модального окна

Модальное окно позволяет создать статью, выбрать готовую, отредактировать выбранную сразу на трех языках

Микаэл:

Пожалуйста, перепроверь код, вдруг чего найдешь как улучшить

+

захости локально и потыкай пж:

создай с 0 маршрут и прикрепи к нему созданную / какую-нибудь статью с видео, можешь попробовать загрузить либо взять готовое

после того как создашь, попробуй потыкать и поменять чего-нибудь

(проще обьясню: представь, что ты Руслан)

Reviewed-on: #16
Reviewed-by: Микаэл Оганесян <15lu.akari@unprism.ru>
Co-authored-by: fisenko <kkzemeow@gmail.com>
Co-committed-by: fisenko <kkzemeow@gmail.com>
2025-10-31 11:13:08 +00:00
11 changed files with 2905 additions and 150 deletions

View File

@@ -6,7 +6,7 @@ import React, {
useMemo, useMemo,
} from "react"; } from "react";
import { useNavigate } from "react-router-dom"; 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 TileLayer from "ol/layer/Tile";
import OSM from "ol/source/OSM"; import OSM from "ol/source/OSM";
import VectorLayer from "ol/layer/Vector"; import VectorLayer from "ol/layer/Vector";
@@ -48,6 +48,9 @@ import {
InfoIcon, InfoIcon,
X, X,
Loader2, Loader2,
EyeOff,
Eye,
Map as MapIcon,
} from "lucide-react"; } from "lucide-react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { singleClick, doubleClick } from "ol/events/condition"; import { singleClick, doubleClick } from "ol/events/condition";
@@ -136,6 +139,7 @@ interface ApiRoute {
interface ApiStation { interface ApiStation {
id: number; id: number;
name: string; name: string;
description?: string;
latitude: number; latitude: number;
longitude: number; longitude: number;
city_id: number; city_id: number;
@@ -162,14 +166,71 @@ export type SortType =
| "updated_asc" | "updated_asc"
| "updated_desc"; | "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 { class MapStore {
constructor() { constructor() {
makeAutoObservable(this); 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[] = []; routes: ApiRoute[] = [];
stations: ApiStation[] = []; stations: ApiStation[] = [];
sights: ApiSight[] = []; 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"; stationSort: SortType = "name_asc";
@@ -297,12 +358,23 @@ class MapStore {
get filteredSights(): ApiSight[] { get filteredSights(): ApiSight[] {
const selectedCityId = selectedCityStore.selectedCityId; const selectedCityId = selectedCityStore.selectedCityId;
if (!selectedCityId) { const cityFiltered = !selectedCityId
return this.sortedSights; ? 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 () => { getRoutes = async () => {
@@ -324,6 +396,54 @@ class MapStore {
this.routes = this.routes.sort((a, b) => this.routes = this.routes.sort((a, b) =>
a.route_number.localeCompare(b.route_number) 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 () => { getStations = async () => {
@@ -430,8 +550,8 @@ class MapStore {
rotate: 0, rotate: 0,
route_direction: false, route_direction: false,
route_sys_number: route_number, route_sys_number: route_number,
scale_max: 0, scale_max: 100,
scale_min: 0, scale_min: 10,
}; };
await routeStore.createRoute(routeData); await routeStore.createRoute(routeData);
@@ -651,7 +771,7 @@ interface MapServiceConfig {
type FeatureType = "station" | "route" | "sight"; type FeatureType = "station" | "route" | "sight";
class MapService { class MapService {
private map: Map | null; private map: OLMap | null;
public pointSource: VectorSource<Feature<Point>>; public pointSource: VectorSource<Feature<Point>>;
public lineSource: VectorSource<Feature<LineString>>; public lineSource: VectorSource<Feature<LineString>>;
public clusterLayer: VectorLayer<Cluster>; // Public for the deselect handler public clusterLayer: VectorLayer<Cluster>; // Public for the deselect handler
@@ -943,7 +1063,7 @@ class MapService {
const initialCenter = storedPosition?.center || config.center; const initialCenter = storedPosition?.center || config.center;
const initialZoom = storedPosition?.zoom || config.zoom; const initialZoom = storedPosition?.zoom || config.zoom;
this.map = new Map({ this.map = new OLMap({
target: config.target, target: config.target,
layers: [ layers: [
new TileLayer({ source: new OSM() }), new TileLayer({ source: new OSM() }),
@@ -1251,28 +1371,38 @@ class MapService {
} }
} }
// Стандартная логика выделения для одиночных объектов (или с Ctrl) // При Ctrl+клик сохраняем предыдущие выделения и добавляем/удаляем только изменённые
// При обычном клике создаём новый набор
const newSelectedIds = ctrlKey const newSelectedIds = ctrlKey
? new Set(this.selectedIds) ? new Set(this.selectedIds)
: new Set<string | number>(); : new Set<string | number>();
// Добавляем новые выбранные элементы
e.selected.forEach((feature) => { e.selected.forEach((feature) => {
const originalFeatures = feature.get("features"); const originalFeatures = feature.get("features");
let targetId: string | number | undefined; let targetId: string | number | undefined;
if (originalFeatures && originalFeatures.length > 0) { if (originalFeatures && originalFeatures.length > 0) {
// Это фича из кластера (может быть и одна)
targetId = originalFeatures[0].getId(); targetId = originalFeatures[0].getId();
} else { } else {
// Это линия или что-то не из кластера
targetId = feature.getId(); targetId = feature.getId();
} }
if (targetId !== undefined) { if (targetId !== undefined) {
// При Ctrl+клик: если элемент уже был выбран, снимаем его (toggle)
// Если не был выбран, добавляем
if (ctrlKey && newSelectedIds.has(targetId)) {
newSelectedIds.delete(targetId);
} else {
newSelectedIds.add(targetId); newSelectedIds.add(targetId);
} }
}
}); });
// При Ctrl+клик игнорируем deselected, так как Select interaction может снимать
// предыдущие выделения, но мы хотим их сохранить
// При обычном клике удаляем deselected элементы
if (!ctrlKey) {
e.deselected.forEach((feature) => { e.deselected.forEach((feature) => {
const originalFeatures = feature.get("features"); const originalFeatures = feature.get("features");
let targetId: string | number | undefined; let targetId: string | number | undefined;
@@ -1287,6 +1417,7 @@ class MapService {
newSelectedIds.delete(targetId); newSelectedIds.delete(targetId);
} }
}); });
}
this.setSelectedIds(newSelectedIds); this.setSelectedIds(newSelectedIds);
}); });
@@ -1373,8 +1504,33 @@ class MapService {
const filteredSights = mapStore.filteredSights; const filteredSights = mapStore.filteredSights;
const filteredRoutes = mapStore.filteredRoutes; 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) => { filteredStations.forEach((station) => {
if (station.longitude == null || station.latitude == null) return; if (station.longitude == null || station.latitude == null) return;
// Пропускаем станции, которые принадлежат только скрытым маршрутам
if (!stationsInVisibleRoutes.has(station.id)) {
skippedStations++;
return;
}
const point = new Point( const point = new Point(
transform( transform(
[station.longitude, station.latitude], [station.longitude, station.latitude],
@@ -1405,6 +1561,10 @@ class MapService {
filteredRoutes.forEach((route) => { filteredRoutes.forEach((route) => {
if (!route.path || route.path.length === 0) return; if (!route.path || route.path.length === 0) return;
// Пропускаем скрытые маршруты
if (mapStore.hiddenRoutes.has(route.id)) return;
const coordinates = route.path const coordinates = route.path
.filter((c) => c && c[0] != null && c[1] != null) .filter((c) => c && c[0] != null && c[1] != null)
.map((c: [number, number]) => .map((c: [number, number]) =>
@@ -1423,6 +1583,10 @@ class MapService {
lineFeatures.push(lineFeature); lineFeatures.push(lineFeature);
}); });
console.log(
`[loadFeaturesFromApi] Skipped ${skippedStations} stations (belonging only to hidden routes)`
);
this.pointSource.addFeatures(pointFeatures); this.pointSource.addFeatures(pointFeatures);
this.lineSource.addFeatures(lineFeatures); this.lineSource.addFeatures(lineFeatures);
@@ -1880,10 +2044,14 @@ class MapService {
this.selectInteraction.getFeatures().clear(); this.selectInteraction.getFeatures().clear();
ids.forEach((id) => { ids.forEach((id) => {
const lineFeature = this.lineSource.getFeatureById(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); const pointFeature = this.pointSource.getFeatureById(id);
if (pointFeature) this.selectInteraction.getFeatures().push(pointFeature); if (pointFeature) {
this.selectInteraction.getFeatures().push(pointFeature);
}
}); });
this.modifyInteraction.setActive( this.modifyInteraction.setActive(
@@ -1915,7 +2083,7 @@ class MapService {
if (this.mode === "lasso") this.deactivateLasso(); if (this.mode === "lasso") this.deactivateLasso();
else this.activateLasso(); else this.activateLasso();
} }
public getMap(): Map | null { public getMap(): OLMap | null {
return this.map; return this.map;
} }
@@ -2208,6 +2376,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
) )
), ),
name: station.name, name: station.name,
description: station.description || "",
}); });
feature.setId(`station-${station.id}`); feature.setId(`station-${station.id}`);
feature.set("featureType", "station"); feature.set("featureType", "station");
@@ -2263,11 +2432,26 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
}, [allFeatures, searchQuery]); }, [allFeatures, searchQuery]);
const handleFeatureClick = useCallback( const handleFeatureClick = useCallback(
(id: string | number) => { (id: string | number, event?: React.MouseEvent) => {
if (!mapService) return; if (!mapService) return;
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.selectFeature(id);
}
}, },
[mapService] [mapService, selectedIds, setSelectedIds]
); );
const handleDeleteFeature = useCallback( const handleDeleteFeature = useCallback(
@@ -2313,6 +2497,217 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
[navigate] [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>>( const sortFeaturesByType = <T extends Feature<Geometry>>(
features: T[], features: T[],
sortType: SortType sortType: SortType
@@ -2411,11 +2806,28 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
const isSelected = selectedFeature?.getId() === fId; const isSelected = selectedFeature?.getId() === fId;
const isChecked = selectedIds.has(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);
const description = feature.get("description") as
| string
| undefined;
const showDescription =
featureType === "station" &&
description &&
description.trim() !== "";
return ( return (
<div <div
key={String(fId)} key={String(fId)}
data-feature-id={fId} data-feature-id={fId}
className={`flex items-start p-2 rounded-md group transition-colors duration-150 ${ className={`flex items-start p-2 rounded-md group transition-colors duration-150 relative ${
isSelected isSelected
? "bg-orange-100 border border-orange-300" ? "bg-orange-100 border border-orange-300"
: "hover:bg-blue-50" : "hover:bg-blue-50"
@@ -2433,7 +2845,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
</div> </div>
<div <div
className="flex flex-col text-gray-800 text-sm flex-grow mr-2 min-w-0 cursor-pointer" 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"> <div className="flex items-center">
<IconComponent <IconComponent
@@ -2458,6 +2870,11 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
{fName} {fName}
</span> </span>
</div> </div>
{showDescription && (
<div className="mt-1 text-xs text-gray-600 line-clamp-2">
{description}
</div>
)}
</div> </div>
<div className="flex-shrink-0 flex items-center space-x-1 opacity-60 group-hover:opacity-100"> <div className="flex-shrink-0 flex items-center space-x-1 opacity-60 group-hover:opacity-100">
<button <button
@@ -2472,6 +2889,37 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
> >
<Pencil size={16} /> <Pencil size={16} />
</button> </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 <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -2495,6 +2943,17 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
const toggleSection = (id: string) => const toggleSection = (id: string) =>
setActiveSection(activeSection === id ? null : id); 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 = [ const sections = [
{ {
id: "layers", id: "layers",
@@ -2534,7 +2993,8 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
icon: <Landmark size={20} />, icon: <Landmark size={20} />,
count: sortedSights.length, count: sortedSights.length,
sortControl: ( sortControl: (
<div className="flex items-center space-x-2 p-3 bg-white border-b border-gray-200"> <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> <label className="text-sm text-gray-700">Сортировка:</label>
<select <select
value={sightSort} value={sightSort}
@@ -2549,6 +3009,45 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
<option value="updated_desc">Дата обновления </option> <option value="updated_desc">Дата обновления </option>
</select> </select>
</div> </div>
<div
className="relative"
onMouseEnter={() => {
sightsOptionsTimeoutRef.current = setTimeout(() => {
setShowSightsOptions(true);
}, 1000);
}}
onMouseLeave={() => {
if (sightsOptionsTimeoutRef.current) {
clearTimeout(sightsOptionsTimeoutRef.current);
sightsOptionsTimeoutRef.current = null;
}
setShowSightsOptions(false);
}}
>
<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), content: renderFeatureList(sortedSights, "sight", Landmark),
}, },
@@ -2854,6 +3353,27 @@ export const MapPage: React.FC = observer(() => {
} }
}, [selectedCityId, mapServiceInstance, isDataLoading]); }, [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 showLoader = isMapLoading || isDataLoading;
const showContent = mapServiceInstance && !showLoader && !error; const showContent = mapServiceInstance && !showLoader && !error;
const isAnythingSelected = const isAnythingSelected =

View File

@@ -562,7 +562,14 @@ const LinkedItemsContentsInner = <
size="small" 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={{ sx={{
margin: 0, margin: 0,
"& .MuiFormControlLabel-label": { "& .MuiFormControlLabel-label": {

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -3,56 +3,6 @@ import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import path from "path"; import path from "path";
type ManualChunksFn = (id: string, api: { getModuleIds: () => Iterable<string> }) => string | undefined;
const manualChunks: ManualChunksFn = (id) => {
if (id.includes('node_modules')) {
if (
id.includes('three.') ||
id.includes('@react-three') ||
id.includes('ol/') ||
id.includes('mapbox-gl') ||
id.includes('@babel/runtime')
) {
return 'vendor-3d-maps';
}
if (id.includes('codemirror') || id.includes('react-codemirror2')) {
return 'vendor-codemirror';
}
if (id.includes('hls.js')) {
return 'vendor-hls';
}
if (id.includes('pixi.js')) {
return 'vendor-pixijs';
}
if (id.includes('@mui/material') || id.includes('@mui/icons-material') || id.includes('@mui/x-data-grid')) {
return 'vendor-mui-core';
}
if (id.includes('/react/') || id.includes('/react-dom/')) {
return 'vendor-react-core';
}
if (id.includes('react-router') || id.includes('history')) {
return 'vendor-router';
}
return 'vendor-common-remainder';
}
if (id.includes('src/pages/')) {
const pathParts = id.split('src/pages/');
if (pathParts.length > 1) {
return 'page-' + pathParts[1].split('/')[0].toLowerCase();
}
}
};
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
react(), react(),
@@ -70,16 +20,7 @@ export default defineConfig({
}, },
build: { build: {
chunkSizeWarningLimit: 2000, chunkSizeWarningLimit: 5000,
},
});
rollupOptions: {
output: {
manualChunks,
entryFileNames: `assets/[name]-[hash].js`,
chunkFileNames: `assets/[name]-[hash].js`,
assetFileNames: `assets/[name]-[hash].[ext]`,
}
}
}
}) as UserConfigExport;