feat: cache delete + empty snapshot + route page

This commit is contained in:
2026-04-28 03:50:29 +03:00
parent 248eea6f85
commit 60c6840db4
21 changed files with 770 additions and 361 deletions

View File

@@ -28,7 +28,7 @@ import { LanguageSwitcher, ImageUploadCard } from "@widgets";
export const CityCreatePage = observer(() => {
const navigate = useNavigate();
const { language } = languageStore;
const { createCityData, setCreateCityData } = cityStore;
const { createCityData, setCreateCityData, setCreateCityWeatherCode } = cityStore;
const [isLoading, setIsLoading] = useState(false);
const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false);
const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false);
@@ -139,6 +139,15 @@ export const CityCreatePage = observer(() => {
</Select>
</FormControl>
<TextField
fullWidth
label="Код города для погоды"
type="number"
value={createCityData.weather_city_code ?? 0}
helperText="Числовой код города в источнике погоды (Кранштат)"
onChange={(e) => setCreateCityWeatherCode(Number(e.target.value))}
/>
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<ImageUploadCard
title="Герб города"

View File

@@ -40,7 +40,7 @@ export const CityEditPage = observer(() => {
>(null);
const { language } = languageStore;
const { id } = useParams();
const { editCityData, editCity, getCity, setEditCityData } = cityStore;
const { editCityData, editCity, getCity, setEditCityData, setEditCityWeatherCode } = cityStore;
const { getCountries } = countryStore;
const { getMedia, getOneMedia } = mediaStore;
@@ -74,6 +74,7 @@ export const CityEditPage = observer(() => {
setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru");
setEditCityData(enData.name, enData.country_code, enData.arms, "en");
setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh");
setEditCityWeatherCode(ruData.weather_city_code ?? 0);
await getOneMedia(ruData.arms as string);
@@ -179,6 +180,15 @@ export const CityEditPage = observer(() => {
</Select>
</FormControl>
<TextField
fullWidth
label="Код города для погоды"
type="number"
value={editCityData.weather_city_code ?? 0}
helperText="Числовой код города в источнике погоды (Кранштат)"
onChange={(e) => setEditCityWeatherCode(Number(e.target.value))}
/>
<div className="w-full flex flex-col gap-4 max-w-[300px] mx-auto">
<ImageUploadCard
title="Герб города"

View File

@@ -12,7 +12,7 @@ import {
CreateRightTab,
LeaveAgree,
} from "@widgets";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
import { observer } from "mobx-react-lite";
function a11yProps(index: number) {
@@ -28,7 +28,7 @@ export const CreateSightPage = observer(() => {
const [value, setValue] = useState(0);
const { getCities } = cityStore;
const { getArticles } = articlesStore;
const { needLeaveAgree } = createSightStore;
const needLeave = createSightStore.needLeaveAgree;
const handleChange = (_: React.SyntheticEvent, newValue: number) => {
setValue(newValue);
@@ -36,9 +36,15 @@ export const CreateSightPage = observer(() => {
let blocker = useBlocker(
({ currentLocation, nextLocation }) =>
needLeaveAgree && currentLocation.pathname !== nextLocation.pathname
needLeave && currentLocation.pathname !== nextLocation.pathname,
);
useEffect(() => {
if (blocker.state === "blocked" && !needLeave) {
blocker.proceed();
}
}, [blocker.state, needLeave]);
useEffect(() => {
const fetchData = async () => {
if (!authStore.me) {

View File

@@ -6,10 +6,10 @@ import { observer } from "mobx-react-lite";
import { Map, Pencil, Trash2, Minus, Monitor } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets";
import { Box, CircularProgress } from "@mui/material";
import { Box, CircularProgress, Tooltip } from "@mui/material";
export const RouteListPage = observer(() => {
const { routes, getRoutes, deleteRoute } = routeStore;
const { routes, getRoutes, deleteRoute, sightCounts, stationCounts, countsLoading, loadCounts } = routeStore;
const { carriers, getCarriers } = carrierStore;
const navigate = useNavigate();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
@@ -38,6 +38,9 @@ export const RouteListPage = observer(() => {
await getCarriers("zh");
await getRoutes();
setIsLoading(false);
const routeIds = routeStore.routes.data.map((r) => r.id);
loadCounts(routeIds);
};
fetchData();
}, [language]);
@@ -128,6 +131,42 @@ export const RouteListPage = observer(() => {
);
},
},
{
field: "sightCount",
headerName: "Достопримечательности",
width: 180,
align: "center" as const,
headerAlign: "center" as const,
sortable: true,
renderHeader: (params: any) => (
<Tooltip title="Количество привязанных достопримечательностей">
<span>{params.colDef.headerName}</span>
</Tooltip>
),
renderCell: (params: GridRenderCellParams) => (
<div className="w-full h-full flex items-center justify-center">
{params.value === null ? <CircularProgress size={14} /> : params.value}
</div>
),
},
{
field: "stationCount",
headerName: "Остановки",
width: 120,
align: "center" as const,
headerAlign: "center" as const,
sortable: true,
renderHeader: (params: any) => (
<Tooltip title="Количество привязанных остановок">
<span>{params.colDef.headerName}</span>
</Tooltip>
),
renderCell: (params: GridRenderCellParams) => (
<div className="w-full h-full flex items-center justify-center">
{params.value === null ? <CircularProgress size={14} /> : params.value}
</div>
),
},
...(canShowActionsColumn ? [{
field: "actions",
headerName: "Действия",
@@ -195,8 +234,10 @@ export const RouteListPage = observer(() => {
route_sys_number: route.route_sys_number,
route_direction: route.route_direction ? "Прямой" : "Обратный",
route_name: route.route_name,
sightCount: sightCounts.has(route.id) ? sightCounts.get(route.id) : null,
stationCount: stationCounts.has(route.id) ? stationCounts.get(route.id) : null,
}));
}, [routes.data, carriers["ru"].data, selectedCityStore.selectedCityId, searchQuery]);
}, [routes.data, carriers["ru"].data, selectedCityStore.selectedCityId, searchQuery, sightCounts.size, stationCounts.size, countsLoading]);
return (
<>

View File

@@ -1910,7 +1910,8 @@ export const WebGLRouteMapPrototype = observer(() => {
const stationIconSizePercent =
liveStationIconSizes.get(station.id) ??
(typeof station.icon_size === "number" && Number.isFinite(station.icon_size)
(typeof station.icon_size === "number" &&
Number.isFinite(station.icon_size)
? station.icon_size
: 100);
const iconSizePx = Math.max(
@@ -2277,6 +2278,7 @@ export const WebGLRouteMapPrototype = observer(() => {
position: "absolute",
inset: 0,
pointerEvents: "none",
zIndex: 1,
}}
>
{stationData.ru.map((station, index) => {
@@ -2706,13 +2708,14 @@ export const WebGLRouteMapPrototype = observer(() => {
? camera.scale /
Math.max(customSightIconBaseScaleRef.current ?? 1, 1e-6)
: 1;
const sightIconSizePercent = sight.is_default_icon === false
? (liveSightIconSizes.get(sight.id) ??
(typeof sight.icon_size === "number" &&
Number.isFinite(sight.icon_size)
? sight.icon_size
: 100))
: (routeData?.icon_size ?? originalRouteData?.icon_size ?? 100);
const sightIconSizePercent =
sight.is_default_icon === false
? (liveSightIconSizes.get(sight.id) ??
(typeof sight.icon_size === "number" &&
Number.isFinite(sight.icon_size)
? sight.icon_size
: 100))
: (routeData?.icon_size ?? originalRouteData?.icon_size ?? 100);
const iconSize =
30 *
clamp(sightIconSizePercent / 100, 0.1, 10) *
@@ -2723,7 +2726,10 @@ export const WebGLRouteMapPrototype = observer(() => {
resizingSightIconId === sight.id);
const iconLeft = cssX - iconSize;
const iconTop = cssY - iconSize;
const sightZoomClampedScale = Math.min(Math.max(camera.scale, 1), 3);
const sightZoomClampedScale = Math.min(
Math.max(camera.scale, 1),
3,
);
const sightScaleFactor = 1 + (sightZoomClampedScale - 1) * 0.4;
const labelHeight = 24 * sightScaleFactor;
const labelPadding = 6 * sightScaleFactor;

View File

@@ -5,7 +5,7 @@ import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite";
import { DatabaseBackup, Trash2 } from "lucide-react";
import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets";
import { Alert, Box, CircularProgress } from "@mui/material";
import { Alert, Box, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from "@mui/material";
const LOW_STORAGE_THRESHOLD_GB = 10;
@@ -30,6 +30,7 @@ export const SnapshotListPage = observer(() => {
restoreSnapshot,
storageInfo,
getStorageInfo,
createEmptySnapshot,
} = snapshotStore;
const canWriteDevices = authStore.canWrite("devices");
const canCreateSnapshot =
@@ -42,6 +43,9 @@ export const SnapshotListPage = observer(() => {
const [isRestoreModalOpen, setIsRestoreModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [isEmptySnapshotModalOpen, setIsEmptySnapshotModalOpen] = useState(false);
const [emptySnapshotName, setEmptySnapshotName] = useState("");
const [isCreatingEmpty, setIsCreatingEmpty] = useState(false);
const [paginationModel, setPaginationModel] = useState({
page: 0,
pageSize: 50,
@@ -167,13 +171,27 @@ export const SnapshotListPage = observer(() => {
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl ">Экспорт Медиа</h1>
{canCreateSnapshot && (
<CreateButton
label="Создать экспорт медиа"
path="/snapshot/create"
disabled={isLowStorage}
/>
)}
<div className="flex gap-3">
{canCreateSnapshot && (
<Button
variant="outlined"
disabled={isLowStorage}
onClick={() => {
setEmptySnapshotName("");
setIsEmptySnapshotModalOpen(true);
}}
>
Создать пустой снапшот
</Button>
)}
{canCreateSnapshot && (
<CreateButton
label="Создать экспорт медиа"
path="/snapshot/create"
disabled={isLowStorage}
/>
)}
</div>
</div>
{usedGB != null && totalGB != null && (
<div className="bg-white rounded-2xl p-5 mb-6 shadow-sm border border-gray-100">
@@ -301,6 +319,46 @@ export const SnapshotListPage = observer(() => {
}}
/>
<Dialog
open={isEmptySnapshotModalOpen}
onClose={() => setIsEmptySnapshotModalOpen(false)}
fullWidth
maxWidth="xs"
>
<DialogTitle>Создать пустой снапшот</DialogTitle>
<DialogContent>
<TextField
autoFocus
fullWidth
label="Название"
value={emptySnapshotName}
onChange={(e) => setEmptySnapshotName(e.target.value)}
margin="normal"
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsEmptySnapshotModalOpen(false)}>
Отмена
</Button>
<Button
variant="contained"
disabled={!emptySnapshotName.trim() || isCreatingEmpty}
onClick={async () => {
setIsCreatingEmpty(true);
try {
await createEmptySnapshot(emptySnapshotName);
await getSnapshots();
setIsEmptySnapshotModalOpen(false);
} finally {
setIsCreatingEmpty(false);
}
}}
>
{isCreatingEmpty ? <CircularProgress size={20} /> : "Создать"}
</Button>
</DialogActions>
</Dialog>
<SnapshotRestore
open={isRestoreModalOpen}
loading={isLoading}