feat: big major update

This commit is contained in:
2026-02-02 04:00:37 +03:00
parent bbab6fc46a
commit d557664b25
34 changed files with 1801 additions and 665 deletions

View File

@@ -8,8 +8,8 @@ import {
InputLabel, InputLabel,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react"; import { ArrowLeft, Loader2, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { import {
@@ -17,15 +17,14 @@ import {
cityStore, cityStore,
mediaStore, mediaStore,
languageStore, languageStore,
isMediaIdEmpty,
useSelectedCity, useSelectedCity,
} from "@shared";
import { useState, useEffect } from "react";
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
import {
SelectMediaDialog, SelectMediaDialog,
UploadMediaDialog, UploadMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
} from "@shared"; } from "@shared";
import { useState, useEffect } from "react";
import { ImageUploadCard, LanguageSwitcher } from "@widgets";
export const CarrierCreatePage = observer(() => { export const CarrierCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -56,7 +55,7 @@ export const CarrierCreatePage = observer(() => {
selectedCityId, selectedCityId,
createCarrierData[language].slogan, createCarrierData[language].slogan,
selectedMediaId || "", selectedMediaId || "",
language language,
); );
} }
}, [selectedCityId, createCarrierData.city_id]); }, [selectedCityId, createCarrierData.city_id]);
@@ -88,13 +87,17 @@ export const CarrierCreatePage = observer(() => {
createCarrierData.city_id, createCarrierData.city_id,
createCarrierData[language].slogan, createCarrierData[language].slogan,
media.id, media.id,
language language,
); );
}; };
const selectedMedia = selectedMediaId const selectedMedia =
selectedMediaId && !isMediaIdEmpty(selectedMediaId)
? mediaStore.media.find((m) => m.id === selectedMediaId) ? mediaStore.media.find((m) => m.id === selectedMediaId)
: null; : null;
const effectiveLogoUrl = isMediaIdEmpty(selectedMediaId)
? null
: selectedMedia?.id ?? selectedMediaId ?? null;
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
@@ -127,7 +130,7 @@ export const CarrierCreatePage = observer(() => {
e.target.value as number, e.target.value as number,
createCarrierData[language].slogan, createCarrierData[language].slogan,
selectedMediaId || "", selectedMediaId || "",
language language,
) )
} }
> >
@@ -151,7 +154,7 @@ export const CarrierCreatePage = observer(() => {
createCarrierData.city_id, createCarrierData.city_id,
createCarrierData[language].slogan, createCarrierData[language].slogan,
selectedMediaId || "", selectedMediaId || "",
language language,
) )
} }
/> />
@@ -168,7 +171,7 @@ export const CarrierCreatePage = observer(() => {
createCarrierData.city_id, createCarrierData.city_id,
createCarrierData[language].slogan, createCarrierData[language].slogan,
selectedMediaId || "", selectedMediaId || "",
language language,
) )
} }
/> />
@@ -184,7 +187,7 @@ export const CarrierCreatePage = observer(() => {
createCarrierData.city_id, createCarrierData.city_id,
e.target.value, e.target.value,
selectedMediaId || "", selectedMediaId || "",
language language,
) )
} }
/> />
@@ -193,10 +196,10 @@ export const CarrierCreatePage = observer(() => {
<ImageUploadCard <ImageUploadCard
title="Логотип перевозчика" title="Логотип перевозчика"
imageKey="thumbnail" imageKey="thumbnail"
imageUrl={selectedMedia?.id} imageUrl={effectiveLogoUrl}
onImageClick={() => { onImageClick={() => {
setIsPreviewMediaOpen(true); setIsPreviewMediaOpen(true);
setMediaId(selectedMedia?.id ?? ""); setMediaId(effectiveLogoUrl ?? "");
}} }}
onDeleteImageClick={() => { onDeleteImageClick={() => {
setSelectedMediaId(null); setSelectedMediaId(null);
@@ -207,7 +210,7 @@ export const CarrierCreatePage = observer(() => {
createCarrierData.city_id, createCarrierData.city_id,
createCarrierData[language].slogan, createCarrierData[language].slogan,
"", "",
language language,
); );
}} }}
onSelectFileClick={() => { onSelectFileClick={() => {

View File

@@ -18,6 +18,7 @@ import {
cityStore, cityStore,
mediaStore, mediaStore,
languageStore, languageStore,
isMediaIdEmpty,
LoadingSpinner, LoadingSpinner,
} from "@shared"; } from "@shared";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
@@ -123,9 +124,13 @@ export const CarrierEditPage = observer(() => {
); );
}; };
const selectedMedia = editCarrierData.logo const selectedMedia =
editCarrierData.logo && !isMediaIdEmpty(editCarrierData.logo)
? mediaStore.media.find((m) => m.id === editCarrierData.logo) ? mediaStore.media.find((m) => m.id === editCarrierData.logo)
: null; : null;
const effectiveLogoUrl = isMediaIdEmpty(editCarrierData.logo)
? null
: (selectedMedia?.id ?? editCarrierData.logo);
if (isLoadingData) { if (isLoadingData) {
return ( return (
@@ -238,10 +243,10 @@ export const CarrierEditPage = observer(() => {
<ImageUploadCard <ImageUploadCard
title="Логотип перевозчика" title="Логотип перевозчика"
imageKey="thumbnail" imageKey="thumbnail"
imageUrl={selectedMedia?.id} imageUrl={effectiveLogoUrl}
onImageClick={() => { onImageClick={() => {
setIsPreviewMediaOpen(true); setIsPreviewMediaOpen(true);
setMediaId(selectedMedia?.id ?? ""); setMediaId(effectiveLogoUrl ?? "");
}} }}
onDeleteImageClick={() => { onDeleteImageClick={() => {
setIsDeleteLogoModalOpen(true); setIsDeleteLogoModalOpen(true);

View File

@@ -12,14 +12,18 @@ import { ArrowLeft, Save } from "lucide-react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { cityStore, countryStore, languageStore, mediaStore } from "@shared";
import { useState, useEffect } from "react";
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
import { import {
cityStore,
countryStore,
languageStore,
mediaStore,
isMediaIdEmpty,
SelectMediaDialog, SelectMediaDialog,
UploadMediaDialog, UploadMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
} from "@shared"; } from "@shared";
import { useState, useEffect } from "react";
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
export const CityCreatePage = observer(() => { export const CityCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -72,9 +76,13 @@ export const CityCreatePage = observer(() => {
); );
}; };
const selectedMedia = createCityData.arms const selectedMedia =
createCityData.arms && !isMediaIdEmpty(createCityData.arms)
? mediaStore.media.find((m) => m.id === createCityData.arms) ? mediaStore.media.find((m) => m.id === createCityData.arms)
: null; : null;
const effectiveArmsUrl = isMediaIdEmpty(createCityData.arms)
? null
: (selectedMedia?.id ?? createCityData.arms);
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
@@ -135,10 +143,10 @@ export const CityCreatePage = observer(() => {
<ImageUploadCard <ImageUploadCard
title="Герб города" title="Герб города"
imageKey="image" imageKey="image"
imageUrl={selectedMedia?.id} imageUrl={effectiveArmsUrl}
onImageClick={() => { onImageClick={() => {
setIsPreviewMediaOpen(true); setIsPreviewMediaOpen(true);
setMediaId(selectedMedia?.id ?? ""); setMediaId(effectiveArmsUrl ?? "");
}} }}
onDeleteImageClick={() => { onDeleteImageClick={() => {
setCreateCityData( setCreateCityData(

View File

@@ -9,8 +9,7 @@ import {
Box, Box,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react"; import { ArrowLeft, Loader2, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { import {
@@ -18,16 +17,15 @@ import {
countryStore, countryStore,
languageStore, languageStore,
mediaStore, mediaStore,
isMediaIdEmpty,
CashedCities, CashedCities,
LoadingSpinner, LoadingSpinner,
} from "@shared";
import { useEffect, useState } from "react";
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
import {
SelectMediaDialog, SelectMediaDialog,
UploadMediaDialog, UploadMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
} from "@shared"; } from "@shared";
import { useEffect, useState } from "react";
import { LanguageSwitcher, ImageUploadCard } from "@widgets";
export const CityEditPage = observer(() => { export const CityEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -99,13 +97,17 @@ export const CityEditPage = observer(() => {
editCityData[language].name, editCityData[language].name,
editCityData.country_code, editCityData.country_code,
media.id, media.id,
language language,
); );
}; };
const selectedMedia = editCityData.arms const selectedMedia =
editCityData.arms && !isMediaIdEmpty(editCityData.arms)
? mediaStore.media.find((m) => m.id === editCityData.arms) ? mediaStore.media.find((m) => m.id === editCityData.arms)
: null; : null;
const effectiveArmsUrl = isMediaIdEmpty(editCityData.arms)
? null
: selectedMedia?.id ?? editCityData.arms;
if (isLoadingData) { if (isLoadingData) {
return ( return (
@@ -149,7 +151,7 @@ export const CityEditPage = observer(() => {
e.target.value, e.target.value,
editCityData.country_code, editCityData.country_code,
editCityData.arms, editCityData.arms,
language language,
) )
} }
/> />
@@ -165,7 +167,7 @@ export const CityEditPage = observer(() => {
editCityData[language].name, editCityData[language].name,
e.target.value, e.target.value,
editCityData.arms, editCityData.arms,
language language,
); );
}} }}
> >
@@ -181,17 +183,17 @@ export const CityEditPage = observer(() => {
<ImageUploadCard <ImageUploadCard
title="Герб города" title="Герб города"
imageKey="image" imageKey="image"
imageUrl={selectedMedia?.id} imageUrl={effectiveArmsUrl}
onImageClick={() => { onImageClick={() => {
setIsPreviewMediaOpen(true); setIsPreviewMediaOpen(true);
setMediaId(selectedMedia?.id ?? ""); setMediaId(effectiveArmsUrl ?? "");
}} }}
onDeleteImageClick={() => { onDeleteImageClick={() => {
setEditCityData( setEditCityData(
editCityData[language].name, editCityData[language].name,
editCityData.country_code, editCityData.country_code,
"", "",
language language,
); );
setActiveMenuType(null); setActiveMenuType(null);
}} }}

View File

@@ -186,7 +186,7 @@ const saveHiddenRoutes = (hiddenRoutes: Set<number>): void => {
try { try {
localStorage.setItem( localStorage.setItem(
HIDDEN_ROUTES_KEY, HIDDEN_ROUTES_KEY,
JSON.stringify(Array.from(hiddenRoutes)) JSON.stringify(Array.from(hiddenRoutes)),
); );
} catch (error) { } catch (error) {
console.warn("Failed to save hidden routes:", error); console.warn("Failed to save hidden routes:", error);
@@ -221,7 +221,7 @@ class MapStore {
try { try {
localStorage.setItem( localStorage.setItem(
HIDE_SIGHTS_BY_HIDDEN_ROUTES_KEY, HIDE_SIGHTS_BY_HIDDEN_ROUTES_KEY,
JSON.stringify(!!val) JSON.stringify(!!val),
); );
} catch (e) {} } catch (e) {}
} }
@@ -239,7 +239,7 @@ class MapStore {
private sortFeatures<T extends ApiStation | ApiSight>( private sortFeatures<T extends ApiStation | ApiSight>(
features: T[], features: T[],
sortType: SortType sortType: SortType,
): T[] { ): T[] {
const sorted = [...features]; const sorted = [...features];
switch (sortType) { switch (sortType) {
@@ -324,7 +324,7 @@ class MapStore {
return this.sortedStations; return this.sortedStations;
} }
return this.sortedStations.filter( return this.sortedStations.filter(
(station) => station.city_id === selectedCityId (station) => station.city_id === selectedCityId,
); );
} }
@@ -365,7 +365,7 @@ class MapStore {
const response = await languageInstance("ru").get("/route"); const response = await languageInstance("ru").get("/route");
const routesIds = response.data.map((route: any) => route.id); const routesIds = response.data.map((route: any) => route.id);
const routePromises = routesIds.map((id: number) => const routePromises = routesIds.map((id: number) =>
languageInstance("ru").get(`/route/${id}`) languageInstance("ru").get(`/route/${id}`),
); );
const routeResponses = await Promise.all(routePromises); const routeResponses = await Promise.all(routePromises);
this.routes = routeResponses.map((res) => ({ this.routes = routeResponses.map((res) => ({
@@ -379,7 +379,7 @@ 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.preloadRouteStations(routesIds);
@@ -391,14 +391,14 @@ class MapStore {
const stationPromises = routesIds.map(async (routeId) => { const stationPromises = routesIds.map(async (routeId) => {
try { try {
const stationsResponse = await languageInstance("ru").get( const stationsResponse = await languageInstance("ru").get(
`/route/${routeId}/station` `/route/${routeId}/station`,
); );
const stationIds = stationsResponse.data.map((s: any) => s.id); const stationIds = stationsResponse.data.map((s: any) => s.id);
this.routeStationsCache.set(routeId, stationIds); this.routeStationsCache.set(routeId, stationIds);
} catch (error) { } catch (error) {
console.error( console.error(
`Failed to preload stations for route ${routeId}:`, `Failed to preload stations for route ${routeId}:`,
error error,
); );
} }
}); });
@@ -409,7 +409,7 @@ class MapStore {
const sightPromises = routesIds.map(async (routeId) => { const sightPromises = routesIds.map(async (routeId) => {
try { try {
const sightsResponse = await languageInstance("ru").get( const sightsResponse = await languageInstance("ru").get(
`/route/${routeId}/sight` `/route/${routeId}/sight`,
); );
const sightIds = sightsResponse.data.map((s: any) => s.id); const sightIds = sightsResponse.data.map((s: any) => s.id);
this.routeSightsCache.set(routeId, sightIds); this.routeSightsCache.set(routeId, sightIds);
@@ -493,7 +493,7 @@ class MapStore {
if (selectedCityStore.selectedCityId) { if (selectedCityStore.selectedCityId) {
const carriersInCity = carrierStore.carriers.ru.data.filter( const carriersInCity = carrierStore.carriers.ru.data.filter(
(c: any) => c.city_id === selectedCityStore.selectedCityId (c: any) => c.city_id === selectedCityStore.selectedCityId,
); );
if (carriersInCity.length > 0) { if (carriersInCity.length > 0) {
@@ -521,7 +521,7 @@ class MapStore {
if (!carrier_id && selectedCityStore.selectedCityId) { if (!carrier_id && selectedCityStore.selectedCityId) {
toast.error( toast.error(
"В выбранном городе нет доступных перевозчиков, маршрут отображается в общем списке" "В выбранном городе нет доступных перевозчиков, маршрут отображается в общем списке",
); );
} }
createdItem = routeStore.routes.data[routeStore.routes.data.length - 1]; createdItem = routeStore.routes.data[routeStore.routes.data.length - 1];
@@ -573,7 +573,7 @@ class MapStore {
const centerCoords = getCenter(lineGeom.getExtent()); const centerCoords = getCenter(lineGeom.getExtent());
const [center_longitude, center_latitude] = toLonLat( const [center_longitude, center_latitude] = toLonLat(
centerCoords, centerCoords,
"EPSG:3857" "EPSG:3857",
); );
data = { data = {
route_number: properties.name, route_number: properties.name,
@@ -606,7 +606,7 @@ class MapStore {
return; return;
} }
throw new Error( throw new Error(
`Could not find old data for ${featureType} with id ${numericId}` `Could not find old data for ${featureType} with id ${numericId}`,
); );
} }
@@ -626,7 +626,7 @@ class MapStore {
const response = await languageInstance("ru").patch( const response = await languageInstance("ru").patch(
`/${featureType}/${numericId}`, `/${featureType}/${numericId}`,
requestBody requestBody,
); );
const updateStore = (store: any[], updatedItem: any) => { const updateStore = (store: any[], updatedItem: any) => {
@@ -745,7 +745,7 @@ class MapService {
private selectInteraction: Select; private selectInteraction: Select;
private hoveredFeatureId: string | number | null; private hoveredFeatureId: string | number | null;
private boundHandlePointerMove: ( private boundHandlePointerMove: (
event: MapBrowserEvent<PointerEvent> event: MapBrowserEvent<PointerEvent>,
) => void; ) => void;
private boundHandlePointerLeave: () => void; private boundHandlePointerLeave: () => void;
private boundHandleContextMenu: (event: MouseEvent) => void; private boundHandleContextMenu: (event: MouseEvent) => void;
@@ -784,7 +784,7 @@ class MapService {
onFeaturesChange: (features: Feature<Geometry>[]) => void, onFeaturesChange: (features: Feature<Geometry>[]) => void,
onFeatureSelect: (feature: Feature<Geometry> | null) => void, onFeatureSelect: (feature: Feature<Geometry> | null) => void,
tooltipElement: HTMLElement, tooltipElement: HTMLElement,
onSelectionChange?: (ids: Set<string | number>) => void onSelectionChange?: (ids: Set<string | number>) => void,
) { ) {
this.map = null; this.map = null;
this.tooltipElement = tooltipElement; this.tooltipElement = tooltipElement;
@@ -933,7 +933,7 @@ class MapService {
style: (featureLike: FeatureLike) => { style: (featureLike: FeatureLike) => {
const clusterFeature = featureLike as Feature<Point>; const clusterFeature = featureLike as Feature<Point>;
const featuresInCluster = clusterFeature.get( const featuresInCluster = clusterFeature.get(
"features" "features",
) as Feature<Point>[]; ) as Feature<Point>[];
const size = featuresInCluster.length; const size = featuresInCluster.length;
@@ -991,18 +991,18 @@ class MapService {
this.pointSource.on( this.pointSource.on(
"addfeature", "addfeature",
this.handleFeatureEvent.bind(this) as any this.handleFeatureEvent.bind(this) as any,
); );
this.pointSource.on("removefeature", () => this.updateFeaturesInReact()); this.pointSource.on("removefeature", () => this.updateFeaturesInReact());
this.pointSource.on( this.pointSource.on(
"changefeature", "changefeature",
this.handleFeatureChange.bind(this) as any this.handleFeatureChange.bind(this) as any,
); );
this.lineSource.on("addfeature", this.handleFeatureEvent.bind(this) as any); this.lineSource.on("addfeature", this.handleFeatureEvent.bind(this) as any);
this.lineSource.on("removefeature", () => this.updateFeaturesInReact()); this.lineSource.on("removefeature", () => this.updateFeaturesInReact());
this.lineSource.on( this.lineSource.on(
"changefeature", "changefeature",
this.handleFeatureChange.bind(this) as any this.handleFeatureChange.bind(this) as any,
); );
let renderCompleteHandled = false; let renderCompleteHandled = false;
@@ -1056,7 +1056,7 @@ class MapService {
if (center && zoom !== undefined && this.map) { if (center && zoom !== undefined && this.map) {
const [lon, lat] = toLonLat( const [lon, lat] = toLonLat(
center, center,
this.map.getView().getProjection() this.map.getView().getProjection(),
); );
saveMapPosition({ center: [lon, lat], zoom }); saveMapPosition({ center: [lon, lat], zoom });
} }
@@ -1068,7 +1068,7 @@ class MapService {
if (center && zoom !== undefined && this.map) { if (center && zoom !== undefined && this.map) {
const [lon, lat] = toLonLat( const [lon, lat] = toLonLat(
center, center,
this.map.getView().getProjection() this.map.getView().getProjection(),
); );
saveMapPosition({ center: [lon, lat], zoom }); saveMapPosition({ center: [lon, lat], zoom });
} }
@@ -1189,7 +1189,7 @@ class MapService {
const feature = this.map?.forEachFeatureAtPixel( const feature = this.map?.forEachFeatureAtPixel(
event.pixel, event.pixel,
(f: FeatureLike) => f as Feature<Geometry>, (f: FeatureLike) => f as Feature<Geometry>,
{ layerFilter, hitTolerance: 5 } { layerFilter, hitTolerance: 5 },
); );
if (!feature) return; if (!feature) return;
@@ -1227,7 +1227,7 @@ class MapService {
} }
const newCoordinates = coordinates.filter( const newCoordinates = coordinates.filter(
(_, index) => index !== closestIndex (_, index) => index !== closestIndex,
); );
lineString.setCoordinates(newCoordinates); lineString.setCoordinates(newCoordinates);
this.saveModifiedFeature(feature); this.saveModifiedFeature(feature);
@@ -1270,7 +1270,7 @@ class MapService {
selected.add(f.getId()!); selected.add(f.getId()!);
} }
} }
} },
); );
this.setSelectedIds(selected); this.setSelectedIds(selected);
@@ -1417,7 +1417,7 @@ class MapService {
public loadFeaturesFromApi( public loadFeaturesFromApi(
_apiStations: typeof mapStore.stations, _apiStations: typeof mapStore.stations,
_apiRoutes: typeof mapStore.routes, _apiRoutes: typeof mapStore.routes,
_apiSights: typeof mapStore.sights _apiSights: typeof mapStore.sights,
): void { ): void {
if (!this.map) return; if (!this.map) return;
@@ -1450,8 +1450,8 @@ class MapService {
transform( transform(
[station.longitude, station.latitude], [station.longitude, station.latitude],
"EPSG:4326", "EPSG:4326",
projection projection,
) ),
); );
const feature = new Feature({ geometry: point, name: station.name }); const feature = new Feature({ geometry: point, name: station.name });
feature.setId(`station-${station.id}`); feature.setId(`station-${station.id}`);
@@ -1462,7 +1462,7 @@ class MapService {
filteredSights.forEach((sight) => { filteredSights.forEach((sight) => {
if (sight.longitude == null || sight.latitude == null) return; if (sight.longitude == null || sight.latitude == null) return;
const point = new Point( const point = new Point(
transform([sight.longitude, sight.latitude], "EPSG:4326", projection) transform([sight.longitude, sight.latitude], "EPSG:4326", projection),
); );
const feature = new Feature({ const feature = new Feature({
geometry: point, geometry: point,
@@ -1482,7 +1482,7 @@ class MapService {
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]) =>
transform([c[1], c[0]], "EPSG:4326", projection) transform([c[1], c[0]], "EPSG:4326", projection),
); );
if (coordinates.length === 0) return; if (coordinates.length === 0) return;
@@ -1568,7 +1568,7 @@ class MapService {
public startDrawing( public startDrawing(
type: "Point" | "LineString", type: "Point" | "LineString",
featureType: FeatureType featureType: FeatureType,
): void { ): void {
if (!this.map) return; if (!this.map) return;
@@ -1732,7 +1732,7 @@ class MapService {
this.map.forEachFeatureAtPixel( this.map.forEachFeatureAtPixel(
event.pixel, event.pixel,
(f: FeatureLike) => f as Feature<Geometry>, (f: FeatureLike) => f as Feature<Geometry>,
{ layerFilter, hitTolerance: 5 } { layerFilter, hitTolerance: 5 },
); );
let finalFeature: Feature<Geometry> | null = null; let finalFeature: Feature<Geometry> | null = null;
@@ -1807,7 +1807,7 @@ class MapService {
public deleteFeature( public deleteFeature(
featureId: string | number | undefined, featureId: string | number | undefined,
recourse: string recourse: string,
): void { ): void {
if (featureId === undefined) return; if (featureId === undefined) return;
@@ -1863,7 +1863,7 @@ class MapService {
const lineFeature = this.lineSource.getFeatureById(id); const lineFeature = this.lineSource.getFeatureById(id);
if (lineFeature) if (lineFeature)
this.lineSource.removeFeature( this.lineSource.removeFeature(
lineFeature as Feature<LineString> lineFeature as Feature<LineString>,
); );
const pointFeature = this.pointSource.getFeatureById(id); const pointFeature = this.pointSource.getFeatureById(id);
if (pointFeature) if (pointFeature)
@@ -1890,11 +1890,11 @@ class MapService {
if (targetEl instanceof HTMLElement) { if (targetEl instanceof HTMLElement) {
targetEl.removeEventListener( targetEl.removeEventListener(
"contextmenu", "contextmenu",
this.boundHandleContextMenu this.boundHandleContextMenu,
); );
targetEl.removeEventListener( targetEl.removeEventListener(
"pointerleave", "pointerleave",
this.boundHandlePointerLeave this.boundHandlePointerLeave,
); );
} }
this.map.un("pointermove", this.boundHandlePointerMove as any); this.map.un("pointermove", this.boundHandlePointerMove as any);
@@ -1907,7 +1907,7 @@ class MapService {
} }
private handleFeatureEvent( private handleFeatureEvent(
event: VectorSourceEvent<Feature<Geometry>> event: VectorSourceEvent<Feature<Geometry>>,
): void { ): void {
if (!event.feature) return; if (!event.feature) return;
const feature = event.feature; const feature = event.feature;
@@ -1918,7 +1918,7 @@ class MapService {
} }
private handleFeatureChange( private handleFeatureChange(
event: VectorSourceEvent<Feature<Geometry>> event: VectorSourceEvent<Feature<Geometry>>,
): void { ): void {
if (!event.feature) return; if (!event.feature) return;
this.updateFeaturesInReact(); this.updateFeaturesInReact();
@@ -1956,7 +1956,7 @@ class MapService {
}); });
this.modifyInteraction.setActive( this.modifyInteraction.setActive(
this.selectInteraction.getFeatures().getLength() > 0 this.selectInteraction.getFeatures().getLength() > 0,
); );
this.clusterLayer.changed(); this.clusterLayer.changed();
this.routeLayer.changed(); this.routeLayer.changed();
@@ -2026,7 +2026,7 @@ class MapService {
if (typeof featureId === "number" || !String(featureId).includes("-")) { if (typeof featureId === "number" || !String(featureId).includes("-")) {
console.warn( console.warn(
"Skipping save for feature with non-standard ID:", "Skipping save for feature with non-standard ID:",
featureId featureId,
); );
return; return;
} }
@@ -2074,7 +2074,7 @@ class MapService {
try { try {
const createdFeatureData = await mapStore.createFeature( const createdFeatureData = await mapStore.createFeature(
featureType, featureType,
featureGeoJSON featureGeoJSON,
); );
const newFeatureId = `${featureType}-${createdFeatureData.id}`; const newFeatureId = `${featureType}-${createdFeatureData.id}`;
@@ -2093,8 +2093,8 @@ class MapService {
const lineGeom = new LineString( const lineGeom = new LineString(
routeData.path.map((c) => routeData.path.map((c) =>
transform([c[1], c[0]], "EPSG:4326", projection) transform([c[1], c[0]], "EPSG:4326", projection),
) ),
); );
feature.setGeometry(lineGeom); feature.setGeometry(lineGeom);
} else { } else {
@@ -2247,7 +2247,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
const actualFeatures = useMemo( const actualFeatures = useMemo(
() => mapFeatures.filter((f) => !f.get("isProxy")), () => mapFeatures.filter((f) => !f.get("isProxy")),
[mapFeatures] [mapFeatures],
); );
const allFeatures = useMemo(() => { const allFeatures = useMemo(() => {
@@ -2257,8 +2257,8 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
transform( transform(
[station.longitude, station.latitude], [station.longitude, station.latitude],
"EPSG:4326", "EPSG:4326",
"EPSG:3857" "EPSG:3857",
) ),
), ),
name: station.name, name: station.name,
description: station.description || "", description: station.description || "",
@@ -2275,8 +2275,8 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
transform( transform(
[sight.longitude, sight.latitude], [sight.longitude, sight.latitude],
"EPSG:4326", "EPSG:4326",
"EPSG:3857" "EPSG:3857",
) ),
), ),
name: sight.name, name: sight.name,
description: sight.description, description: sight.description,
@@ -2320,7 +2320,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
(f.get("routeNumber") as string) || "", (f.get("routeNumber") as string) || "",
]; ];
return candidates.some((value) => return candidates.some((value) =>
value.toLowerCase().includes(normalizedQuery) value.toLowerCase().includes(normalizedQuery),
); );
}); });
}, [allFeatures, searchQuery]); }, [allFeatures, searchQuery]);
@@ -2343,7 +2343,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
mapService.selectFeature(id); mapService.selectFeature(id);
} }
}, },
[mapService, selectedIds, setSelectedIds] [mapService, selectedIds, setSelectedIds],
); );
const handleDeleteFeature = useCallback( const handleDeleteFeature = useCallback(
@@ -2353,7 +2353,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
mapService.deleteFeature(id, resource); mapService.deleteFeature(id, resource);
} }
}, },
[mapService] [mapService],
); );
const handleCheckboxChange = useCallback( const handleCheckboxChange = useCallback(
@@ -2365,14 +2365,14 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
setSelectedIds(newSet); setSelectedIds(newSet);
mapService.setSelectedIds(newSet); mapService.setSelectedIds(newSet);
}, },
[mapService, selectedIds, setSelectedIds] [mapService, selectedIds, setSelectedIds],
); );
const handleBulkDelete = useCallback(() => { const handleBulkDelete = useCallback(() => {
if (!mapService || selectedIds.size === 0) return; if (!mapService || selectedIds.size === 0) return;
if ( if (
window.confirm( window.confirm(
`Вы уверены, что хотите удалить ${selectedIds.size} объект(ов)?` `Вы уверены, что хотите удалить ${selectedIds.size} объект(ов)?`,
) )
) { ) {
mapService.deleteMultipleFeatures(Array.from(selectedIds)); mapService.deleteMultipleFeatures(Array.from(selectedIds));
@@ -2386,7 +2386,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
if (!featureType || !numericId) return; if (!featureType || !numericId) return;
navigate(`/${featureType}/${numericId}/edit`); navigate(`/${featureType}/${numericId}/edit`);
}, },
[navigate] [navigate],
); );
const handleHideRoute = useCallback( const handleHideRoute = useCallback(
@@ -2413,7 +2413,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
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]) =>
transform([c[1], c[0]], "EPSG:4326", projection) transform([c[1], c[0]], "EPSG:4326", projection),
); );
if (coordinates.length > 0) { if (coordinates.length > 0) {
@@ -2435,7 +2435,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
const visibleRouteIds = allRouteIds.filter( const visibleRouteIds = allRouteIds.filter(
(id: number) => (id: number) =>
id !== numericRouteId && !mapStore.hiddenRoutes.has(id) id !== numericRouteId && !mapStore.hiddenRoutes.has(id),
); );
const stationsInVisibleRoutes = new Set<number>(); const stationsInVisibleRoutes = new Set<number>();
@@ -2443,12 +2443,12 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
const stationIds = const stationIds =
mapStore.routeStationsCache.get(otherRouteId) || []; mapStore.routeStationsCache.get(otherRouteId) || [];
stationIds.forEach((id: number) => stationIds.forEach((id: number) =>
stationsInVisibleRoutes.add(id) stationsInVisibleRoutes.add(id),
); );
}); });
const stationsToShow = routeStationIds.filter( const stationsToShow = routeStationIds.filter(
(id: number) => !stationsInVisibleRoutes.has(id) (id: number) => !stationsInVisibleRoutes.has(id),
); );
for (const stationId of stationsToShow) { for (const stationId of stationsToShow) {
@@ -2459,8 +2459,8 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
transform( transform(
[station.longitude, station.latitude], [station.longitude, station.latitude],
"EPSG:4326", "EPSG:4326",
projection projection,
) ),
); );
const feature = new Feature({ const feature = new Feature({
geometry: point, geometry: point,
@@ -2470,7 +2470,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
feature.set("featureType", "station"); feature.set("featureType", "station");
const existingFeature = mapService.pointSource.getFeatureById( const existingFeature = mapService.pointSource.getFeatureById(
`station-${station.id}` `station-${station.id}`,
); );
if (!existingFeature) { if (!existingFeature) {
mapService.pointSource.addFeature(feature); mapService.pointSource.addFeature(feature);
@@ -2487,7 +2487,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
const visibleRouteIds = allRouteIds.filter( const visibleRouteIds = allRouteIds.filter(
(id: number) => (id: number) =>
id !== numericRouteId && !mapStore.hiddenRoutes.has(id) id !== numericRouteId && !mapStore.hiddenRoutes.has(id),
); );
const stationsInVisibleRoutes = new Set<number>(); const stationsInVisibleRoutes = new Set<number>();
@@ -2495,21 +2495,21 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
const stationIds = const stationIds =
mapStore.routeStationsCache.get(otherRouteId) || []; mapStore.routeStationsCache.get(otherRouteId) || [];
stationIds.forEach((id: number) => stationIds.forEach((id: number) =>
stationsInVisibleRoutes.add(id) stationsInVisibleRoutes.add(id),
); );
}); });
const stationsToHide = routeStationIds.filter( const stationsToHide = routeStationIds.filter(
(id: number) => !stationsInVisibleRoutes.has(id) (id: number) => !stationsInVisibleRoutes.has(id),
); );
stationsToHide.forEach((stationId: number) => { stationsToHide.forEach((stationId: number) => {
const pointFeature = mapService.pointSource.getFeatureById( const pointFeature = mapService.pointSource.getFeatureById(
`station-${stationId}` `station-${stationId}`,
); );
if (pointFeature) { if (pointFeature) {
mapService.pointSource.removeFeature( mapService.pointSource.removeFeature(
pointFeature as Feature<Point> pointFeature as Feature<Point>,
); );
} }
}); });
@@ -2517,7 +2517,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
const lineFeature = mapService.lineSource.getFeatureById(routeId); const lineFeature = mapService.lineSource.getFeatureById(routeId);
if (lineFeature) { if (lineFeature) {
mapService.lineSource.removeFeature( mapService.lineSource.removeFeature(
lineFeature as Feature<LineString> lineFeature as Feature<LineString>,
); );
} }
@@ -2529,31 +2529,31 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
} catch (error) { } catch (error) {
console.error( console.error(
"[handleHideRoute] Error toggling route visibility:", "[handleHideRoute] Error toggling route visibility:",
error error,
); );
toast.error("Ошибка при изменении видимости маршрута"); toast.error("Ошибка при изменении видимости маршрута");
} }
}, },
[mapService] [mapService],
); );
const sortFeaturesByType = <T extends Feature<Geometry>>( const sortFeaturesByType = <T extends Feature<Geometry>>(
features: T[], features: T[],
sortType: SortType sortType: SortType,
): T[] => { ): T[] => {
const sorted = [...features]; const sorted = [...features];
switch (sortType) { switch (sortType) {
case "name_asc": case "name_asc":
return sorted.sort((a, b) => return sorted.sort((a, b) =>
((a.get("name") as string) || "").localeCompare( ((a.get("name") as string) || "").localeCompare(
(b.get("name") as string) || "" (b.get("name") as string) || "",
) ),
); );
case "name_desc": case "name_desc":
return sorted.sort((a, b) => return sorted.sort((a, b) =>
((b.get("name") as string) || "").localeCompare( ((b.get("name") as string) || "").localeCompare(
(a.get("name") as string) || "" (a.get("name") as string) || "",
) ),
); );
case "created_asc": case "created_asc":
return sorted.sort((a, b) => { return sorted.sort((a, b) => {
@@ -2609,13 +2609,13 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
}; };
const stations = filteredFeatures.filter( const stations = filteredFeatures.filter(
(f) => f.get("featureType") === "station" (f) => f.get("featureType") === "station",
); );
const lines = filteredFeatures.filter( const lines = filteredFeatures.filter(
(f) => f.get("featureType") === "route" (f) => f.get("featureType") === "route",
); );
const sights = filteredFeatures.filter( const sights = filteredFeatures.filter(
(f) => f.get("featureType") === "sight" (f) => f.get("featureType") === "sight",
); );
const sortedStations = sortFeaturesByType(stations, stationSort); const sortedStations = sortFeaturesByType(stations, stationSort);
@@ -2624,7 +2624,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
const renderFeatureList = ( const renderFeatureList = (
features: Feature<Geometry>[], features: Feature<Geometry>[],
featureType: "station" | "route" | "sight", featureType: "station" | "route" | "sight",
IconComponent: React.ElementType IconComponent: React.ElementType,
) => ( ) => (
<div className="space-y-1 pr-1"> <div className="space-y-1 pr-1">
{features.length > 0 ? ( {features.length > 0 ? (
@@ -2897,7 +2897,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
}`} }`}
onClick={() => onClick={() =>
mapStore.setHideSightsByHiddenRoutes( mapStore.setHideSightsByHiddenRoutes(
!mapStore.hideSightsByHiddenRoutes !mapStore.hideSightsByHiddenRoutes,
) )
} }
> >
@@ -2992,7 +2992,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
</> </>
)} )}
</div> </div>
) ),
) )
)} )}
</div> </div>
@@ -3009,7 +3009,7 @@ const MapSightbar: React.FC<MapSightbarProps> = observer(
)} )}
</div> </div>
); );
} },
); );
export const MapPage: React.FC = observer(() => { export const MapPage: React.FC = observer(() => {
@@ -3025,7 +3025,7 @@ export const MapPage: React.FC = observer(() => {
const [selectedFeatureForSidebar, setSelectedFeatureForSidebar] = const [selectedFeatureForSidebar, setSelectedFeatureForSidebar] =
useState<Feature<Geometry> | null>(null); useState<Feature<Geometry> | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string | number>>( const [selectedIds, setSelectedIds] = useState<Set<string | number>>(
new Set() new Set(),
); );
const [isLassoActive, setIsLassoActive] = useState<boolean>(false); const [isLassoActive, setIsLassoActive] = useState<boolean>(false);
const [showHelp, setShowHelp] = useState<boolean>(false); const [showHelp, setShowHelp] = useState<boolean>(false);
@@ -3037,7 +3037,7 @@ export const MapPage: React.FC = observer(() => {
const handleFeaturesChange = useCallback( const handleFeaturesChange = useCallback(
(feats: Feature<Geometry>[]) => setMapFeatures([...feats]), (feats: Feature<Geometry>[]) => setMapFeatures([...feats]),
[] [],
); );
const handleFeatureSelectForSidebar = useCallback( const handleFeatureSelectForSidebar = useCallback(
@@ -3059,7 +3059,7 @@ export const MapPage: React.FC = observer(() => {
}, 100); }, 100);
} }
}, },
[] [],
); );
useEffect(() => { useEffect(() => {
@@ -3080,7 +3080,7 @@ export const MapPage: React.FC = observer(() => {
mapService.loadFeaturesFromApi( mapService.loadFeaturesFromApi(
mapStore.stations, mapStore.stations,
mapStore.routes, mapStore.routes,
mapStore.sights mapStore.sights,
); );
} catch (e) { } catch (e) {
console.error("Failed to load initial map data:", e); console.error("Failed to load initial map data:", e);
@@ -3099,7 +3099,7 @@ export const MapPage: React.FC = observer(() => {
handleFeaturesChange, handleFeaturesChange,
handleFeatureSelectForSidebar, handleFeatureSelectForSidebar,
tooltipRef.current, tooltipRef.current,
setSelectedIds setSelectedIds,
); );
setMapServiceInstance(service); setMapServiceInstance(service);
@@ -3110,7 +3110,7 @@ export const MapPage: React.FC = observer(() => {
loadInitialData(service); loadInitialData(service);
} catch (e: any) { } catch (e: any) {
setError( setError(
`Ошибка инициализации карты: ${e.message || "Неизвестная ошибка"}.` `Ошибка инициализации карты: ${e.message || "Неизвестная ошибка"}.`,
); );
setIsMapLoading(false); setIsMapLoading(false);
setIsDataLoading(false); setIsDataLoading(false);
@@ -3203,7 +3203,7 @@ export const MapPage: React.FC = observer(() => {
mapServiceInstance.loadFeaturesFromApi( mapServiceInstance.loadFeaturesFromApi(
mapStore.stations, mapStore.stations,
mapStore.routes, mapStore.routes,
mapStore.sights mapStore.sights,
); );
} }
}, [selectedCityId, mapServiceInstance, isDataLoading]); }, [selectedCityId, mapServiceInstance, isDataLoading]);
@@ -3216,7 +3216,7 @@ export const MapPage: React.FC = observer(() => {
mapServiceInstance.loadFeaturesFromApi( mapServiceInstance.loadFeaturesFromApi(
mapStore.stations, mapStore.stations,
mapStore.routes, mapStore.routes,
mapStore.sights mapStore.sights,
); );
} }
}, [ }, [
@@ -3232,7 +3232,7 @@ export const MapPage: React.FC = observer(() => {
selectedFeatureForSidebar !== null || selectedIds.size > 0; selectedFeatureForSidebar !== null || selectedIds.size > 0;
return ( return (
<div className="flex flex-col md:flex-row font-sans bg-gray-100 h-[90vh] overflow-hidden"> <div className="-mb-4 flex flex-col md:flex-row font-sans bg-gray-100 h-[90vh] overflow-hidden">
<div className="relative flex-grow flex"> <div className="relative flex-grow flex">
<div <div
ref={mapRef} ref={mapRef}
@@ -3279,35 +3279,87 @@ export const MapPage: React.FC = observer(() => {
/> />
)} )}
<button
onClick={() => setShowHelp(!showHelp)}
className="absolute bottom-4 right-4 z-20 p-2 bg-white rounded-full shadow-md hover:bg-gray-100"
title="Помощь по клавишам"
>
<InfoIcon size={20} />
</button>
{showHelp && ( {showHelp && (
<div className="absolute bottom-16 right-4 z-20 p-4 bg-white rounded-lg shadow-xl max-w-xs"> <div className="absolute bottom-16 right-4 z-20 p-4 bg-white rounded-lg shadow-xl max-w-md max-h-[30vh] overflow-y-auto scrollbar-visible">
<h4 className="font-bold mb-2">Горячие клавиши:</h4> <h4 className="font-bold mb-2">Управление картой</h4>
<ul className="text-sm space-y-2"> <div className="text-sm space-y-3">
<div>
<p className="font-semibold mb-1">Перемещение и масштаб:</p>
<ul className="space-y-1">
<li>Колесо мыши приблизить / отдалить.</li>
<li>
Средняя кнопка мыши (колесо зажато) перетаскивание карты.
</li>
</ul>
</div>
<div>
<p className="font-semibold mb-1">Выделение объектов:</p>
<ul className="space-y-1">
<li>Одинарный клик по объекту выделить и центрировать.</li>
<li>
<span className="font-mono bg-gray-100 px-1 rounded">
Ctrl
</span>{" "}
+ клик добавить / убрать объект из выделения.
</li>
<li> <li>
<span className="font-mono bg-gray-100 px-1 rounded"> <span className="font-mono bg-gray-100 px-1 rounded">
Shift Shift
</span>{" "} </span>{" "}
- Режим выделения (лассо) временно включить режим лассо (выделение области).
</li> </li>
<li>Клик по пустому месту карты снять выделение.</li>
<li> <li>
<span className="font-mono bg-gray-100 px-1 rounded"> <span className="font-mono bg-gray-100 px-1 rounded">
Ctrl + клик Esc
</span>{" "} </span>{" "}
- Добавить/убрать из выделения снять выделение всех объектов.
</li>
<li>
<span className="font-mono bg-gray-100 px-1 rounded">Esc</span>{" "}
- Отменить выделение
</li> </li>
</ul> </ul>
</div>
<div>
<p className="font-semibold mb-1">
Рисование и редактирование:
</p>
<ul className="space-y-1">
<li>
Кнопки в верхней панели выбор режима: редактирование,
добавление остановки, достопримечательности или маршрута.
</li>
<li>
При рисовании маршрута: правый клик завершить линию.
</li>
<li>
В режиме редактирования: перетаскивайте точки маршрута для
изменения траектории.
</li>
<li>
Двойной клик по внутренней точке маршрута удалить эту
точку (при наличии не менее 2 точек).
</li>
</ul>
</div>
<div>
<p className="font-semibold mb-1">Боковая панель:</p>
<ul className="space-y-1">
<li>Клик по строке в списке перейти к объекту на карте.</li>
<li>
Иконка карандаша открыть объект в режиме редактирования.
</li>
<li>
Иконка карты у маршрутов открыть предпросмотр маршрута.
</li>
<li>
Иконка глаза у маршрутов скрыть / показать маршрут и
связанные остановки.
</li>
<li>Иконка корзины удалить объект.</li>
</ul>
</div>
</div>
<button <button
onClick={() => setShowHelp(false)} onClick={() => setShowHelp(false)}
className="mt-3 text-xs text-blue-600 hover:text-blue-800" className="mt-3 text-xs text-blue-600 hover:text-blue-800"
@@ -3316,6 +3368,14 @@ export const MapPage: React.FC = observer(() => {
</button> </button>
</div> </div>
)} )}
<button
onClick={() => setShowHelp(!showHelp)}
className="absolute bottom-4 right-4 z-20 p-2 bg-white rounded-full shadow-md hover:bg-gray-100"
title="Помощь по управлению картой"
>
<InfoIcon size={20} />
</button>
</div> </div>
{showContent && ( {showContent && (
<MapSightbar <MapSightbar

View File

@@ -13,22 +13,30 @@ import {
DialogContent, DialogContent,
DialogActions, DialogActions,
} from "@mui/material"; } from "@mui/material";
import { MediaViewer, VideoPreviewCard } from "@widgets"; import {
MediaViewer,
VideoPreviewCard,
ImageUploadCard,
} from "@widgets";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save, Plus, X } from "lucide-react"; import { ArrowLeft, Loader2, Save, Plus, X } from "lucide-react";
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState, useMemo } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { carrierStore } from "../../../shared/store/CarrierStore";
import { articlesStore } from "../../../shared/store/ArticlesStore";
import { Route, routeStore } from "../../../shared/store/RouteStore";
import { import {
carrierStore,
articlesStore,
routeStore,
languageStore, languageStore,
mediaStore,
isMediaIdEmpty,
ArticleSelectOrCreateDialog, ArticleSelectOrCreateDialog,
SelectMediaDialog, SelectMediaDialog,
selectedCityStore, selectedCityStore,
UploadMediaDialog, UploadMediaDialog,
PreviewMediaDialog,
} from "@shared"; } from "@shared";
import type { Route } from "@shared";
export const RouteCreatePage = observer(() => { export const RouteCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -45,18 +53,27 @@ export const RouteCreatePage = observer(() => {
const [centerLat, setCenterLat] = useState(""); const [centerLat, setCenterLat] = useState("");
const [centerLng, setCenterLng] = useState(""); const [centerLng, setCenterLng] = useState("");
const [videoPreview, setVideoPreview] = useState<string>(""); const [videoPreview, setVideoPreview] = useState<string>("");
const [icon, setIcon] = useState<string>("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] = const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] =
useState(false); useState(false);
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false); const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false); const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
const [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false); const [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false);
const [isSelectIconDialogOpen, setIsSelectIconDialogOpen] = useState(false);
const [isUploadIconDialogOpen, setIsUploadIconDialogOpen] = useState(false);
const [isPreviewIconOpen, setIsPreviewIconOpen] = useState(false);
const [previewIconId, setPreviewIconId] = useState("");
const [activeIconMenuType, setActiveIconMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
const [fileToUpload, setFileToUpload] = useState<File | null>(null); const [fileToUpload, setFileToUpload] = useState<File | null>(null);
const { language } = languageStore; const { language } = languageStore;
useEffect(() => { useEffect(() => {
carrierStore.getCarriers(language); carrierStore.getCarriers(language);
articlesStore.getArticleList(); articlesStore.getArticleList();
mediaStore.getMedia();
}, [language]); }, [language]);
const filteredCarriers = useMemo(() => { const filteredCarriers = useMemo(() => {
@@ -150,6 +167,23 @@ export const RouteCreatePage = observer(() => {
setIsVideoPreviewOpen(true); setIsVideoPreviewOpen(true);
}; };
const handleIconSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setIcon(media.id);
setIsSelectIconDialogOpen(false);
};
const selectedIconMedia =
icon && !isMediaIdEmpty(icon)
? mediaStore.media.find((m) => m.id === icon)
: null;
const effectiveIconUrl = isMediaIdEmpty(icon) ? null : selectedIconMedia?.id ?? icon;
const effectiveVideoId = isMediaIdEmpty(videoPreview) ? null : videoPreview;
const handleCreateRoute = async () => { const handleCreateRoute = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
@@ -243,8 +277,8 @@ export const RouteCreatePage = observer(() => {
center_latitude, center_latitude,
center_longitude, center_longitude,
path, path,
video_preview: video_preview: !isMediaIdEmpty(videoPreview) ? videoPreview : undefined,
videoPreview && videoPreview !== "" ? videoPreview : undefined, icon: !isMediaIdEmpty(icon) ? icon : undefined,
}; };
if (governor_appeal !== undefined) { if (governor_appeal !== undefined) {
@@ -403,16 +437,41 @@ export const RouteCreatePage = observer(() => {
</Button> </Button>
</Box> </Box>
<Box className="w-full flex justify-center gap-4 flex-wrap">
<div className="flex flex-col gap-4 max-w-[300px]">
<ImageUploadCard
title="Иконка маршрута"
imageKey="thumbnail"
imageUrl={effectiveIconUrl}
onImageClick={() => {
setIsPreviewIconOpen(true);
setPreviewIconId(selectedIconMedia?.id ?? icon ?? "");
}}
onDeleteImageClick={() => {
setIcon("");
setActiveIconMenuType(null);
}}
onSelectFileClick={() => {
setActiveIconMenuType("image");
setIsSelectIconDialogOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadIconDialogOpen(true);
setActiveIconMenuType("image");
}}
/>
</div>
<div className="flex flex-col gap-4 max-w-[300px]">
<VideoPreviewCard <VideoPreviewCard
title="Видеозаставка" title="Видеозаставка"
videoId={videoPreview} videoId={effectiveVideoId}
onVideoClick={handleVideoPreviewClick} onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => { onDeleteVideoClick={() => setVideoPreview("")}
setVideoPreview("");
}}
onSelectVideoClick={handleVideoFileSelect} onSelectVideoClick={handleVideoFileSelect}
className="w-full" className="w-full"
/> />
</div>
</Box>
<FormControl fullWidth required> <FormControl fullWidth required>
<InputLabel>Прямой/обратный маршрут</InputLabel> <InputLabel>Прямой/обратный маршрут</InputLabel>
@@ -522,7 +581,7 @@ export const RouteCreatePage = observer(() => {
onSelectMedia={handleVideoSelect} onSelectMedia={handleVideoSelect}
mediaType={2} mediaType={2}
/> />
{videoPreview && videoPreview !== "" && ( {effectiveVideoId && (
<Dialog <Dialog
open={isVideoPreviewOpen} open={isVideoPreviewOpen}
onClose={() => setIsVideoPreviewOpen(false)} onClose={() => setIsVideoPreviewOpen(false)}
@@ -534,7 +593,7 @@ export const RouteCreatePage = observer(() => {
<Box className="flex justify-center items-center p-4"> <Box className="flex justify-center items-center p-4">
<MediaViewer <MediaViewer
media={{ media={{
id: videoPreview, id: effectiveVideoId,
media_type: 2, media_type: 2,
filename: "video_preview", filename: "video_preview",
}} }}
@@ -560,6 +619,28 @@ export const RouteCreatePage = observer(() => {
initialFile={fileToUpload || undefined} initialFile={fileToUpload || undefined}
afterUpload={handleVideoUpload} afterUpload={handleVideoUpload}
/> />
<SelectMediaDialog
open={isSelectIconDialogOpen}
onClose={() => setIsSelectIconDialogOpen(false)}
onSelectMedia={handleIconSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadIconDialogOpen}
onClose={() => setIsUploadIconDialogOpen(false)}
contextObjectName={routeName || "Маршрут"}
contextType="route"
afterUpload={handleIconSelect}
hardcodeType={activeIconMenuType}
/>
<PreviewMediaDialog
open={isPreviewIconOpen}
onClose={() => setIsPreviewIconOpen(false)}
mediaId={previewIconId}
/>
</Paper> </Paper>
); );
}); });

View File

@@ -13,24 +13,31 @@ import {
DialogContent, DialogContent,
DialogActions, DialogActions,
} from "@mui/material"; } from "@mui/material";
import { MediaViewer, VideoPreviewCard } from "@widgets"; import {
MediaViewer,
VideoPreviewCard,
ImageUploadCard,
DeleteModal,
} from "@widgets";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Copy, Save, Plus, X } from "lucide-react"; import { ArrowLeft, Copy, Save, Plus, X } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify";
import { carrierStore } from "../../../shared/store/CarrierStore";
import { articlesStore } from "../../../shared/store/ArticlesStore";
import { import {
carrierStore,
articlesStore,
routeStore, routeStore,
languageStore, languageStore,
mediaStore,
isMediaIdEmpty,
stationsStore,
ArticleSelectOrCreateDialog, ArticleSelectOrCreateDialog,
SelectMediaDialog, SelectMediaDialog,
UploadMediaDialog, UploadMediaDialog,
PreviewMediaDialog,
LoadingSpinner, LoadingSpinner,
} from "@shared"; } from "@shared";
import { toast } from "react-toastify";
import { stationsStore } from "@shared";
import { LinkedItems } from "../LinekedStations"; import { LinkedItems } from "../LinekedStations";
export const RouteEditPage = observer(() => { export const RouteEditPage = observer(() => {
@@ -44,6 +51,14 @@ export const RouteEditPage = observer(() => {
const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false); const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false);
const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false); const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false);
const [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false); const [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false);
const [isSelectIconDialogOpen, setIsSelectIconDialogOpen] = useState(false);
const [isUploadIconDialogOpen, setIsUploadIconDialogOpen] = useState(false);
const [isPreviewIconOpen, setIsPreviewIconOpen] = useState(false);
const [isDeleteIconModalOpen, setIsDeleteIconModalOpen] = useState(false);
const [previewIconId, setPreviewIconId] = useState("");
const [activeIconMenuType, setActiveIconMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
const [fileToUpload, setFileToUpload] = useState<File | null>(null); const [fileToUpload, setFileToUpload] = useState<File | null>(null);
const { language } = languageStore; const { language } = languageStore;
const [coordinates, setCoordinates] = useState<string>(""); const [coordinates, setCoordinates] = useState<string>("");
@@ -71,10 +86,32 @@ export const RouteEditPage = observer(() => {
await carrierStore.getCarriers(language); await carrierStore.getCarriers(language);
await stationsStore.getStations(); await stationsStore.getStations();
await articlesStore.getArticleList(); await articlesStore.getArticleList();
await mediaStore.getMedia();
}; };
fetchData(); fetchData();
}, [id, language]); }, [id, language]);
const handleIconSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
routeStore.setEditRouteData({ icon: media.id });
setIsSelectIconDialogOpen(false);
};
const selectedIconMedia =
editRouteData.icon && !isMediaIdEmpty(editRouteData.icon)
? mediaStore.media.find((m) => m.id === editRouteData.icon)
: null;
const effectiveIconUrl = isMediaIdEmpty(editRouteData.icon)
? null
: (selectedIconMedia?.id ?? editRouteData.icon);
const effectiveVideoId = isMediaIdEmpty(editRouteData.video_preview)
? null
: editRouteData.video_preview;
useEffect(() => { useEffect(() => {
if (editRouteData.path && editRouteData.path.length > 0) { if (editRouteData.path && editRouteData.path.length > 0) {
const formattedPath = editRouteData.path const formattedPath = editRouteData.path
@@ -552,9 +589,33 @@ export const RouteEditPage = observer(() => {
</Button> </Button>
</Box> </Box>
<Box className="w-full flex justify-center gap-4 flex-wrap">
<div className="flex flex-col gap-4 max-w-[300px]">
<ImageUploadCard
title="Иконка маршрута"
imageKey="thumbnail"
imageUrl={effectiveIconUrl}
onImageClick={() => {
setIsPreviewIconOpen(true);
setPreviewIconId(
selectedIconMedia?.id ?? editRouteData.icon ?? ""
);
}}
onDeleteImageClick={() => setIsDeleteIconModalOpen(true)}
onSelectFileClick={() => {
setActiveIconMenuType("image");
setIsSelectIconDialogOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadIconDialogOpen(true);
setActiveIconMenuType("image");
}}
/>
</div>
<div className="flex flex-col gap-4 max-w-[300px]">
<VideoPreviewCard <VideoPreviewCard
title="Видеозаставка" title="Видеозаставка"
videoId={editRouteData.video_preview} videoId={effectiveVideoId}
onVideoClick={handleVideoPreviewClick} onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => { onDeleteVideoClick={() => {
routeStore.setEditRouteData({ video_preview: "" }); routeStore.setEditRouteData({ video_preview: "" });
@@ -562,6 +623,8 @@ export const RouteEditPage = observer(() => {
onSelectVideoClick={handleVideoFileSelect} onSelectVideoClick={handleVideoFileSelect}
className="w-full" className="w-full"
/> />
</div>
</Box>
</Box> </Box>
<LinkedItems <LinkedItems
@@ -621,10 +684,10 @@ export const RouteEditPage = observer(() => {
<DialogTitle>Предпросмотр видео</DialogTitle> <DialogTitle>Предпросмотр видео</DialogTitle>
<DialogContent> <DialogContent>
<Box className="flex justify-center items-center p-4"> <Box className="flex justify-center items-center p-4">
{editRouteData.video_preview && ( {effectiveVideoId && (
<MediaViewer <MediaViewer
media={{ media={{
id: editRouteData.video_preview, id: effectiveVideoId,
media_type: 2, media_type: 2,
filename: "video_preview", filename: "video_preview",
}} }}
@@ -648,6 +711,38 @@ export const RouteEditPage = observer(() => {
initialFile={fileToUpload || undefined} initialFile={fileToUpload || undefined}
afterUpload={handleVideoUpload} afterUpload={handleVideoUpload}
/> />
<SelectMediaDialog
open={isSelectIconDialogOpen}
onClose={() => setIsSelectIconDialogOpen(false)}
onSelectMedia={handleIconSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadIconDialogOpen}
onClose={() => setIsUploadIconDialogOpen(false)}
contextObjectName={editRouteData.route_name || "Маршрут"}
contextType="route"
afterUpload={handleIconSelect}
hardcodeType={activeIconMenuType}
/>
<PreviewMediaDialog
open={isPreviewIconOpen}
onClose={() => setIsPreviewIconOpen(false)}
mediaId={previewIconId}
/>
<DeleteModal
open={isDeleteIconModalOpen}
onDelete={() => {
routeStore.setEditRouteData({ icon: "" });
setIsDeleteIconModalOpen(false);
}}
onCancel={() => setIsDeleteIconModalOpen(false)}
edit
/>
</Paper> </Paper>
); );
}); });

View File

@@ -4,7 +4,7 @@ import { MediaViewer } from "@widgets";
import { useMapData } from "./MapDataContext"; import { useMapData } from "./MapDataContext";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { authInstance } from "@shared"; import { authInstance, isMediaIdEmpty } from "@shared";
import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import LanguageSelector from "./web-gl/LanguageSelector"; import LanguageSelector from "./web-gl/LanguageSelector";
@@ -106,7 +106,7 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
gap: 10, gap: 10,
}} }}
> >
{carrierThumbnail && ( {carrierThumbnail && !isMediaIdEmpty(carrierThumbnail) && (
<MediaViewer <MediaViewer
media={{ media={{
id: carrierThumbnail, id: carrierThumbnail,
@@ -143,7 +143,7 @@ export const LeftSidebar = observer(({ open, onToggle }: LeftSidebarProps) => {
justifyContent="center" justifyContent="center"
flexGrow={1} flexGrow={1}
> >
{carrierLogo && ( {carrierLogo && !isMediaIdEmpty(carrierLogo) && (
<MediaViewer <MediaViewer
media={{ media={{
id: carrierLogo, id: carrierLogo,

View File

@@ -33,6 +33,7 @@ export interface StationData {
address: string; address: string;
city_id?: number; city_id?: number;
description: string; description: string;
icon?: string;
id: number; id: number;
latitude: number; latitude: number;
longitude: number; longitude: number;

View File

@@ -1960,6 +1960,11 @@ export const WebGLRouteMapPrototype = observer(() => {
? { right: 0, transform: "none" } ? { right: 0, transform: "none" }
: { left: "50%", transform: "translateX(-50%)" }; : { left: "50%", transform: "translateX(-50%)" };
const iconUrl = station.icon
? `${import.meta.env.VITE_KRBL_MEDIA}${station.icon}/download?token=${localStorage.getItem("token") ?? ""}`
: null;
const iconSizePx = Math.round(primaryFontSize * 1.2);
const secondaryLineHeight = 1.2; const secondaryLineHeight = 1.2;
const secondaryHeight = showSecondary const secondaryHeight = showSecondary
? secondaryFontSize * secondaryLineHeight ? secondaryFontSize * secondaryLineHeight
@@ -2019,10 +2024,24 @@ export const WebGLRouteMapPrototype = observer(() => {
<div <div
style={{ style={{
position: "relative", position: "relative",
display: "inline-block", display: "inline-flex",
alignItems: "center",
gap: iconUrl ? 6 : 0,
pointerEvents: "none", pointerEvents: "none",
}} }}
> >
{iconUrl ? (
<img
src={iconUrl}
alt=""
style={{
width: iconSizePx,
height: iconSizePx,
flexShrink: 0,
pointerEvents: "none",
}}
/>
) : null}
<div <div
style={{ style={{
fontWeight: 700, fontWeight: 700,

View File

@@ -25,7 +25,7 @@ export const SnapshotCreatePage = observer(() => {
Назад Назад
</button> </button>
</div> </div>
<h1 className="text-2xl font-bold">Создание снапшота</h1> <h1 className="text-2xl font-bold">Создание экспорта медиа</h1>
<div className="flex flex-col gap-10 w-full items-end"> <div className="flex flex-col gap-10 w-full items-end">
<TextField <TextField
className="w-full" className="w-full"
@@ -53,7 +53,7 @@ export const SnapshotCreatePage = observer(() => {
} }
if (snapshotStore.snapshotStatus?.Status === "done") { if (snapshotStore.snapshotStatus?.Status === "done") {
toast.success("Снапшот успешно создан"); toast.success("Экспорт медиа успешно создан");
runInAction(() => { runInAction(() => {
snapshotStore.snapshotStatus = null; snapshotStore.snapshotStatus = null;
@@ -63,7 +63,7 @@ export const SnapshotCreatePage = observer(() => {
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast.error("Ошибка при создании снапшота"); toast.error("Ошибка при создании экспорта медиа");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }

View File

@@ -30,6 +30,14 @@ export const SnapshotListPage = observer(() => {
fetchSnapshots(); fetchSnapshots();
}, [language]); }, [language]);
const formatCreationTime = (isoString: string | undefined) => {
if (!isoString) return "";
const [datePart, timePartWithMs] = isoString.split("T");
if (!datePart || !timePartWithMs) return isoString;
const timePart = timePartWithMs.split(".")[0];
return `${datePart} - ${timePart}`;
};
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ {
field: "name", field: "name",
@@ -41,7 +49,14 @@ export const SnapshotListPage = observer(() => {
headerName: "Родитель", headerName: "Родитель",
flex: 1, flex: 1,
}, },
{
field: "created_at",
headerName: "Дата создания",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return <div>{params.value ? params.value : "-"}</div>;
},
},
{ {
field: "actions", field: "actions",
headerName: "Действия", headerName: "Действия",
@@ -79,14 +94,15 @@ export const SnapshotListPage = observer(() => {
id: snapshot.ID, id: snapshot.ID,
name: snapshot.Name, name: snapshot.Name,
parent: snapshots.find((s) => s.ID === snapshot.ParentID)?.Name, parent: snapshots.find((s) => s.ID === snapshot.ParentID)?.Name,
created_at: formatCreationTime(snapshot.CreationTime),
})); }));
return ( return (
<> <>
<div style={{ width: "100%" }}> <div style={{ width: "100%" }}>
<div className="flex justify-between items-center mb-10"> <div className="flex justify-between items-center mb-10">
<h1 className="text-2xl ">Снапшоты</h1> <h1 className="text-2xl ">Экспорт Медиа</h1>
<CreateButton label="Создать снапшот" path="/snapshot/create" /> <CreateButton label="Создать экспорт медиа" path="/snapshot/create" />
</div> </div>
<DataGrid <DataGrid
rows={rows} rows={rows}
@@ -99,7 +115,11 @@ export const SnapshotListPage = observer(() => {
slots={{ slots={{
noRowsOverlay: () => ( noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}> <Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет снапшотов"} {isLoading ? (
<CircularProgress size={20} />
) : (
"Нет экспортов медиа"
)}
</Box> </Box>
), ),
}} }}

View File

@@ -1,26 +1,33 @@
import { import {
Button, Button,
Paper,
TextField, TextField,
Select, Select,
MenuItem, MenuItem,
FormControl, FormControl,
InputLabel, InputLabel,
Box,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react"; import { ArrowLeft, Loader2, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { import {
stationsStore, stationsStore,
languageStore, languageStore,
cityStore, cityStore,
mediaStore,
isMediaIdEmpty,
useSelectedCity, useSelectedCity,
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
} from "@shared"; } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LanguageSwitcher } from "@widgets"; import {
import { SaveWithoutCityAgree } from "@widgets"; ImageUploadCard,
LanguageSwitcher,
SaveWithoutCityAgree,
} from "@widgets";
export const StationCreatePage = observer(() => { export const StationCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -35,6 +42,13 @@ export const StationCreatePage = observer(() => {
const { cities, getCities } = cityStore; const { cities, getCities } = cityStore;
const { selectedCityId, selectedCity } = useSelectedCity(); const { selectedCityId, selectedCity } = useSelectedCity();
const [coordinates, setCoordinates] = useState<string>(""); const [coordinates, setCoordinates] = useState<string>("");
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false); const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
@@ -96,8 +110,27 @@ export const StationCreatePage = observer(() => {
}; };
fetchCities(); fetchCities();
mediaStore.getMedia();
}, []); }, []);
const handleMediaSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setCreateCommonData({ icon: media.id });
};
const selectedMedia =
createStationData.common.icon &&
!isMediaIdEmpty(createStationData.common.icon)
? mediaStore.media.find((m) => m.id === createStationData.common.icon)
: null;
const effectiveIconUrl = isMediaIdEmpty(createStationData.common.icon)
? null
: selectedMedia?.id ?? createStationData.common.icon;
useEffect(() => { useEffect(() => {
if (selectedCityId && selectedCity && !createStationData.common.city_id) { if (selectedCityId && selectedCity && !createStationData.common.city_id) {
setCreateCommonData({ setCreateCommonData({
@@ -108,7 +141,7 @@ export const StationCreatePage = observer(() => {
}, [selectedCityId, selectedCity, createStationData.common.city_id]); }, [selectedCityId, selectedCity, createStationData.common.city_id]);
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Box className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher /> <LanguageSwitcher />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button <button
@@ -213,6 +246,30 @@ export const StationCreatePage = observer(() => {
</Select> </Select>
</FormControl> </FormControl>
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<ImageUploadCard
title="Иконка остановки"
imageKey="thumbnail"
imageUrl={effectiveIconUrl}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(effectiveIconUrl ?? "");
}}
onDeleteImageClick={() => {
setCreateCommonData({ icon: "" });
setActiveMenuType(null);
}}
onSelectFileClick={() => {
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("image");
}}
/>
</div>
<Button <Button
variant="contained" variant="contained"
className="w-min flex gap-2 items-center" className="w-min flex gap-2 items-center"
@@ -229,6 +286,28 @@ export const StationCreatePage = observer(() => {
</div> </div>
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */} {/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
<SelectMediaDialog
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={createStationData[language].name || "Остановка"}
contextType="station"
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
{isSaveWarningOpen && ( {isSaveWarningOpen && (
<SaveWithoutCityAgree <SaveWithoutCityAgree
blocker={{ blocker={{
@@ -237,6 +316,6 @@ export const StationCreatePage = observer(() => {
}} }}
/> />
)} )}
</Paper> </Box>
); );
}); });

View File

@@ -1,6 +1,5 @@
import { import {
Button, Button,
Paper,
TextField, TextField,
Select, Select,
MenuItem, MenuItem,
@@ -9,20 +8,28 @@ import {
Box, Box,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react"; import { ArrowLeft, Loader2, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { import {
stationsStore, stationsStore,
languageStore, languageStore,
cityStore, cityStore,
mediaStore,
isMediaIdEmpty,
LoadingSpinner, LoadingSpinner,
SelectMediaDialog,
PreviewMediaDialog,
UploadMediaDialog,
} from "@shared"; } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LanguageSwitcher } from "@widgets"; import {
ImageUploadCard,
LanguageSwitcher,
SaveWithoutCityAgree,
DeleteModal,
} from "@widgets";
import { LinkedSights } from "../LinkedSights"; import { LinkedSights } from "../LinkedSights";
import { SaveWithoutCityAgree } from "@widgets";
export const StationEditPage = observer(() => { export const StationEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -39,6 +46,14 @@ export const StationEditPage = observer(() => {
} = stationsStore; } = stationsStore;
const { cities, getCities } = cityStore; const { cities, getCities } = cityStore;
const [coordinates, setCoordinates] = useState<string>(""); const [coordinates, setCoordinates] = useState<string>("");
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [isDeleteIconModalOpen, setIsDeleteIconModalOpen] = useState(false);
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false); const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false);
@@ -95,6 +110,23 @@ export const StationEditPage = observer(() => {
setIsSaveWarningOpen(false); setIsSaveWarningOpen(false);
}; };
const handleMediaSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setEditCommonData({ icon: media.id });
};
const selectedMedia =
editStationData.common.icon && !isMediaIdEmpty(editStationData.common.icon)
? mediaStore.media.find((m) => m.id === editStationData.common.icon)
: null;
const effectiveIconUrl = isMediaIdEmpty(editStationData.common.icon)
? null
: selectedMedia?.id ?? editStationData.common.icon;
useEffect(() => { useEffect(() => {
const fetchAndSetStationData = async () => { const fetchAndSetStationData = async () => {
if (!id) { if (!id) {
@@ -109,6 +141,7 @@ export const StationEditPage = observer(() => {
await getCities("ru"); await getCities("ru");
await getCities("en"); await getCities("en");
await getCities("zh"); await getCities("zh");
await mediaStore.getMedia();
} finally { } finally {
setIsLoadingData(false); setIsLoadingData(false);
} }
@@ -133,7 +166,7 @@ export const StationEditPage = observer(() => {
} }
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Box className="w-full h-full p-3 flex flex-col gap-10">
<LanguageSwitcher /> <LanguageSwitcher />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -239,6 +272,29 @@ export const StationEditPage = observer(() => {
</Select> </Select>
</FormControl> </FormControl>
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<ImageUploadCard
title="Иконка остановки"
imageKey="thumbnail"
imageUrl={effectiveIconUrl}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(effectiveIconUrl ?? "");
}}
onDeleteImageClick={() => {
setIsDeleteIconModalOpen(true);
}}
onSelectFileClick={() => {
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("image");
}}
/>
</div>
{id && ( {id && (
<LinkedSights <LinkedSights
parentId={Number(id)} parentId={Number(id)}
@@ -262,6 +318,38 @@ export const StationEditPage = observer(() => {
</Button> </Button>
</div> </div>
<SelectMediaDialog
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={editStationData[language].name || "Остановка"}
contextType="station"
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
<DeleteModal
open={isDeleteIconModalOpen}
onDelete={() => {
setEditCommonData({ icon: "" });
setIsDeleteIconModalOpen(false);
}}
onCancel={() => setIsDeleteIconModalOpen(false)}
edit
/>
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */} {/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */}
{isSaveWarningOpen && ( {isSaveWarningOpen && (
<SaveWithoutCityAgree <SaveWithoutCityAgree
@@ -271,6 +359,6 @@ export const StationEditPage = observer(() => {
}} }}
/> />
)} )}
</Paper> </Box>
); );
}); });

View File

@@ -6,17 +6,35 @@ import {
FormControlLabel, FormControlLabel,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react"; import { ArrowLeft, Loader2, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { userStore } from "@shared"; import {
import { useState } from "react"; userStore,
mediaStore,
isMediaIdEmpty,
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
} from "@shared";
import { useState, useEffect } from "react";
import { ImageUploadCard } from "@widgets";
export const UserCreatePage = observer(() => { export const UserCreatePage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
const { createUserData, setCreateUserData, createUser } = userStore; const { createUserData, setCreateUserData, createUser } = userStore;
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
useEffect(() => {
mediaStore.getMedia();
}, []);
const handleCreate = async () => { const handleCreate = async () => {
try { try {
@@ -31,6 +49,29 @@ export const UserCreatePage = observer(() => {
} }
}; };
const handleMediaSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setCreateUserData(
createUserData.name || "",
createUserData.email || "",
createUserData.password || "",
createUserData.is_admin || false,
media.id
);
};
const selectedMedia =
createUserData.icon && !isMediaIdEmpty(createUserData.icon)
? mediaStore.media.find((m) => m.id === createUserData.icon)
: null;
const effectiveIconUrl = isMediaIdEmpty(createUserData.icon)
? null
: selectedMedia?.id ?? createUserData.icon ?? null;
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <Paper className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -54,7 +95,8 @@ export const UserCreatePage = observer(() => {
e.target.value, e.target.value,
createUserData.email || "", createUserData.email || "",
createUserData.password || "", createUserData.password || "",
createUserData.is_admin || false createUserData.is_admin || false,
createUserData.icon
) )
} }
/> />
@@ -69,7 +111,8 @@ export const UserCreatePage = observer(() => {
createUserData.name || "", createUserData.name || "",
e.target.value, e.target.value,
createUserData.password || "", createUserData.password || "",
createUserData.is_admin || false createUserData.is_admin || false,
createUserData.icon
) )
} }
/> />
@@ -84,7 +127,8 @@ export const UserCreatePage = observer(() => {
createUserData.name || "", createUserData.name || "",
createUserData.email || "", createUserData.email || "",
e.target.value, e.target.value,
createUserData.is_admin || false createUserData.is_admin || false,
createUserData.icon
) )
} }
/> />
@@ -99,7 +143,8 @@ export const UserCreatePage = observer(() => {
createUserData.name || "", createUserData.name || "",
createUserData.email || "", createUserData.email || "",
createUserData.password || "", createUserData.password || "",
e.target.checked e.target.checked,
createUserData.icon
); );
}} }}
/> />
@@ -108,6 +153,36 @@ export const UserCreatePage = observer(() => {
/> />
</div> </div>
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<ImageUploadCard
title="Аватар"
imageKey="thumbnail"
imageUrl={effectiveIconUrl}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(effectiveIconUrl ?? "");
}}
onDeleteImageClick={() => {
setCreateUserData(
createUserData.name || "",
createUserData.email || "",
createUserData.password || "",
createUserData.is_admin || false,
""
);
setActiveMenuType(null);
}}
onSelectFileClick={() => {
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("image");
}}
/>
</div>
<Button <Button
variant="contained" variant="contained"
className="w-min flex gap-2 items-center" className="w-min flex gap-2 items-center"
@@ -124,6 +199,28 @@ export const UserCreatePage = observer(() => {
)} )}
</Button> </Button>
</div> </div>
<SelectMediaDialog
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={createUserData.name || "Пользователь"}
contextType="user"
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
</Paper> </Paper>
); );
}); });

View File

@@ -7,12 +7,21 @@ import {
Box, Box,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Save } from "lucide-react"; import { ArrowLeft, Loader2, Save } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { userStore, languageStore, LoadingSpinner } from "@shared"; import {
userStore,
languageStore,
LoadingSpinner,
mediaStore,
isMediaIdEmpty,
SelectMediaDialog,
UploadMediaDialog,
PreviewMediaDialog,
} from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ImageUploadCard, DeleteModal } from "@widgets";
export const UserEditPage = observer(() => { export const UserEditPage = observer(() => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -22,8 +31,16 @@ export const UserEditPage = observer(() => {
const { id } = useParams(); const { id } = useParams();
const { editUserData, editUser, getUser, setEditUserData } = userStore; const { editUserData, editUser, getUser, setEditUserData } = userStore;
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false);
const [mediaId, setMediaId] = useState("");
const [isDeleteIconModalOpen, setIsDeleteIconModalOpen] = useState(false);
const [activeMenuType, setActiveMenuType] = useState<
"thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null
>(null);
useEffect(() => { useEffect(() => {
// Устанавливаем русский язык при загрузке страницы
languageStore.setLanguage("ru"); languageStore.setLanguage("ru");
}, []); }, []);
@@ -40,19 +57,38 @@ export const UserEditPage = observer(() => {
} }
}; };
const handleMediaSelect = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setEditUserData(
editUserData.name || "",
editUserData.email || "",
editUserData.password || "",
editUserData.is_admin || false,
media.id
);
};
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (id) { if (id) {
setIsLoadingData(true); setIsLoadingData(true);
try { try {
await mediaStore.getMedia();
const data = await getUser(Number(id)); const data = await getUser(Number(id));
if (data) {
setEditUserData( setEditUserData(
data?.name || "", data.name || "",
data?.email || "", data.email || "",
data?.password || "", data.password || "",
data?.is_admin || false data.is_admin || false,
data.icon || ""
); );
}
} finally { } finally {
setIsLoadingData(false); setIsLoadingData(false);
} }
@@ -62,6 +98,14 @@ export const UserEditPage = observer(() => {
})(); })();
}, [id]); }, [id]);
const selectedMedia =
editUserData.icon && !isMediaIdEmpty(editUserData.icon)
? mediaStore.media.find((m) => m.id === editUserData.icon)
: null;
const effectiveIconUrl = isMediaIdEmpty(editUserData.icon)
? null
: selectedMedia?.id ?? editUserData.icon ?? null;
if (isLoadingData) { if (isLoadingData) {
return ( return (
<Box <Box
@@ -100,7 +144,8 @@ export const UserEditPage = observer(() => {
e.target.value, e.target.value,
editUserData.email || "", editUserData.email || "",
editUserData.password || "", editUserData.password || "",
editUserData.is_admin || false editUserData.is_admin || false,
editUserData.icon
) )
} }
/> />
@@ -114,7 +159,8 @@ export const UserEditPage = observer(() => {
editUserData.name || "", editUserData.name || "",
e.target.value, e.target.value,
editUserData.password || "", editUserData.password || "",
editUserData.is_admin || false editUserData.is_admin || false,
editUserData.icon
) )
} }
/> />
@@ -129,7 +175,8 @@ export const UserEditPage = observer(() => {
editUserData.name || "", editUserData.name || "",
editUserData.email || "", editUserData.email || "",
e.target.value, e.target.value,
editUserData.is_admin || false editUserData.is_admin || false,
editUserData.icon
) )
} }
/> />
@@ -142,7 +189,8 @@ export const UserEditPage = observer(() => {
editUserData.name || "", editUserData.name || "",
editUserData.email || "", editUserData.email || "",
editUserData.password || "", editUserData.password || "",
e.target.checked e.target.checked,
editUserData.icon
) )
} }
/> />
@@ -150,6 +198,27 @@ export const UserEditPage = observer(() => {
label="Администратор" label="Администратор"
/> />
<div className="w-full flex flex-col gap-4 max-w-[300px]">
<ImageUploadCard
title="Аватар"
imageKey="thumbnail"
imageUrl={effectiveIconUrl}
onImageClick={() => {
setIsPreviewMediaOpen(true);
setMediaId(effectiveIconUrl ?? "");
}}
onDeleteImageClick={() => setIsDeleteIconModalOpen(true)}
onSelectFileClick={() => {
setActiveMenuType("image");
setIsSelectMediaOpen(true);
}}
setUploadMediaOpen={() => {
setIsUploadMediaOpen(true);
setActiveMenuType("image");
}}
/>
</div>
<Button <Button
variant="contained" variant="contained"
className="w-min flex gap-2 items-center self-end" className="w-min flex gap-2 items-center self-end"
@@ -164,6 +233,44 @@ export const UserEditPage = observer(() => {
)} )}
</Button> </Button>
</div> </div>
<SelectMediaDialog
open={isSelectMediaOpen}
onClose={() => setIsSelectMediaOpen(false)}
onSelectMedia={handleMediaSelect}
mediaType={1}
/>
<UploadMediaDialog
open={isUploadMediaOpen}
onClose={() => setIsUploadMediaOpen(false)}
contextObjectName={editUserData.name || "Пользователь"}
contextType="user"
afterUpload={handleMediaSelect}
hardcodeType={activeMenuType}
/>
<PreviewMediaDialog
open={isPreviewMediaOpen}
onClose={() => setIsPreviewMediaOpen(false)}
mediaId={mediaId}
/>
<DeleteModal
open={isDeleteIconModalOpen}
onDelete={() => {
setEditUserData(
editUserData.name || "",
editUserData.email || "",
editUserData.password || "",
editUserData.is_admin || false,
""
);
setIsDeleteIconModalOpen(false);
}}
onCancel={() => setIsDeleteIconModalOpen(false)}
edit
/>
</Paper> </Paper>
); );
}); });

View File

@@ -25,6 +25,7 @@ export const VehicleCreatePage = observer(() => {
const [tailNumber, setTailNumber] = useState(""); const [tailNumber, setTailNumber] = useState("");
const [type, setType] = useState(""); const [type, setType] = useState("");
const [carrierId, setCarrierId] = useState<number | null>(null); const [carrierId, setCarrierId] = useState<number | null>(null);
const [model, setModel] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { language } = languageStore; const { language } = languageStore;
@@ -40,7 +41,8 @@ export const VehicleCreatePage = observer(() => {
Number(type), Number(type),
carrierStore.carriers[language].data?.find((c) => c.id === carrierId) carrierStore.carriers[language].data?.find((c) => c.id === carrierId)
?.full_name as string, ?.full_name as string,
carrierId! carrierId!,
model || undefined,
); );
toast.success("Транспорт успешно создан"); toast.success("Транспорт успешно создан");
} catch (error) { } catch (error) {
@@ -103,6 +105,14 @@ export const VehicleCreatePage = observer(() => {
</Select> </Select>
</FormControl> </FormControl>
<TextField
fullWidth
label="Модель ТС"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="Произвольное название модели"
/>
<Button <Button
variant="contained" variant="contained"
className="w-min flex gap-2 items-center" className="w-min flex gap-2 items-center"

View File

@@ -7,6 +7,8 @@ import {
InputLabel, InputLabel,
Button, Button,
Box, Box,
FormControlLabel,
Checkbox,
} from "@mui/material"; } from "@mui/material";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save } from "lucide-react"; import { ArrowLeft, Loader2, Save } from "lucide-react";
@@ -54,10 +56,13 @@ export const VehicleEditPage = observer(() => {
await getCarriers(language); await getCarriers(language);
setEditVehicleData({ setEditVehicleData({
tail_number: vehicle[Number(id)]?.vehicle.tail_number, tail_number: vehicle[Number(id)]?.vehicle.tail_number ?? "",
type: vehicle[Number(id)]?.vehicle.type, type: vehicle[Number(id)]?.vehicle.type ?? 0,
carrier: vehicle[Number(id)]?.vehicle.carrier, carrier: vehicle[Number(id)]?.vehicle.carrier ?? "",
carrier_id: vehicle[Number(id)]?.vehicle.carrier_id, carrier_id: vehicle[Number(id)]?.vehicle.carrier_id ?? 0,
model: vehicle[Number(id)]?.vehicle.model ?? "",
snapshot_update_blocked:
vehicle[Number(id)]?.vehicle.snapshot_update_blocked ?? false,
}); });
} finally { } finally {
setIsLoadingData(false); setIsLoadingData(false);
@@ -159,6 +164,35 @@ export const VehicleEditPage = observer(() => {
</Select> </Select>
</FormControl> </FormControl>
<TextField
fullWidth
label="Модель ТС"
value={editVehicleData.model}
onChange={(e) =>
setEditVehicleData({
...editVehicleData,
model: e.target.value,
})
}
placeholder="Произвольное название модели"
/>
<FormControlLabel
control={
<Checkbox
checked={editVehicleData.snapshot_update_blocked}
onChange={(e) =>
setEditVehicleData({
...editVehicleData,
snapshot_update_blocked: e.target.checked,
})
}
color="primary"
/>
}
label="Блокировка обновления ПО"
/>
<Button <Button
variant="contained" variant="contained"
className="w-min flex gap-2 items-center" className="w-min flex gap-2 items-center"

View File

@@ -36,7 +36,7 @@ export const NAVIGATION_ITEMS: {
primary: [ primary: [
{ {
id: "snapshots", id: "snapshots",
label: "Снапшоты", label: "Экспорт",
icon: GitBranch, icon: GitBranch,
path: "/snapshot", path: "/snapshot",
for_admin: true, for_admin: true,
@@ -124,6 +124,16 @@ export const NAVIGATION_ITEMS: {
}; };
export const VEHICLE_TYPES = [ export const VEHICLE_TYPES = [
{ label: "Трамвай", value: 1 }, { label: "Автобус", value: 3 },
{ label: "Троллейбус", value: 2 }, { label: "Троллейбус", value: 2 },
{ label: "Трамвай", value: 1 },
{ label: "Электробус", value: 4 },
{ label: "Электричка", value: 5 },
{ label: "Вагон метро", value: 6 },
{ label: "Вагон ЖД", value: 7 },
]; ];
export const VEHICLE_MODELS = [
{ label: "71-431P «Довлатов»", value: "71-431P «Довлатов»" },
{ label: "71-638M-02 «Альтаир»", value: "71-638M-02 «Альтаир»" },
] as const;

View File

@@ -33,3 +33,12 @@ export const generateDefaultMediaName = (
return `${objectName || "Название"}_${fileNameWithoutExtension}_Медиа`; return `${objectName || "Название"}_${fileNameWithoutExtension}_Медиа`;
}; };
/** Медиа-id считается пустым, если строка пустая или состоит только из нулей (с дефисами или без). */
export const isMediaIdEmpty = (
id: string | null | undefined
): boolean => {
if (id == null || id === "") return true;
const digits = id.replace(/-/g, "");
return digits === "" || /^0+$/.test(digits);
};

View File

@@ -51,7 +51,9 @@ interface UploadMediaDialogProps {
| "carrier" | "carrier"
| "country" | "country"
| "vehicle" | "vehicle"
| "station"; | "station"
| "route"
| "user";
isArticle?: boolean; isArticle?: boolean;
articleName?: string; articleName?: string;
initialFile?: File; initialFile?: File;

View File

@@ -1,5 +1,10 @@
import { makeAutoObservable, runInAction } from "mobx"; import { makeAutoObservable, runInAction } from "mobx";
import { authInstance, languageInstance, languageStore } from "@shared"; import {
authInstance,
languageInstance,
languageStore,
isMediaIdEmpty,
} from "@shared";
export type Route = { export type Route = {
route_name: string; route_name: string;
@@ -9,6 +14,7 @@ export type Route = {
center_longitude: number; center_longitude: number;
governor_appeal: number; governor_appeal: number;
id: number; id: number;
icon: string;
path: number[][]; path: number[][];
rotate: number; rotate: number;
route_direction: boolean; route_direction: boolean;
@@ -137,6 +143,7 @@ class RouteStore {
center_longitude: "", center_longitude: "",
governor_appeal: 0, governor_appeal: 0,
id: 0, id: 0,
icon: "",
path: [] as number[][], path: [] as number[][],
rotate: 0, rotate: 0,
route_direction: false, route_direction: false,
@@ -152,9 +159,15 @@ class RouteStore {
}; };
editRoute = async (id: number) => { editRoute = async (id: number) => {
if (!this.editRouteData.video_preview) { if (
!this.editRouteData.video_preview ||
isMediaIdEmpty(this.editRouteData.video_preview)
) {
delete this.editRouteData.video_preview; delete this.editRouteData.video_preview;
} }
if (!this.editRouteData.icon || isMediaIdEmpty(this.editRouteData.icon)) {
delete (this.editRouteData as any).icon;
}
const dataToSend: any = { const dataToSend: any = {
...this.editRouteData, ...this.editRouteData,
center_latitude: parseFloat(this.editRouteData.center_latitude), center_latitude: parseFloat(this.editRouteData.center_latitude),

View File

@@ -7,6 +7,7 @@ export type User = {
is_admin: boolean; is_admin: boolean;
name: string; name: string;
password?: string; password?: string;
icon?: string;
}; };
class UserStore { class UserStore {
@@ -57,15 +58,23 @@ class UserStore {
email: "", email: "",
password: "", password: "",
is_admin: false, is_admin: false,
icon: "",
}; };
setCreateUserData = ( setCreateUserData = (
name: string, name: string,
email: string, email: string,
password: string, password: string,
is_admin: boolean is_admin: boolean,
icon?: string
) => { ) => {
this.createUserData = { name, email, password, is_admin }; this.createUserData = {
name,
email,
password,
is_admin,
icon: icon ?? "",
};
}; };
createUser = async () => { createUser = async () => {
@@ -73,7 +82,9 @@ class UserStore {
if (this.users.data.length > 0) { if (this.users.data.length > 0) {
id = this.users.data[this.users.data.length - 1].id + 1; id = this.users.data[this.users.data.length - 1].id + 1;
} }
const response = await authInstance.post("/user", this.createUserData); const payload = { ...this.createUserData };
if (!payload.icon) delete payload.icon;
const response = await authInstance.post("/user", payload);
runInAction(() => { runInAction(() => {
this.users.data.push({ this.users.data.push({
@@ -88,19 +99,29 @@ class UserStore {
email: "", email: "",
password: "", password: "",
is_admin: false, is_admin: false,
icon: "",
}; };
setEditUserData = ( setEditUserData = (
name: string, name: string,
email: string, email: string,
password: string, password: string,
is_admin: boolean is_admin: boolean,
icon?: string
) => { ) => {
this.editUserData = { name, email, password, is_admin }; this.editUserData = {
name,
email,
password,
is_admin,
icon: icon ?? "",
};
}; };
editUser = async (id: number) => { editUser = async (id: number) => {
const response = await authInstance.patch(`/user/${id}`, this.editUserData); const payload = { ...this.editUserData };
if (!payload.icon) delete payload.icon;
const response = await authInstance.patch(`/user/${id}`, payload);
runInAction(() => { runInAction(() => {
this.users.data = this.users.data.map((user) => this.users.data = this.users.data.map((user) =>

View File

@@ -9,6 +9,9 @@ export type Vehicle = {
carrier_id: number; carrier_id: number;
carrier: string; carrier: string;
uuid?: string; uuid?: string;
model?: string;
current_snapshot_uuid?: string;
snapshot_update_blocked?: boolean;
}; };
device_status?: { device_status?: {
device_uuid: string; device_uuid: string;
@@ -65,14 +68,18 @@ class VehicleStore {
tailNumber: string, tailNumber: string,
type: number, type: number,
carrier: string, carrier: string,
carrierId: number carrierId: number,
model?: string
) => { ) => {
const response = await languageInstance("ru").post("/vehicle", { const payload: Record<string, unknown> = {
tail_number: tailNumber, tail_number: tailNumber,
type, type,
carrier, carrier,
carrier_id: carrierId, carrier_id: carrierId,
}); };
// TODO: когда будет бекенд — добавить model в payload и в ответ
if (model != null && model !== "") payload.model = model;
const response = await languageInstance("ru").post("/vehicle", payload);
runInAction(() => { runInAction(() => {
this.vehicles.data.push({ this.vehicles.data.push({
@@ -83,6 +90,7 @@ class VehicleStore {
carrier_id: response.data.carrier_id, carrier_id: response.data.carrier_id,
carrier: response.data.carrier, carrier: response.data.carrier,
uuid: response.data.uuid, uuid: response.data.uuid,
model: response.data.model ?? model,
}, },
}); });
}); });
@@ -93,11 +101,15 @@ class VehicleStore {
type: number; type: number;
carrier: string; carrier: string;
carrier_id: number; carrier_id: number;
model: string;
snapshot_update_blocked: boolean;
} = { } = {
tail_number: "", tail_number: "",
type: 0, type: 0,
carrier: "", carrier: "",
carrier_id: 0, carrier_id: 0,
model: "",
snapshot_update_blocked: false,
}; };
setEditVehicleData = (data: { setEditVehicleData = (data: {
@@ -105,6 +117,8 @@ class VehicleStore {
type: number; type: number;
carrier: string; carrier: string;
carrier_id: number; carrier_id: number;
model?: string;
snapshot_update_blocked?: boolean;
}) => { }) => {
this.editVehicleData = { this.editVehicleData = {
...this.editVehicleData, ...this.editVehicleData,
@@ -119,27 +133,45 @@ class VehicleStore {
type: number; type: number;
carrier: string; carrier: string;
carrier_id: number; carrier_id: number;
model?: string;
snapshot_update_blocked?: boolean;
} }
) => { ) => {
const response = await languageInstance("ru").patch(`/vehicle/${id}`, { const payload: Record<string, unknown> = {
tail_number: data.tail_number, tail_number: data.tail_number,
type: data.type, type: data.type,
carrier: data.carrier, carrier: data.carrier,
carrier_id: data.carrier_id, carrier_id: data.carrier_id,
}); };
if (data.model != null && data.model !== "") payload.model = data.model;
if (data.snapshot_update_blocked != null)
payload.snapshot_update_blocked = data.snapshot_update_blocked;
const response = await languageInstance("ru").patch(
`/vehicle/${id}`,
payload
);
runInAction(() => { runInAction(() => {
const updated = {
...response.data,
model: response.data.model ?? data.model,
snapshot_update_blocked:
response.data.snapshot_update_blocked ?? data.snapshot_update_blocked,
};
this.vehicle[id] = { this.vehicle[id] = {
vehicle: { vehicle: {
...this.vehicle[id].vehicle, ...this.vehicle[id].vehicle,
...response.data, ...updated,
}, },
}; };
this.vehicles.data = this.vehicles.data.map((vehicle) => this.vehicles.data = this.vehicles.data.map((vehicle) =>
vehicle.vehicle.id === id vehicle.vehicle.id === id
? { ? {
...vehicle, ...vehicle,
...response.data, vehicle: {
...vehicle.vehicle,
...updated,
},
} }
: vehicle : vehicle
); );

View File

@@ -1,10 +1,11 @@
import { Modal as MuiModal, Typography, Box } from "@mui/material"; import { Modal as MuiModal, Typography, Box, SxProps, Theme } from "@mui/material";
interface ModalProps { interface ModalProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
children: React.ReactNode; children: React.ReactNode;
title?: string; title?: string;
sx?: SxProps<Theme>;
} }
const style = { const style = {
@@ -19,7 +20,7 @@ const style = {
borderRadius: 2, borderRadius: 2,
}; };
export const Modal = ({ open, onClose, children, title }: ModalProps) => { export const Modal = ({ open, onClose, children, title, sx }: ModalProps) => {
return ( return (
<MuiModal <MuiModal
open={open} open={open}
@@ -27,7 +28,7 @@ export const Modal = ({ open, onClose, children, title }: ModalProps) => {
aria-labelledby="modal-modal-title" aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description" aria-describedby="modal-modal-description"
> >
<Box sx={style}> <Box sx={{ ...style, ...sx }}>
{title && ( {title && (
<Typography <Typography
id="modal-modal-title" id="modal-modal-title"

1
src/vite-env.d.ts vendored
View File

@@ -1 +1,2 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
declare const __APP_VERSION__: string;

View File

@@ -0,0 +1,132 @@
import { API_URL, authInstance, Modal } from "@shared";
import { CircularProgress } from "@mui/material";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
interface DeviceLogChunk {
date?: string;
lines?: string[];
}
interface DeviceLogsModalProps {
open: boolean;
deviceUuid: string | null;
onClose: () => void;
}
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(date);
};
const formatLogTimestamp = (timestampStr: string) => {
return timestampStr.replace(/^\[|\]$/g, "").trim();
};
const parseLogLine = (line: string, index: number) => {
const match = line.match(/^(\[[^\]]+\])\s*(.*)$/);
if (match) {
return { id: index, time: match[1], text: match[2].trim() || line };
}
return { id: index, time: "", text: line };
};
const toYYYYMMDD = (d: Date) => d.toISOString().slice(0, 10);
export const DeviceLogsModal = ({
open,
deviceUuid,
onClose,
}: DeviceLogsModalProps) => {
const [chunks, setChunks] = useState<DeviceLogChunk[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const today = new Date();
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
const dateStr = toYYYYMMDD(today);
const dateStrYesterday = toYYYYMMDD(yesterday);
useEffect(() => {
if (!open || !deviceUuid) return;
const fetchLogs = async () => {
setIsLoading(true);
setError(null);
try {
const { data } = await authInstance.get<DeviceLogChunk[]>(
`${API_URL}/devices/${deviceUuid}/logs`,
{ params: { from: dateStrYesterday, to: dateStr } }
);
setChunks(Array.isArray(data) ? data : []);
} catch (err: unknown) {
const msg =
err && typeof err === "object" && "message" in err
? String((err as { message?: string }).message)
: "Ошибка загрузки логов";
setError(msg);
toast.error(msg);
} finally {
setIsLoading(false);
}
};
fetchLogs();
}, [open, deviceUuid, dateStr]);
const logs = chunks.flatMap((chunk, chunkIdx) =>
(chunk.lines ?? []).map((line, i) =>
parseLogLine(line, chunkIdx * 10000 + i)
)
);
return (
<Modal open={open} onClose={onClose} sx={{ width: "80vw", p: 1.5 }}>
<div className="flex flex-col gap-6 h-[85vh]">
<div className="flex gap-3 items-center justify-between w-full">
<h2 className="text-2xl font-semibold text-[#000000BF]">Логи</h2>
<span className="text-lg text-[#00000040]">{formatDate(today)}</span>
</div>
<div className="flex-1 flex flex-col min-h-0 w-full">
{isLoading && (
<div className="w-full h-full flex items-center justify-center bg-white rounded-xl shadow-inner">
<CircularProgress />
</div>
)}
{!isLoading && error && (
<div className="w-full h-full flex items-center justify-center text-red-500">
{error}
</div>
)}
{!isLoading && !error && (
<div className="w-full bg-white p-4 h-full overflow-y-auto rounded-xl shadow-inner">
<div className="flex flex-col gap-2 font-mono">
{logs.length > 0 ? (
logs.map((log) => (
<div key={log.id} className="flex gap-3 items-start p-2">
<div className="text-sm text-[#00000050] shrink-0 whitespace-nowrap pt-px">
{log.time ? `[${formatLogTimestamp(log.time)}]` : null}
</div>
<div className="text-sm text-[#000000BF] break-words w-full">
{log.text}
</div>
</div>
))
) : (
<div className="h-full flex items-center justify-center text-gray-500">
Логи отсутствуют.
</div>
)}
</div>
</div>
)}
</div>
</div>
</Modal>
);
};

View File

@@ -1,11 +1,5 @@
import Table from "@mui/material/Table"; import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import TableBody from "@mui/material/TableBody"; import { ruRU } from "@mui/x-data-grid/locales";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import Paper from "@mui/material/Paper";
import { Check, Copy, Pencil, RotateCcw, Trash2, X } from "lucide-react";
import { import {
authInstance, authInstance,
devicesStore, devicesStore,
@@ -13,13 +7,34 @@ import {
snapshotStore, snapshotStore,
vehicleStore, vehicleStore,
Vehicle, Vehicle,
carrierStore,
selectedCityStore,
VEHICLE_TYPES,
} from "@shared"; } from "@shared";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Button, Checkbox, Typography } from "@mui/material"; import {
Check,
ChevronDown,
ChevronRight,
Copy,
Pencil,
RotateCcw,
ScrollText,
Trash2,
X,
} from "lucide-react";
import {
Button,
Box,
CircularProgress,
Typography,
Checkbox,
} from "@mui/material";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { DeleteModal } from "@widgets"; import { DeleteModal } from "@widgets";
import { DeviceLogsModal } from "./DeviceLogsModal";
export type ConnectedDevice = string; export type ConnectedDevice = string;
@@ -41,13 +56,16 @@ const formatDate = (dateString: string | null) => {
second: "2-digit", second: "2-digit",
hour12: false, hour12: false,
}).format(date); }).format(date);
} catch (error) { } catch {
return "Некорректная дата"; return "Некорректная дата";
} }
}; };
type TableRowData = { type RowData = {
id: number;
vehicle_id: number; vehicle_id: number;
type: string;
model: string;
tail_number: string; tail_number: string;
online: boolean; online: boolean;
lastUpdate: string | null; lastUpdate: string | null;
@@ -55,53 +73,39 @@ type TableRowData = {
media: boolean; media: boolean;
connection: boolean; connection: boolean;
device_uuid: string | null; device_uuid: string | null;
current_snapshot_uuid: string | null;
snapshot_update_blocked: boolean;
}; };
function createData(
vehicle_id: number, function getVehicleTypeLabel(vehicle: Vehicle): string {
tail_number: string, return (
online: boolean, VEHICLE_TYPES.find((t) => t.value === vehicle.vehicle.type)?.label ?? "—"
lastUpdate: string | null, );
gps: boolean,
media: boolean,
connection: boolean,
device_uuid: string | null
): TableRowData {
return {
vehicle_id,
tail_number,
online,
lastUpdate,
gps,
media,
connection,
device_uuid,
};
} }
const transformDevicesToRows = (vehicles: Vehicle[]): TableRowData[] => { const transformToRows = (vehicles: Vehicle[]): RowData[] => {
return vehicles.map((vehicle) => { return vehicles.map((vehicle) => {
const uuid = vehicle.vehicle.uuid; const uuid = vehicle.vehicle.uuid;
if (!uuid) const model =
vehicle.vehicle.model != null && vehicle.vehicle.model !== ""
? vehicle.vehicle.model
: "—";
const type = getVehicleTypeLabel(vehicle);
return { return {
id: vehicle.vehicle.id,
vehicle_id: vehicle.vehicle.id, vehicle_id: vehicle.vehicle.id,
type,
model,
tail_number: vehicle.vehicle.tail_number, tail_number: vehicle.vehicle.tail_number,
online: false, online: uuid ? vehicle.device_status?.online ?? false : false,
lastUpdate: null, lastUpdate: vehicle.device_status?.last_update ?? null,
gps: false, gps: uuid ? vehicle.device_status?.gps_ok ?? false : false,
media: false, media: uuid ? vehicle.device_status?.media_service_ok ?? false : false,
connection: false, connection: uuid ? vehicle.device_status?.is_connected ?? false : false,
device_uuid: null, device_uuid: uuid ?? null,
current_snapshot_uuid: vehicle.vehicle.current_snapshot_uuid ?? null,
snapshot_update_blocked: vehicle.vehicle.snapshot_update_blocked ?? false,
}; };
return createData(
vehicle.vehicle.id,
vehicle.vehicle.tail_number,
vehicle.device_status?.online ?? false,
vehicle.device_status?.last_update ?? null,
vehicle.device_status?.gps_ok ?? false,
vehicle.device_status?.media_service_ok ?? false,
vehicle.device_status?.is_connected ?? false,
uuid
);
}); });
}; };
@@ -111,130 +115,409 @@ export const DevicesTable = observer(() => {
setSelectedDevice, setSelectedDevice,
sendSnapshotModalOpen, sendSnapshotModalOpen,
toggleSendSnapshotModal, toggleSendSnapshotModal,
devices,
} = devicesStore; } = devicesStore;
const { snapshots, getSnapshots } = snapshotStore; const { snapshots, getSnapshots } = snapshotStore;
const { getVehicles, vehicles, deleteVehicle } = vehicleStore; const { getVehicles, vehicles, deleteVehicle } = vehicleStore;
const { devices } = devicesStore;
const navigate = useNavigate(); const navigate = useNavigate();
const [selectedDeviceUuids, setSelectedDeviceUuids] = useState<string[]>([]); const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [logsModalOpen, setLogsModalOpen] = useState(false);
const [logsModalDeviceUuid, setLogsModalDeviceUuid] = useState<string | null>(
null
);
const [isLoading, setIsLoading] = useState(false);
const [collapsedModels, setCollapsedModels] = useState<Set<string>>(
new Set()
);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
});
const currentTableRows = transformDevicesToRows(vehicles.data as Vehicle[]); const filterVehiclesBySelectedCity = (vehiclesList: Vehicle[]): Vehicle[] => {
const selectedCityId = selectedCityStore.selectedCityId;
if (!selectedCityId) {
return vehiclesList;
}
const carriersInSelectedCityIds = new Set(
carrierStore.carriers.ru.data
.filter((carrier) => carrier.city_id === selectedCityId)
.map((carrier) => carrier.id)
);
if (carriersInSelectedCityIds.size === 0) {
return [];
}
return vehiclesList.filter((vehicle) =>
carriersInSelectedCityIds.has(vehicle.vehicle.carrier_id)
);
};
const filteredVehicles = filterVehiclesBySelectedCity(
vehicles.data as Vehicle[]
);
const rows = useMemo(
() => transformToRows(filteredVehicles),
[filteredVehicles]
);
const groupsByModel = useMemo(() => {
const map = new Map<string, RowData[]>();
for (const row of rows) {
const list = map.get(row.model) ?? [];
list.push(row);
map.set(row.model, list);
}
return Array.from(map.entries()).map(([model, groupRows]) => ({
model,
rows: groupRows,
}));
}, [rows]);
const toggleModelCollapsed = (model: string) => {
setCollapsedModels((prev) => {
const next = new Set(prev);
if (next.has(model)) next.delete(model);
else next.add(model);
return next;
});
};
const selectedDeviceUuids = useMemo(() => {
return rows
.filter((r) => selectedIds.includes(r.id))
.map((r) => r.device_uuid)
.filter((u): u is string => u != null);
}, [rows, selectedIds]);
const selectedDeviceUuidsAllowed = useMemo(() => {
return rows
.filter(
(r) =>
selectedIds.includes(r.id) &&
r.device_uuid != null &&
!r.snapshot_update_blocked
)
.map((r) => r.device_uuid as string);
}, [rows, selectedIds]);
const columns: GridColDef[] = useMemo(
() => [
{
field: "model",
headerName: "Модель",
flex: 1,
minWidth: 120,
filterable: true,
},
{
field: "tail_number",
headerName: "Бортовой номер",
flex: 1,
minWidth: 120,
filterable: true,
},
{
field: "can_send_update",
headerName: "Обновление",
width: 90,
align: "center",
headerAlign: "center",
sortable: false,
disableColumnMenu: true,
renderCell: (params: GridRenderCellParams) => {
const rowData = params.row as RowData;
const canSend =
!rowData.snapshot_update_blocked && rowData.device_uuid !== null;
return (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "100%",
height: "100%",
pointerEvents: "none",
}}
title={
canSend ? "Можно отправить запрос" : "Блокировка обновления"
}
>
<Checkbox
checked={canSend as unknown as boolean}
disabled
size="small"
sx={{ p: 0 }}
/>
</Box>
);
},
},
{
field: "online",
headerName: "Онлайн",
width: 90,
align: "center",
headerAlign: "center",
type: "boolean",
filterable: true,
renderCell: (params: GridRenderCellParams) => (
<Box
sx={{ display: "flex", justifyContent: "center", width: "100%" }}
>
{params.value ? (
<Check size={18} className="text-green-600" />
) : (
<X size={18} className="text-red-600" />
)}
</Box>
),
},
{
field: "lastUpdate",
headerName: "Обновлено",
flex: 1,
minWidth: 140,
filterable: true,
renderCell: (params: GridRenderCellParams) =>
formatDate(params.value as string | null),
},
{
field: "snapshot_name",
headerName: "Экспорт на устройстве",
flex: 1,
minWidth: 140,
filterable: true,
valueGetter: (_value, row, _apiRef) => {
const rowData = row as RowData;
const uuid = rowData.current_snapshot_uuid;
if (!uuid) return "—";
const snapshot = (snapshots as Snapshot[]).find((s) => s.ID === uuid);
return snapshot?.Name ?? uuid;
},
},
{
field: "gps",
headerName: "GPS",
width: 70,
align: "center",
headerAlign: "center",
type: "boolean",
filterable: true,
renderCell: (params: GridRenderCellParams) => (
<Box
sx={{ display: "flex", justifyContent: "center", width: "100%" }}
>
{params.value ? (
<Check size={18} className="text-green-600" />
) : (
<X size={18} className="text-red-600" />
)}
</Box>
),
},
{
field: "actions",
headerName: "Действия",
width: 160,
align: "center",
headerAlign: "center",
sortable: false,
filterable: false,
renderCell: (params: GridRenderCellParams) => {
const row = params.row as RowData;
const handleReloadStatus = async () => {
if (!row.device_uuid) return;
setSelectedDevice(row.device_uuid);
try {
await authInstance.post(
`/devices/${row.device_uuid}/request-status`
);
await getVehicles();
await getDevices();
toast.success("Статус устройства обновлен");
} catch (error) {
console.error(
`Error requesting status for device ${row.device_uuid}:`,
error
);
toast.error("Ошибка сервера");
}
};
return (
<Box
sx={{
display: "flex",
gap: "20px",
height: "100%",
alignItems: "center",
justifyContent: "center",
}}
>
<button
onClick={(e) => {
e.stopPropagation();
navigate(`/vehicle/${row.vehicle_id}/edit`);
}}
title="Редактировать транспорт"
>
<Pencil size={16} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleReloadStatus();
}}
title="Перезапросить статус"
disabled={
!row.device_uuid || !devices.includes(row.device_uuid)
}
>
<RotateCcw size={16} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
if (row.device_uuid) {
navigator.clipboard.writeText(row.device_uuid);
toast.success("UUID скопирован");
}
}}
title="Копировать UUID"
>
<Copy size={16} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
if (row.device_uuid) {
setLogsModalDeviceUuid(row.device_uuid);
setLogsModalOpen(true);
}
}}
title="Логи устройства"
>
<ScrollText size={16} />
</button>
</Box>
);
},
},
],
[
devices,
getDevices,
getVehicles,
navigate,
setSelectedDevice,
snapshots,
setLogsModalDeviceUuid,
setLogsModalOpen,
]
);
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
setIsLoading(true);
await getVehicles(); await getVehicles();
await getDevices(); await getDevices();
await getSnapshots(); await getSnapshots();
setIsLoading(false);
}; };
fetchData(); fetchData();
}, [getDevices, getSnapshots]); }, [getDevices, getSnapshots, getVehicles]);
const isAllSelected = useEffect(() => {
currentTableRows.length > 0 && carrierStore.getCarriers("ru");
selectedDeviceUuids.length === currentTableRows.length; }, []);
const handleSelectAllDevices = () => {
if (isAllSelected) {
setSelectedDeviceUuids([]);
} else {
setSelectedDeviceUuids(
currentTableRows.map((row) => row.device_uuid ?? "")
);
}
};
const handleSelectDevice = (
event: React.ChangeEvent<HTMLInputElement>,
deviceUuid: string
) => {
if (event.target.checked) {
setSelectedDeviceUuids((prevSelected) => [...prevSelected, deviceUuid]);
} else {
setSelectedDeviceUuids((prevSelected) =>
prevSelected.filter((uuid) => uuid !== deviceUuid)
);
}
};
const handleOpenSendSnapshotModal = () => { const handleOpenSendSnapshotModal = () => {
if (selectedDeviceUuids.length > 0) { if (selectedDeviceUuidsAllowed.length > 0) {
toggleSendSnapshotModal(); toggleSendSnapshotModal();
} }
}; };
const handleReloadStatus = async (uuid: string) => {
setSelectedDevice(uuid);
try {
await authInstance.post(`/devices/${uuid}/request-status`);
await getVehicles();
await getDevices();
} catch (error) {
console.error(`Error requesting status for device ${uuid}:`, error);
}
};
const handleSendSnapshotAction = async (snapshotId: string) => { const handleSendSnapshotAction = async (snapshotId: string) => {
if (selectedDeviceUuids.length === 0) return; if (selectedDeviceUuidsAllowed.length === 0) return;
const blockedCount =
selectedDeviceUuids.length - selectedDeviceUuidsAllowed.length;
if (blockedCount > 0) {
toast.info(
`Обновление ПО не отправлено на ${blockedCount} устройств (блокировка)`
);
}
const send = async (deviceUuid: string) => { const send = async (deviceUuid: string) => {
try { try {
await authInstance.post( await authInstance.post(
`/devices/${deviceUuid}/force-snapshot-update`, `/devices/${deviceUuid}/force-snapshot-update`,
{ { snapshot_id: snapshotId }
snapshot_id: snapshotId,
}
); );
toast.success(`Снапшот отправлен на устройство `); toast.success("Обновление ПО отправлено на устройство");
} catch (error) { } catch (error) {
console.error(`Error sending snapshot to device ${deviceUuid}:`, error); console.error(`Error sending snapshot to device ${deviceUuid}:`, error);
toast.error(`Не удалось отправить снапшот на устройство`); toast.error("Не удалось отправить обновление ПО на устройство");
} }
}; };
try {
const snapshotPromises = selectedDeviceUuids.map((deviceUuid) => {
return send(deviceUuid);
});
await Promise.allSettled(snapshotPromises);
await Promise.allSettled(selectedDeviceUuidsAllowed.map(send));
await getDevices(); await getDevices();
setSelectedDeviceUuids([]); setSelectedIds([]);
toggleSendSnapshotModal(); toggleSendSnapshotModal();
} catch (error) {
console.error("Error in snapshot sending process:", error);
}
};
const getVehicleIdsByUuids = (uuids: string[]): number[] => {
return vehicles.data
.filter((vehicle) => uuids.includes(vehicle.vehicle.uuid ?? ""))
.map((vehicle) => vehicle.vehicle.id);
}; };
const handleDeleteVehicles = async () => { const handleDeleteVehicles = async () => {
if (selectedDeviceUuids.length === 0) return; if (selectedIds.length === 0) return;
const vehicleIds = getVehicleIdsByUuids(selectedDeviceUuids);
try { try {
await Promise.all(vehicleIds.map((id) => deleteVehicle(id))); await Promise.all(selectedIds.map((id) => deleteVehicle(id)));
await getVehicles(); await getVehicles();
await getDevices(); await getDevices();
setSelectedDeviceUuids([]); setSelectedIds([]);
setIsDeleteModalOpen(false); setIsDeleteModalOpen(false);
toast.success(`Удалено устройств: ${vehicleIds.length}`); toast.success(`Удалено устройств: ${selectedIds.length}`);
} catch (error) { } catch (error) {
console.error("Error deleting vehicles:", error); console.error("Error deleting vehicles:", error);
toast.error("Ошибка при удалении устройств"); toast.error("Ошибка при удалении устройств");
} }
}; };
const createSelectionHandler = (groupRowIds: number[]) => {
const groupIdSet = new Set(groupRowIds);
return (newSelection: { ids: Set<number | string> } | number[]) => {
let newIds: number[] = [];
if (Array.isArray(newSelection)) {
newIds = newSelection.map((id) => Number(id));
} else if (
newSelection &&
typeof newSelection === "object" &&
"ids" in newSelection
) {
const idsSet = newSelection.ids as Set<number | string>;
newIds = Array.from(idsSet)
.map((id) => (typeof id === "string" ? Number.parseInt(id, 10) : id))
.filter((id) => !Number.isNaN(id));
}
setSelectedIds((prev) => {
const fromOtherGroups = prev.filter((id) => !groupIdSet.has(id));
return [...fromOtherGroups, ...newIds];
});
};
};
return ( return (
<> <>
<TableContainer component={Paper} sx={{ mt: 2 }}> <div className="w-full">
<div className="flex justify-end p-3 gap-2 "> <div className="flex justify-end mb-5 gap-2 flex-wrap">
<Button <Button
variant="contained" variant="contained"
color="primary" color="primary"
@@ -243,16 +526,7 @@ export const DevicesTable = observer(() => {
> >
Добавить устройство Добавить устройство
</Button> </Button>
</div> {selectedIds.length > 0 && (
<div className="flex justify-end p-3 gap-2 items-center">
<Button
variant="outlined"
onClick={handleSelectAllDevices}
size="small"
>
{isAllSelected ? "Снять выбор" : "Выбрать все"}
</Button>
{selectedDeviceUuids.length > 0 && (
<Button <Button
variant="contained" variant="contained"
color="error" color="error"
@@ -260,227 +534,136 @@ export const DevicesTable = observer(() => {
size="small" size="small"
startIcon={<Trash2 size={16} />} startIcon={<Trash2 size={16} />}
> >
Удалить ({selectedDeviceUuids.length}) Удалить ({selectedIds.length})
</Button> </Button>
)} )}
<Button <Button
variant="contained" variant="contained"
color="primary" color="primary"
disabled={selectedDeviceUuids.length === 0} disabled={selectedDeviceUuidsAllowed.length === 0}
onClick={handleOpenSendSnapshotModal} onClick={handleOpenSendSnapshotModal}
size="small" size="small"
> >
Отправить снапшот ({selectedDeviceUuids.length}) Обновление ПО ({selectedDeviceUuidsAllowed.length}
{selectedDeviceUuids.length !== selectedDeviceUuidsAllowed.length &&
`/${selectedDeviceUuids.length}`}
)
</Button> </Button>
</div> </div>
<Table sx={{ minWidth: 650 }} aria-label="devices table" size="small">
<TableHead>
<TableRow>
<TableCell padding="checkbox">
<Checkbox
indeterminate={
selectedDeviceUuids.length > 0 &&
selectedDeviceUuids.length < currentTableRows.length
}
checked={isAllSelected}
onChange={handleSelectAllDevices}
inputProps={{ "aria-label": "select all devices" }}
size="small"
/>
</TableCell>
<TableCell align="center">Борт. номер</TableCell>
<TableCell align="center">Онлайн</TableCell>
<TableCell align="center">Обновлено</TableCell>
<TableCell align="center">GPS</TableCell>
<TableCell align="center">Медиа</TableCell>
<TableCell align="center">Связь</TableCell>
<TableCell align="center">Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{currentTableRows.map((row) => (
<TableRow
key={row.tail_number}
hover
role="checkbox"
aria-checked={selectedDeviceUuids.includes(
row.device_uuid ?? ""
)}
selected={selectedDeviceUuids.includes(row.device_uuid ?? "")}
onClick={(event) => {
if (
(event.target as HTMLElement).closest("button") === null &&
(event.target as HTMLElement).closest(
'input[type="checkbox"]'
) === null
) {
if (event.shiftKey) {
if (row.device_uuid) {
navigator.clipboard
.writeText(row.device_uuid)
.then(() => {
toast.success(`UUID скопирован`);
})
.catch(() => {
toast.error("Не удалось скопировать UUID");
});
} else {
toast.warning("Устройство не имеет UUID");
}
}
if (!event.shiftKey) { {groupsByModel.length === 0 ? (
handleSelectDevice( <Box
{ sx={{
target: { mt: 5,
checked: !selectedDeviceUuids.includes( py: 4,
row.device_uuid ?? "" textAlign: "center",
), color: "text.secondary",
}, }}
} as React.ChangeEvent<HTMLInputElement>, >
row.device_uuid ?? "" {isLoading ? (
<CircularProgress size={20} />
) : (
"Нет устройств для отображения"
)}
</Box>
) : (
<div className="flex flex-col gap-6 mt-4">
{groupsByModel.map(({ model: groupModel, rows: groupRows }) => {
const isCollapsed = collapsedModels.has(groupModel);
const groupRowIds = groupRows.map((r) => r.id);
const selectedInGroup = selectedIds.filter((id) =>
groupRowIds.includes(id)
); );
return (
<div
key={groupModel}
className="border border-gray-200 rounded-lg overflow-hidden"
>
<button
type="button"
onClick={() => toggleModelCollapsed(groupModel)}
className="w-full flex items-center gap-2 px-4 py-3 bg-gray-50 hover:bg-gray-100 text-left border-b border-gray-200"
>
{isCollapsed ? (
<ChevronRight size={20} className="text-gray-600" />
) : (
<ChevronDown size={20} className="text-gray-600" />
)}
<Typography variant="h6" component="span" fontWeight={600}>
{groupModel}
</Typography>
<Typography variant="body2" color="textSecondary">
({groupRows.length}{" "}
{groupRows.length === 1 ? "устройство" : "устройств"})
</Typography>
</button>
{!isCollapsed && (
<Box sx={{ p: 0 }}>
<DataGrid
rows={groupRows}
columns={columns}
checkboxSelection
disableRowSelectionExcludeModel
loading={isLoading}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
pageSizeOptions={[50]}
onRowSelectionModelChange={
createSelectionHandler(groupRowIds) as (
ids: unknown
) => void
} }
rowSelectionModel={{
type: "include",
ids: new Set(selectedInGroup),
}}
localeText={
ruRU.components.MuiDataGrid.defaultProps.localeText
} }
autoHeight
slots={{
noRowsOverlay: () => null,
}} }}
sx={{ sx={{
cursor: "pointer", border: "none",
"&:last-child td, &:last-child th": { border: 0 }, "& .MuiDataGrid-columnHeaders": {
borderBottom: 1,
borderColor: "divider",
},
"& .MuiDataGrid-cell": {
borderBottom: 1,
borderColor: "divider",
},
}} }}
>
<TableCell padding="checkbox">
<Checkbox
checked={selectedDeviceUuids.includes(
row.device_uuid ?? ""
)}
onChange={(event) =>
handleSelectDevice(event, row.device_uuid ?? "")
}
size="small"
/> />
</TableCell> </Box>
<TableCell
align="center"
component="th"
scope="row"
id={`device-label-${row.device_uuid}`}
>
{row.tail_number}
</TableCell>
<TableCell align="center">
<div className="flex items-center justify-center">
{row.online ? (
<Check size={18} className="text-green-600" />
) : (
<X size={18} className="text-red-600" />
)} )}
</div> </div>
</TableCell> );
<TableCell align="center"> })}
<div className="flex items-center justify-center">
{formatDate(row.lastUpdate)}
</div> </div>
</TableCell>
<TableCell align="center">
<div className="flex items-center justify-center">
{row.gps ? (
<Check size={18} className="text-green-600" />
) : (
<X size={18} className="text-red-600" />
)} )}
</div> </div>
</TableCell>
<TableCell align="center">
<div className="flex items-center justify-center">
{row.media ? (
<Check size={18} className="text-green-600" />
) : (
<X size={18} className="text-red-600" />
)}
</div>
</TableCell>
<TableCell align="center">
<div className="flex items-center justify-center">
{row.connection ? (
<Check size={18} className="text-green-600" />
) : (
<X size={18} className="text-red-600" />
)}
</div>
</TableCell>
<TableCell align="center">
<div className="flex items-center justify-center gap-1">
<Button
onClick={(e) => {
e.stopPropagation();
navigate(`/vehicle/${row.vehicle_id}/edit`);
}}
title="Редактировать транспорт"
size="small"
variant="text"
>
<Pencil size={16} />
</Button>
<Button
onClick={async (e) => {
e.stopPropagation();
try {
if (
row.device_uuid &&
devices.find((device) => device === row.device_uuid)
) {
await handleReloadStatus(row.device_uuid);
toast.success("Статус устройства обновлен");
} else {
toast.error("Нет связи с устройством");
}
} catch (error) {
toast.error("Ошибка сервера");
}
}}
title="Перезапросить статус"
size="small"
variant="text"
>
<RotateCcw size={16} />
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(row.device_uuid ?? "");
toast.success("UUID скопирован");
}}
title="Копировать UUID"
size="small"
variant="text"
>
<Copy size={16} />
</Button>
</div>
</TableCell>
</TableRow>
))}
{currentTableRows.length === 0 && (
<TableRow>
<TableCell colSpan={8} align="center">
Нет устройств для отображения.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<Modal open={sendSnapshotModalOpen} onClose={toggleSendSnapshotModal}> <Modal open={sendSnapshotModalOpen} onClose={toggleSendSnapshotModal}>
<Typography variant="h6" component="h2" sx={{ mb: 1 }}> <Box component="h2" sx={{ mb: 1, typography: "h6" }}>
Отправить снапшот Обновление ПО
</Typography> </Box>
<Typography variant="body1" sx={{ mb: 2 }}> <Box sx={{ mb: 2 }}>
Выбрано устройств:{" "} Выбрано устройств для обновления:{" "}
<strong className="text-blue-600"> <strong className="text-blue-600">
{selectedDeviceUuids.length} {selectedDeviceUuidsAllowed.length}
</strong> </strong>
</Typography> {selectedDeviceUuids.length !== selectedDeviceUuidsAllowed.length && (
<span className="text-amber-600 ml-1">
(пропущено{" "}
{selectedDeviceUuids.length - selectedDeviceUuidsAllowed.length} с
блокировкой)
</span>
)}
</Box>
<div className="mt-2 flex flex-col gap-2 max-h-[300px] overflow-y-auto pr-2"> <div className="mt-2 flex flex-col gap-2 max-h-[300px] overflow-y-auto pr-2">
{snapshots && (snapshots as Snapshot[]).length > 0 ? ( {snapshots && (snapshots as Snapshot[]).length > 0 ? (
(snapshots as Snapshot[]).map((snapshot) => ( (snapshots as Snapshot[]).map((snapshot) => (
@@ -495,9 +678,9 @@ export const DevicesTable = observer(() => {
</Button> </Button>
)) ))
) : ( ) : (
<Typography variant="body2" color="textSecondary"> <Box sx={{ typography: "body2", color: "text.secondary" }}>
Нет доступных снапшотов. Нет доступных экспортов медиа.
</Typography> </Box>
)} )}
</div> </div>
<Button <Button
@@ -516,6 +699,15 @@ export const DevicesTable = observer(() => {
onDelete={handleDeleteVehicles} onDelete={handleDeleteVehicles}
onCancel={() => setIsDeleteModalOpen(false)} onCancel={() => setIsDeleteModalOpen(false)}
/> />
<DeviceLogsModal
open={logsModalOpen}
deviceUuid={logsModalDeviceUuid}
onClose={() => {
setLogsModalOpen(false);
setLogsModalDeviceUuid(null);
}}
/>
</> </>
); );
}); });

View File

@@ -8,7 +8,7 @@ import { AppBar } from "./ui/AppBar";
import { Drawer } from "./ui/Drawer"; import { Drawer } from "./ui/Drawer";
import { DrawerHeader } from "./ui/DrawerHeader"; import { DrawerHeader } from "./ui/DrawerHeader";
import { NavigationList } from "@features"; import { NavigationList } from "@features";
import { authStore, userStore, menuStore } from "@shared"; import { authStore, userStore, menuStore, isMediaIdEmpty } from "@shared";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useEffect } from "react"; import { useEffect } from "react";
import { Typography } from "@mui/material"; import { Typography } from "@mui/material";
@@ -67,18 +67,18 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
</div> </div>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<div className="flex flex-col gap-1">
{(() => { {(() => {
const currentUser = users?.data?.find(
(user) => user.id === (authStore.payload as { user_id?: number })?.user_id,
);
const hasAvatar =
currentUser?.icon && !isMediaIdEmpty(currentUser.icon);
const token = localStorage.getItem("token");
return ( return (
<> <>
<p className=" text-white"> <div className="flex flex-col gap-1">
{ <p className="text-white">{currentUser?.name}</p>
users?.data?.find(
// @ts-ignore
(user) => user.id === authStore.payload?.user_id
)?.name
}
</p>
<div <div
className="text-center text-xs" className="text-center text-xs"
style={{ style={{
@@ -88,19 +88,28 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
padding: "2px 10px", padding: "2px 10px",
}} }}
> >
{/* @ts-ignore */} {(authStore.payload as { is_admin?: boolean })?.is_admin
{authStore.payload?.is_admin
? "Администратор" ? "Администратор"
: "Режим пользователя"} : "Режим пользователя"}
</div> </div>
</div>
<div className="w-10 h-10 rounded-full flex items-center justify-center overflow-hidden bg-gray-600 shrink-0">
{hasAvatar ? (
<img
src={`${
import.meta.env.VITE_KRBL_MEDIA
}${currentUser!.icon}/download?token=${token}`}
alt="Аватар"
className="w-full h-full object-cover"
/>
) : (
<User className="text-white" size={20} />
)}
</div>
</> </>
); );
})()} })()}
</div> </div>
<div className="w-10 h-10 bg-gray-600 rounded-full flex items-center justify-center">
<User />
</div>
</div>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
<Drawer variant="permanent" open={open}> <Drawer variant="permanent" open={open}>
@@ -138,6 +147,9 @@ export const Layout: React.FC<LayoutProps> = observer(({ children }) => {
)} )}
</DrawerHeader> </DrawerHeader>
<NavigationList open={open} onDrawerOpen={handleDrawerOpen} /> <NavigationList open={open} onDrawerOpen={handleDrawerOpen} />
<div className="mt-auto flex justify-center items-center pb-5 text-sm text-gray-300">
v.{__APP_VERSION__}
</div>
</Drawer> </Drawer>
<Box <Box
component="main" component="main"

View File

@@ -16,6 +16,7 @@ import {
languageStore, languageStore,
Language, Language,
cityStore, cityStore,
isMediaIdEmpty,
SelectMediaDialog, SelectMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
SightLanguageInfo, SightLanguageInfo,
@@ -308,7 +309,7 @@ export const CreateInformationTab = observer(
<ImageUploadCard <ImageUploadCard
title="Водяной знак (левый верхний)" title="Водяной знак (левый верхний)"
imageKey="watermark_lu" imageKey="watermark_lu"
imageUrl={sight.watermark_lu} imageUrl={isMediaIdEmpty(sight.watermark_lu) ? null : sight.watermark_lu}
onImageClick={() => { onImageClick={() => {
setIsPreviewMediaOpen(true); setIsPreviewMediaOpen(true);
setMediaId(sight.watermark_lu ?? ""); setMediaId(sight.watermark_lu ?? "");
@@ -363,7 +364,7 @@ export const CreateInformationTab = observer(
<VideoPreviewCard <VideoPreviewCard
title="Видеозаставка" title="Видеозаставка"
videoId={sight.video_preview} videoId={isMediaIdEmpty(sight.video_preview) ? null : sight.video_preview}
onVideoClick={handleVideoPreviewClick} onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => { onDeleteVideoClick={() => {
handleChange({ handleChange({

View File

@@ -17,6 +17,7 @@ import {
Language, Language,
cityStore, cityStore,
editSightStore, editSightStore,
isMediaIdEmpty,
SelectMediaDialog, SelectMediaDialog,
PreviewMediaDialog, PreviewMediaDialog,
SightLanguageInfo, SightLanguageInfo,
@@ -334,7 +335,7 @@ export const InformationTab = observer(
<ImageUploadCard <ImageUploadCard
title="Водяной знак (левый верхний)" title="Водяной знак (левый верхний)"
imageKey="watermark_lu" imageKey="watermark_lu"
imageUrl={sight.common.watermark_lu} imageUrl={isMediaIdEmpty(sight.common.watermark_lu) ? null : sight.common.watermark_lu}
onImageClick={() => { onImageClick={() => {
setIsPreviewMediaOpen(true); setIsPreviewMediaOpen(true);
setMediaId(sight.common.watermark_lu ?? ""); setMediaId(sight.common.watermark_lu ?? "");
@@ -396,7 +397,7 @@ export const InformationTab = observer(
<VideoPreviewCard <VideoPreviewCard
title="Видеозаставка" title="Видеозаставка"
videoId={sight.common.video_preview} videoId={isMediaIdEmpty(sight.common.video_preview) ? null : sight.common.video_preview}
onVideoClick={handleVideoPreviewClick} onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => { onDeleteVideoClick={() => {
handleChange( handleChange(

View File

@@ -19,7 +19,7 @@ export const SnapshotRestore = ({
> >
<div className="bg-white p-4 w-140 rounded-lg flex flex-col gap-4 items-center"> <div className="bg-white p-4 w-140 rounded-lg flex flex-col gap-4 items-center">
<p className="text-black w-110 text-center"> <p className="text-black w-110 text-center">
Вы уверены, что хотите восстановить этот снапшот? Вы уверены, что хотите восстановить этот экспорт медиа?
</p> </p>
<p className="text-black w-100 text-center"> <p className="text-black w-100 text-center">
Это действие нельзя будет отменить. Это действие нельзя будет отменить.

File diff suppressed because one or more lines are too long

View File

@@ -2,12 +2,10 @@ import { defineConfig, type UserConfigExport } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import path from "path"; import path from "path";
import pkg from "./package.json";
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [react(), tailwindcss()],
react(),
tailwindcss(),
],
resolve: { resolve: {
alias: { alias: {
"@shared": path.resolve(__dirname, "src/shared"), "@shared": path.resolve(__dirname, "src/shared"),
@@ -18,9 +16,11 @@ export default defineConfig({
"@app": path.resolve(__dirname, "src/app"), "@app": path.resolve(__dirname, "src/app"),
}, },
}, },
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
build: { build: {
chunkSizeWarningLimit: 5000, chunkSizeWarningLimit: 5000,
}, },
}); });