Compare commits

..

2 Commits

Author SHA1 Message Date
a357994025 feat: Update pop-up logic 2025-10-02 04:45:43 +03:00
7382a85082 feat: Update city logic in map page with scrollbar 2025-10-02 04:38:15 +03:00
7 changed files with 521 additions and 415 deletions

View File

@@ -1,62 +0,0 @@
# Селектор городов
## Описание функциональности
Добавлена функциональность выбора города в админ-панели "Белые ночи":
### Основные возможности:
1. **Селектор городов в шапке приложения**
- Расположен рядом с именем пользователя в верхней части приложения
- Показывает список всех доступных городов
- Имеет иконку MapPin для лучшего UX
2. **Сохранение в localStorage**
- Выбранный город автоматически сохраняется в localStorage
- При перезагрузке страницы выбранный город восстанавливается
3. **Автоматическое использование в формах**
- При создании новой станции выбранный город автоматически подставляется
- При создании нового перевозчика выбранный город автоматически подставляется
- Пользователь может изменить город в форме при необходимости
### Технические детали:
#### Новые компоненты и сторы:
- `SelectedCityStore` - стор для управления выбранным городом
- `CitySelector` - компонент селектора городов
- `useSelectedCity` - хук для удобного доступа к выбранному городу
#### Интеграция:
- Селектор добавлен в `Layout` компонент
- Интегрирован в `StationCreatePage` и `CarrierCreatePage`
- Использует существующий `CityStore` для получения списка городов
#### Файлы, которые были изменены:
- `src/widgets/Layout/index.tsx` - добавлен CitySelector
- `src/pages/Station/StationCreatePage/index.tsx` - автоматическая подстановка города
- `src/pages/Carrier/CarrierCreatePage/index.tsx` - автоматическая подстановка города
- `src/shared/store/index.ts` - добавлен экспорт SelectedCityStore
- `src/widgets/index.ts` - добавлен экспорт CitySelector
- `src/shared/index.tsx` - добавлен экспорт hooks
#### Новые файлы:
- `src/shared/store/SelectedCityStore/index.ts`
- `src/widgets/CitySelector/index.tsx`
- `src/shared/hooks/useSelectedCity.ts`
- `src/shared/hooks/index.ts`
### Использование:
1. Пользователь выбирает город в селекторе в шапке приложения
2. Выбранный город сохраняется в localStorage
3. При создании новой станции или перевозчика выбранный город автоматически подставляется в форму
4. Пользователь может изменить город в форме если нужно
Функциональность полностью интегрирована и готова к использованию.

View File

@@ -52,7 +52,7 @@ import { FeatureLike } from "ol/Feature";
import { createEmpty, extend, getCenter } from "ol/extent";
// --- CUSTOM SCROLLBAR STYLES ---
const scrollbarHideStyles = `
const scrollbarStyles = `
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
@@ -60,11 +60,34 @@ const scrollbarHideStyles = `
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-visible {
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f1f5f9;
}
.scrollbar-visible::-webkit-scrollbar {
width: 8px;
}
.scrollbar-visible::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 4px;
}
.scrollbar-visible::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.scrollbar-visible::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
`;
if (typeof document !== "undefined") {
const styleElement = document.createElement("style");
styleElement.textContent = scrollbarHideStyles;
styleElement.textContent = scrollbarStyles;
document.head.appendChild(styleElement);
}
@@ -73,7 +96,13 @@ if (typeof document !== "undefined") {
import { languageInstance } from "@shared";
import { makeAutoObservable } from "mobx";
import { stationsStore, routeStore, sightsStore, menuStore } from "@shared";
import {
stationsStore,
routeStore,
sightsStore,
menuStore,
selectedCityStore,
} from "@shared";
// Функция для сброса кешей карты
export const clearMapCaches = () => {
@@ -101,6 +130,7 @@ interface ApiStation {
name: string;
latitude: number;
longitude: number;
city_id: number;
created_at?: string;
}
@@ -110,12 +140,12 @@ interface ApiSight {
description: string;
latitude: number;
longitude: number;
city_id: number;
created_at?: string;
}
export type SortType = "name_asc" | "name_desc" | "date_asc" | "date_desc";
class MapStore {
constructor() {
makeAutoObservable(this);
@@ -151,16 +181,32 @@ class MapStore {
return sorted.sort((a, b) => b.name.localeCompare(a.name));
case "date_asc":
return sorted.sort((a, b) => {
if ('created_at' in a && 'created_at' in b && a.created_at && b.created_at) {
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
if (
"created_at" in a &&
"created_at" in b &&
a.created_at &&
b.created_at
) {
return (
new Date(a.created_at).getTime() -
new Date(b.created_at).getTime()
);
}
// Фоллбэк: сортировка по ID, если дата недоступна
return a.id - b.id;
});
case "date_desc":
return sorted.sort((a, b) => {
if ('created_at' in a && 'created_at' in b && a.created_at && b.created_at) {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
if (
"created_at" in a &&
"created_at" in b &&
a.created_at &&
b.created_at
) {
return (
new Date(b.created_at).getTime() -
new Date(a.created_at).getTime()
);
}
// Фоллбэк: сортировка по ID, если дата недоступна
return b.id - a.id;
@@ -179,6 +225,26 @@ class MapStore {
return this.sortFeatures(this.sights, this.sightSort);
}
// ГЕТТЕРЫ ДЛЯ ФИЛЬТРАЦИИ ПО ГОРОДУ
get filteredStations(): ApiStation[] {
const selectedCityId = selectedCityStore.selectedCityId;
if (!selectedCityId) {
return this.sortedStations;
}
return this.sortedStations.filter(
(station) => station.city_id === selectedCityId
);
}
get filteredSights(): ApiSight[] {
const selectedCityId = selectedCityStore.selectedCityId;
if (!selectedCityId) {
return this.sortedSights;
}
return this.sortedSights.filter(
(sight) => sight.city_id === selectedCityId
);
}
getRoutes = async () => {
const response = await languageInstance("ru").get("/route");
@@ -243,7 +309,15 @@ class MapStore {
address: "",
system_name: name,
});
stationsStore.setCreateCommonData({ latitude, longitude, city_id: 1 });
const selectedCityId = selectedCityStore.selectedCityId || 1;
const selectedCityName =
selectedCityStore.selectedCityName || "Неизвестный город";
stationsStore.setCreateCommonData({
latitude,
longitude,
city_id: selectedCityId,
city: selectedCityName,
});
await stationsStore.createStation();
createdItem =
@@ -290,7 +364,11 @@ class MapStore {
sightsStore.updateCreateSight("en", { name, address: "" });
sightsStore.updateCreateSight("zh", { name, address: "" });
await sightsStore.createSightAction(1, { latitude, longitude });
const selectedCityId = selectedCityStore.selectedCityId || 1;
await sightsStore.createSightAction(selectedCityId, {
latitude,
longitude,
});
createdItem = sightsStore.sights[sightsStore.sights.length - 1];
} else {
throw new Error(`Unknown feature type for creation: ${featureType}`);
@@ -1181,9 +1259,9 @@ class MapService {
}
public loadFeaturesFromApi(
apiStations: typeof mapStore.stations,
_apiStations: typeof mapStore.stations,
apiRoutes: typeof mapStore.routes,
apiSights: typeof mapStore.sights
_apiSights: typeof mapStore.sights
): void {
if (!this.map) return;
@@ -1191,7 +1269,11 @@ class MapService {
const pointFeatures: Feature<Point>[] = [];
const lineFeatures: Feature<LineString>[] = [];
apiStations.forEach((station) => {
// Используем фильтрованные данные из mapStore
const filteredStations = mapStore.filteredStations;
const filteredSights = mapStore.filteredSights;
filteredStations.forEach((station) => {
if (station.longitude == null || station.latitude == null) return;
const point = new Point(
transform(
@@ -1206,7 +1288,7 @@ class MapService {
pointFeatures.push(feature);
});
apiSights.forEach((sight) => {
filteredSights.forEach((sight) => {
if (sight.longitude == null || sight.latitude == null) return;
const point = new Point(
transform([sight.longitude, sight.latitude], "EPSG:4326", projection)
@@ -1329,6 +1411,7 @@ class MapService {
name: properties.name,
latitude: lat,
longitude: lon,
city_id: properties.city_id || 1, // Default city_id if not available
});
} else if (featureType === "sight") {
const coords = (geometry as Point).getCoordinates();
@@ -1339,6 +1422,7 @@ class MapService {
description: properties.description,
latitude: lat,
longitude: lon,
city_id: properties.city_id || 1, // Default city_id if not available
});
} else if (featureType === "route") {
const coords = (geometry as LineString).getCoordinates();
@@ -2188,7 +2272,8 @@ interface MapSightbarProps {
activeSection: string | null;
setActiveSection: (section: string | null) => void;
}
const MapSightbar: React.FC<MapSightbarProps> = observer(({
const MapSightbar: React.FC<MapSightbarProps> = observer(
({
mapService,
mapFeatures,
selectedFeature,
@@ -2203,21 +2288,71 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(({
const [sightSort, setSightSort] = useState<SortType>("name_asc");
const { isOpen } = menuStore;
const { selectedCityId } = selectedCityStore;
const actualFeatures = useMemo(
() => mapFeatures.filter((f) => !f.get("isProxy")),
[mapFeatures]
);
// Создаем объединенный список всех объектов для поиска
const allFeatures = useMemo(() => {
const stations = mapStore.filteredStations.map((station) => {
const feature = new Feature({
geometry: new Point(
transform(
[station.longitude, station.latitude],
"EPSG:4326",
"EPSG:3857"
)
),
name: station.name,
});
feature.setId(`station-${station.id}`);
feature.set("featureType", "station");
feature.set("created_at", station.created_at);
return feature;
});
const sights = mapStore.filteredSights.map((sight) => {
const feature = new Feature({
geometry: new Point(
transform(
[sight.longitude, sight.latitude],
"EPSG:4326",
"EPSG:3857"
)
),
name: sight.name,
description: sight.description,
});
feature.setId(`sight-${sight.id}`);
feature.set("featureType", "sight");
feature.set("created_at", sight.created_at);
return feature;
});
const lines = actualFeatures.filter(
(f) => f.get("featureType") === "route"
);
return [...stations, ...sights, ...lines];
}, [
mapStore.filteredStations,
mapStore.filteredSights,
actualFeatures,
selectedCityId,
mapStore,
]);
const filteredFeatures = useMemo(() => {
if (!searchQuery.trim()) return actualFeatures;
return actualFeatures.filter((f) =>
if (!searchQuery.trim()) return allFeatures;
return allFeatures.filter((f) =>
((f.get("name") as string) || "")
.toLowerCase()
.includes(searchQuery.toLowerCase())
);
}, [actualFeatures, searchQuery]);
}, [allFeatures, searchQuery]);
const handleFeatureClick = useCallback(
(id: string | number) => {
@@ -2279,13 +2414,13 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(({
case "name_asc":
return sorted.sort((a, b) =>
((a.get("name") as string) || "").localeCompare(
((b.get("name") as string) || "")
(b.get("name") as string) || ""
)
);
case "name_desc":
return sorted.sort((a, b) =>
((b.get("name") as string) || "").localeCompare(
((a.get("name") as string) || "")
(a.get("name") as string) || ""
)
);
case "date_asc":
@@ -2316,7 +2451,9 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(({
const stations = filteredFeatures.filter(
(f) => f.get("featureType") === "station"
);
const lines = filteredFeatures.filter((f) => f.get("featureType") === "route");
const lines = filteredFeatures.filter(
(f) => f.get("featureType") === "route"
);
const sights = filteredFeatures.filter(
(f) => f.get("featureType") === "sight"
);
@@ -2379,7 +2516,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(({
/>
<span
className={`font-medium whitespace-nowrap overflow-x-auto block
scrollbar-thin scrollbar-thumb-gray-200 scrollbar-track-transparent`}
scrollbar-visible`}
title={fName}
>
{fName}
@@ -2391,7 +2528,8 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(({
onClick={(e) => {
e.stopPropagation();
const featureTypeVal = feature.get("featureType");
if (featureTypeVal) handleEditFeature(featureTypeVal, fId);
if (featureTypeVal)
handleEditFeature(featureTypeVal, fId);
}}
className="p-1 rounded-full text-gray-400 hover:text-blue-600 hover:bg-blue-100 transition-colors"
title="Редактировать детали"
@@ -2427,10 +2565,9 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(({
title: `Остановки (${sortedStations.length})`,
icon: <Bus size={20} />,
count: sortedStations.length,
content: (
<>
<div className="flex items-center space-x-2 mb-2">
<label>Сортировка:</label>
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={stationSort}
onChange={(e) => setStationSort(e.target.value as SortType)}
@@ -2440,15 +2577,15 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(({
<option value="name_desc">Имя </option>
</select>
</div>
{renderFeatureList(sortedStations, "station", MapPin)}
</>
),
content: renderFeatureList(sortedStations, "station", MapPin),
},
{
id: "lines",
title: `Маршруты (${lines.length})`,
icon: <RouteIcon size={20} />,
count: lines.length,
sortControl: null,
content: renderFeatureList(lines, "route", ArrowRightLeft),
},
{
@@ -2456,10 +2593,9 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(({
title: `Достопримечательности (${sortedSights.length})`,
icon: <Landmark size={20} />,
count: sortedSights.length,
content: (
<>
<div className="flex items-center space-x-2 mb-2">
<label>Сортировка:</label>
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)}
@@ -2469,9 +2605,8 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(({
<option value="name_desc">Имя </option>
</select>
</div>
{renderFeatureList(sortedSights, "sight", Landmark)}
</>
),
content: renderFeatureList(sortedSights, "sight", Landmark),
},
];
@@ -2480,7 +2615,11 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(({
}, [isOpen]);
return (
<div className={`${isOpen ? "w-[360px]" : "w-[590px]"} transition-all duration-300 ease-in-out relative bg-gray-50 shadow-lg flex flex-col border-l border-gray-200 h-[90vh]`}>
<div
className={`${
isOpen ? "w-[360px]" : "w-[590px]"
} transition-all duration-300 ease-in-out relative bg-gray-50 shadow-lg flex flex-col border-l border-gray-200 h-[90vh]`}
>
<div className="p-4 bg-gray-700 text-white">
<h2 className="text-lg font-semibold">Панель управления</h2>
</div>
@@ -2493,7 +2632,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(({
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex-1 flex flex-col min-h-0 overflow-y-auto">
<div className="flex-1 flex flex-col min-h-0 overflow-y-auto scrollbar-visible">
{filteredFeatures.length === 0 && searchQuery ? (
<div className="p-4 text-center text-gray-500">
Ничего не найдено.
@@ -2536,14 +2675,19 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(({
</span>
</button>
<div
className={`overflow-y-auto scrollbar-hide bg-white ${
activeSection === s.id ? "block" : "hidden"
}`}
>
<div className="p-3 text-sm text-gray-600">{s.content}</div>
{activeSection === s.id && (
<>
{s.sortControl && (
<div className="flex-shrink-0">{s.sortControl}</div>
)}
<div className="overflow-y-auto scrollbar-visible bg-white flex-1 min-h-0">
<div className="p-3 text-sm text-gray-600">
{s.content}
</div>
</div>
</>
)}
</div>
)
)
)}
@@ -2561,9 +2705,10 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(({
)}
</div>
);
});
}
);
// --- MAP PAGE COMPONENT ---
export const MapPage: React.FC = () => {
export const MapPage: React.FC = observer(() => {
const mapRef = useRef<HTMLDivElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const [mapServiceInstance, setMapServiceInstance] =
@@ -2584,6 +2729,8 @@ export const MapPage: React.FC = () => {
string | null
>(() => getStoredActiveSection() || "layers");
const { selectedCityId } = selectedCityStore;
const handleFeaturesChange = useCallback(
(feats: Feature<Geometry>[]) => setMapFeatures([...feats]),
[]
@@ -2750,6 +2897,22 @@ export const MapPage: React.FC = () => {
saveActiveSection(activeSectionFromParent);
}, [activeSectionFromParent]);
// Перезагружаем данные при изменении города
useEffect(() => {
if (mapServiceInstance && !isDataLoading) {
// Очищаем текущие объекты на карте
mapServiceInstance.pointSource.clear();
mapServiceInstance.lineSource.clear();
// Загружаем новые данные с фильтрацией по городу
mapServiceInstance.loadFeaturesFromApi(
mapStore.stations,
mapStore.routes,
mapStore.sights
);
}
}, [selectedCityId, mapServiceInstance, isDataLoading]);
const showLoader = isMapLoading || isDataLoading;
const showContent = mapServiceInstance && !showLoader && !error;
const isAnythingSelected =
@@ -2866,4 +3029,4 @@ export const MapPage: React.FC = () => {
)}
</div>
);
};
});

View File

@@ -67,7 +67,8 @@ export const StationCreatePage = observer(() => {
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или создания
const handleCreate = async () => {
const isCityMissing = !createStationData.common.city_id;
const isNameMissing = !createStationData[language].name;
// Проверяем названия на всех языках
const isNameMissing = !createStationData.ru.name || !createStationData.en.name || !createStationData.zh.name;
if (isCityMissing || isNameMissing) {
setIsSaveWarningOpen(true);

View File

@@ -67,7 +67,8 @@ export const StationEditPage = observer(() => {
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или редактирования
const handleEdit = async () => {
const isCityMissing = !editStationData.common.city_id;
const isNameMissing = !editStationData[language].name;
// Проверяем названия на всех языках
const isNameMissing = !editStationData.ru.name || !editStationData.en.name || !editStationData.zh.name;
if (isCityMissing || isNameMissing) {
setIsSaveWarningOpen(true);

View File

@@ -5,9 +5,10 @@ export const SaveWithoutCityAgree = ({ blocker }: { blocker: any }) => {
<div className="fixed top-0 left-0 w-screen h-screen flex justify-center items-center z-10000 bg-black/30">
<div className="bg-white p-4 w-140 rounded-lg flex flex-col gap-4 items-center">
<p className="text-black w-140 text-center">
Вы не указали город и/или не заполнили названия на всех языках.
Вы не указали город и/или не заполнили названия на всех языках
(русский, английский, китайский).
<br />
Сохранить достопримечательность без этой информации?
Сохранить без этой информации?
</p>
<div className="flex gap-4 justify-center">
<Button variant="contained" onClick={() => blocker.proceed()}>

View File

@@ -119,7 +119,8 @@ export const CreateInformationTab = observer(
const handleSave = async () => {
const isCityMissing = !sight.city_id;
const isNameMissing = !sight[language].name;
// Проверяем названия на всех языках
const isNameMissing = !sight.ru.name || !sight.en.name || !sight.zh.name;
if (isCityMissing || isNameMissing) {
setIsSaveWarningOpen(true);

View File

@@ -128,7 +128,8 @@ export const InformationTab = observer(
// ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или сохранения
const handleSave = async () => {
const isCityMissing = !sight.common.city_id;
const isNameMissing = !sight[language].name;
// Проверяем названия на всех языках
const isNameMissing = !sight.ru.name || !sight.en.name || !sight.zh.name;
if (isCityMissing || isNameMissing) {
setIsSaveWarningOpen(true);