feat: Rewrite route edit and create page for new field

This commit is contained in:
2025-10-30 01:26:32 +03:00
parent 2b48ade2f1
commit 3526648d1f
8 changed files with 1310 additions and 213 deletions

View File

@@ -13,7 +13,7 @@ import {
DialogContent, DialogContent,
DialogActions, DialogActions,
} from "@mui/material"; } from "@mui/material";
import { MediaViewer } from "@widgets"; import { MediaViewer, VideoPreviewCard } from "@widgets";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Loader2, Save, Plus } from "lucide-react"; import { ArrowLeft, Loader2, Save, Plus } from "lucide-react";
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState, useMemo } from "react";
@@ -24,9 +24,10 @@ import { articlesStore } from "../../../shared/store/ArticlesStore";
import { Route, routeStore } from "../../../shared/store/RouteStore"; import { Route, routeStore } from "../../../shared/store/RouteStore";
import { import {
languageStore, languageStore,
SelectArticleModal, ArticleSelectOrCreateDialog,
SelectMediaDialog, SelectMediaDialog,
selectedCityStore, selectedCityStore,
UploadMediaDialog,
} from "@shared"; } from "@shared";
export const RouteCreatePage = observer(() => { export const RouteCreatePage = observer(() => {
@@ -39,6 +40,7 @@ export const RouteCreatePage = observer(() => {
const [direction, setDirection] = useState("backward"); const [direction, setDirection] = useState("backward");
const [scaleMin, setScaleMin] = useState(""); const [scaleMin, setScaleMin] = useState("");
const [scaleMax, setScaleMax] = useState(""); const [scaleMax, setScaleMax] = useState("");
const [routeName, setRouteName] = useState("");
const [turn, setTurn] = useState(""); const [turn, setTurn] = useState("");
const [centerLat, setCenterLat] = useState(""); const [centerLat, setCenterLat] = useState("");
const [centerLng, setCenterLng] = useState(""); const [centerLng, setCenterLng] = useState("");
@@ -48,6 +50,8 @@ export const RouteCreatePage = observer(() => {
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 [fileToUpload, setFileToUpload] = useState<File | null>(null);
const { language } = languageStore; const { language } = languageStore;
useEffect(() => { useEffect(() => {
@@ -110,6 +114,8 @@ export const RouteCreatePage = observer(() => {
const handleArticleSelect = (articleId: number) => { const handleArticleSelect = (articleId: number) => {
setGovernorAppeal(articleId.toString()); setGovernorAppeal(articleId.toString());
setIsSelectArticleDialogOpen(false); setIsSelectArticleDialogOpen(false);
// Обновляем список статей после создания новой
articlesStore.getArticleList();
}; };
const handleVideoSelect = (media: { const handleVideoSelect = (media: {
@@ -122,6 +128,26 @@ export const RouteCreatePage = observer(() => {
setIsSelectVideoDialogOpen(false); setIsSelectVideoDialogOpen(false);
}; };
const handleVideoFileSelect = (file?: File) => {
if (file) {
setFileToUpload(file);
setIsUploadVideoDialogOpen(true);
} else {
setIsSelectVideoDialogOpen(true);
}
};
const handleVideoUpload = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
setVideoPreview(media.id);
setIsUploadVideoDialogOpen(false);
setFileToUpload(null);
};
const handleVideoPreviewClick = () => { const handleVideoPreviewClick = () => {
setIsVideoPreviewOpen(true); setIsVideoPreviewOpen(true);
}; };
@@ -167,6 +193,7 @@ export const RouteCreatePage = observer(() => {
route_number: routeNumber, route_number: routeNumber,
route_sys_number: govRouteNumber, route_sys_number: govRouteNumber,
governor_appeal, governor_appeal,
route_name: routeName,
route_direction, route_direction,
scale_min, scale_min,
scale_max, scale_max,
@@ -208,6 +235,13 @@ export const RouteCreatePage = observer(() => {
<div className="flex flex-col gap-10 w-full items-end"> <div className="flex flex-col gap-10 w-full items-end">
<Box className="flex flex-col gap-6 w-full"> <Box className="flex flex-col gap-6 w-full">
<TextField
className="w-full"
label="Название маршрута"
required
value={routeName}
onChange={(e) => setRouteName(e.target.value)}
/>
<FormControl fullWidth required> <FormControl fullWidth required>
<InputLabel>Выберите перевозчика</InputLabel> <InputLabel>Выберите перевозчика</InputLabel>
<Select <Select
@@ -279,6 +313,7 @@ export const RouteCreatePage = observer(() => {
}, },
}} }}
/> />
<TextField <TextField
className="w-full" className="w-full"
label="Номер маршрута в Говорящем Городе" label="Номер маршрута в Говорящем Городе"
@@ -287,17 +322,16 @@ export const RouteCreatePage = observer(() => {
onChange={(e) => setGovRouteNumber(e.target.value)} onChange={(e) => setGovRouteNumber(e.target.value)}
/> />
{/* Заменяем Select на кнопку для выбора статьи */} <Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
<Box className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">
Обращение к пассажирам Обращение к пассажирам
</label> </Typography>
<Box className="flex gap-2"> <Box className="flex gap-2">
<TextField <TextField
className="flex-1" className="flex-1"
value={selectedArticle?.heading || "Статья не выбрана"} value={selectedArticle?.heading || "Статья не выбрана"}
placeholder="Выберите статью" placeholder="Выберите статью"
disabled disabled
fullWidth
sx={{ sx={{
"& .MuiInputBase-input": { "& .MuiInputBase-input": {
color: selectedArticle ? "inherit" : "#999", color: selectedArticle ? "inherit" : "#999",
@@ -313,73 +347,18 @@ export const RouteCreatePage = observer(() => {
Выбрать Выбрать
</Button> </Button>
</Box> </Box>
</Box>
{/* Селектор видеозаставки */} {/* Видео-превью как на странице редактирования */}
<Box className="flex flex-col gap-2"> <VideoPreviewCard
<label className="text-sm font-medium text-gray-700"> title="Видеозаставка"
Видеозаставка videoId={videoPreview}
</label> onVideoClick={handleVideoPreviewClick}
<Box className="flex gap-2"> onDeleteVideoClick={() => {
<Box
className="flex-1"
onClick={handleVideoPreviewClick}
sx={{
cursor:
videoPreview && videoPreview !== "" ? "pointer" : "default",
}}
>
<Box
className="w-full h-[50px] border border-gray-400 rounded-sm flex items-center justify-between px-4"
sx={{
"& .MuiInputBase-input": {
color:
videoPreview && videoPreview !== ""
? "inherit"
: "#999",
cursor:
videoPreview && videoPreview !== ""
? "pointer"
: "default",
},
}}
>
<Typography variant="body1" className="text-sm">
{videoPreview && videoPreview !== ""
? "Видео выбрано"
: "Видео не выбрано"}
</Typography>
{videoPreview && videoPreview !== "" && (
<Box
onClick={(e) => {
e.stopPropagation();
setVideoPreview(""); setVideoPreview("");
}} }}
sx={{ onSelectVideoClick={handleVideoFileSelect}
cursor: "pointer", className="w-full"
color: "#999", />
"&:hover": {
color: "#666",
},
}}
>
<Typography variant="body1" className="text-lg font-bold">
×
</Typography>
</Box>
)}
</Box>
</Box>
<Button
variant="outlined"
onClick={() => setIsSelectVideoDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Выбрать
</Button>
</Box>
</Box>
<FormControl fullWidth required> <FormControl fullWidth required>
<InputLabel>Прямой/обратный маршрут</InputLabel> <InputLabel>Прямой/обратный маршрут</InputLabel>
@@ -404,6 +383,7 @@ export const RouteCreatePage = observer(() => {
value={scaleMax} value={scaleMax}
onChange={(e) => setScaleMax(e.target.value)} onChange={(e) => setScaleMax(e.target.value)}
/> />
<TextField <TextField
className="w-full" className="w-full"
label="Поворот" label="Поворот"
@@ -441,8 +421,8 @@ export const RouteCreatePage = observer(() => {
</div> </div>
</div> </div>
{/* Модальное окно выбора статьи */} {/* Модальное окно выбора или создания статьи */}
<SelectArticleModal <ArticleSelectOrCreateDialog
open={isSelectArticleDialogOpen} open={isSelectArticleDialogOpen}
onClose={() => setIsSelectArticleDialogOpen(false)} onClose={() => setIsSelectArticleDialogOpen(false)}
onSelectArticle={handleArticleSelect} onSelectArticle={handleArticleSelect}
@@ -483,6 +463,20 @@ export const RouteCreatePage = observer(() => {
</DialogActions> </DialogActions>
</Dialog> </Dialog>
)} )}
{/* Модальное окно загрузки видео */}
<UploadMediaDialog
open={isUploadVideoDialogOpen}
onClose={() => {
setIsUploadVideoDialogOpen(false);
setFileToUpload(null);
}}
hardcodeType="video_preview"
contextObjectName={routeName || "Маршрут"}
contextType="sight"
initialFile={fileToUpload || undefined}
afterUpload={handleVideoUpload}
/>
</Paper> </Paper>
); );
}); });

View File

@@ -13,7 +13,7 @@ import {
DialogContent, DialogContent,
DialogActions, DialogActions,
} from "@mui/material"; } from "@mui/material";
import { MediaViewer } from "@widgets"; import { MediaViewer, VideoPreviewCard } from "@widgets";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { ArrowLeft, Copy, Save, Plus } from "lucide-react"; import { ArrowLeft, Copy, Save, Plus } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -24,8 +24,9 @@ import { articlesStore } from "../../../shared/store/ArticlesStore";
import { import {
routeStore, routeStore,
languageStore, languageStore,
SelectArticleModal, ArticleSelectOrCreateDialog,
SelectMediaDialog, SelectMediaDialog,
UploadMediaDialog,
} from "@shared"; } from "@shared";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { stationsStore } from "@shared"; import { stationsStore } from "@shared";
@@ -40,6 +41,8 @@ export const RouteEditPage = observer(() => {
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 [fileToUpload, setFileToUpload] = useState<File | null>(null);
const { language } = languageStore; const { language } = languageStore;
const [coordinates, setCoordinates] = useState<string>(""); const [coordinates, setCoordinates] = useState<string>("");
@@ -125,6 +128,8 @@ export const RouteEditPage = observer(() => {
governor_appeal: articleId, governor_appeal: articleId,
}); });
setIsSelectArticleDialogOpen(false); setIsSelectArticleDialogOpen(false);
// Обновляем список статей после создания новой
articlesStore.getArticleList();
}; };
const handleVideoSelect = (media: { const handleVideoSelect = (media: {
@@ -139,6 +144,28 @@ export const RouteEditPage = observer(() => {
setIsSelectVideoDialogOpen(false); setIsSelectVideoDialogOpen(false);
}; };
const handleVideoFileSelect = (file?: File) => {
if (file) {
setFileToUpload(file);
setIsUploadVideoDialogOpen(true);
} else {
setIsSelectVideoDialogOpen(true);
}
};
const handleVideoUpload = (media: {
id: string;
filename: string;
media_name?: string;
media_type: number;
}) => {
routeStore.setEditRouteData({
video_preview: media.id,
});
setIsUploadVideoDialogOpen(false);
setFileToUpload(null);
};
const handleVideoPreviewClick = () => { const handleVideoPreviewClick = () => {
if (editRouteData.video_preview && editRouteData.video_preview !== "") { if (editRouteData.video_preview && editRouteData.video_preview !== "") {
setIsVideoPreviewOpen(true); setIsVideoPreviewOpen(true);
@@ -164,6 +191,17 @@ export const RouteEditPage = observer(() => {
<div className="flex flex-col gap-10 w-full items-end"> <div className="flex flex-col gap-10 w-full items-end">
<Box className="flex flex-col gap-6 w-full"> <Box className="flex flex-col gap-6 w-full">
<TextField
className="w-full"
label="Название маршрута"
required
value={editRouteData.route_name || ""}
onChange={(e) =>
routeStore.setEditRouteData({
route_name: e.target.value,
})
}
/>
<FormControl fullWidth required> <FormControl fullWidth required>
<InputLabel>Выберите перевозчика</InputLabel> <InputLabel>Выберите перевозчика</InputLabel>
<Select <Select
@@ -279,110 +317,6 @@ export const RouteEditPage = observer(() => {
} }
/> />
{/* Заменяем Select на кнопку для выбора статьи */}
<Box className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">
Обращение к пассажирам
</label>
<Box className="flex gap-2">
<TextField
className="flex-1"
value={selectedArticle?.heading || "Статья не выбрана"}
placeholder="Выберите статью"
disabled
sx={{
"& .MuiInputBase-input": {
color: selectedArticle ? "inherit" : "#999",
},
}}
/>
<Button
variant="outlined"
onClick={() => setIsSelectArticleDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Выбрать
</Button>
</Box>
</Box>
{/* Селектор видеозаставки */}
<Box className="flex flex-col gap-2">
<label className="text-sm font-medium text-gray-700">
Видеозаставка
</label>
<Box className="flex gap-2">
<Box
className="flex-1"
onClick={handleVideoPreviewClick}
sx={{
cursor:
editRouteData.video_preview &&
editRouteData.video_preview !== ""
? "pointer"
: "default",
}}
>
<Box
className="w-full h-[50px] border border-gray-400 rounded-sm flex items-center justify-between px-4"
sx={{
"& .MuiInputBase-input": {
color:
editRouteData.video_preview &&
editRouteData.video_preview !== ""
? "inherit"
: "#999",
cursor:
editRouteData.video_preview &&
editRouteData.video_preview !== ""
? "pointer"
: "default",
},
}}
>
<Typography variant="body1" className="text-sm">
{editRouteData.video_preview &&
editRouteData.video_preview !== ""
? "Видео выбрано"
: "Видео не выбрано"}
</Typography>
{editRouteData.video_preview &&
editRouteData.video_preview !== "" && (
<Box
onClick={(e) => {
e.stopPropagation();
routeStore.setEditRouteData({ video_preview: "" });
}}
sx={{
cursor: "pointer",
color: "#999",
"&:hover": {
color: "#666",
},
}}
>
<Typography
variant="body1"
className="text-lg font-bold"
>
×
</Typography>
</Box>
)}
</Box>
</Box>
<Button
variant="outlined"
onClick={() => setIsSelectVideoDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Выбрать
</Button>
</Box>
</Box>
<FormControl fullWidth required> <FormControl fullWidth required>
<InputLabel>Прямой/обратный маршрут</InputLabel> <InputLabel>Прямой/обратный маршрут</InputLabel>
<Select <Select
@@ -453,6 +387,45 @@ export const RouteEditPage = observer(() => {
}) })
} }
/> />
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Обращение к пассажирам
</Typography>
<Box className="flex gap-2">
<TextField
className="flex-1"
value={selectedArticle?.heading || "Статья не выбрана"}
placeholder="Выберите статью"
disabled
fullWidth
sx={{
"& .MuiInputBase-input": {
color: selectedArticle ? "inherit" : "#999",
},
}}
/>
<Button
variant="outlined"
onClick={() => setIsSelectArticleDialogOpen(true)}
startIcon={<Plus size={16} />}
sx={{ minWidth: "auto", px: 2 }}
>
Выбрать
</Button>
</Box>
{/* Правая часть - Видео (30%) */}
<VideoPreviewCard
title="Видеозаставка"
videoId={editRouteData.video_preview}
onVideoClick={handleVideoPreviewClick}
onDeleteVideoClick={() => {
routeStore.setEditRouteData({ video_preview: "" });
}}
onSelectVideoClick={handleVideoFileSelect}
className="w-full"
/>
</Box> </Box>
<LinkedItems <LinkedItems
@@ -494,8 +467,8 @@ export const RouteEditPage = observer(() => {
</div> </div>
</div> </div>
{/* Модальное окно выбора статьи */} {/* Модальное окно выбора или создания статьи */}
<SelectArticleModal <ArticleSelectOrCreateDialog
open={isSelectArticleDialogOpen} open={isSelectArticleDialogOpen}
onClose={() => setIsSelectArticleDialogOpen(false)} onClose={() => setIsSelectArticleDialogOpen(false)}
onSelectArticle={handleArticleSelect} onSelectArticle={handleArticleSelect}
@@ -519,6 +492,7 @@ 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 && (
<MediaViewer <MediaViewer
media={{ media={{
id: editRouteData.video_preview, id: editRouteData.video_preview,
@@ -526,12 +500,27 @@ export const RouteEditPage = observer(() => {
filename: "video_preview", filename: "video_preview",
}} }}
/> />
)}
</Box> </Box>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setIsVideoPreviewOpen(false)}>Закрыть</Button> <Button onClick={() => setIsVideoPreviewOpen(false)}>Закрыть</Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
{/* Модальное окно загрузки видео */}
<UploadMediaDialog
open={isUploadVideoDialogOpen}
onClose={() => {
setIsUploadVideoDialogOpen(false);
setFileToUpload(null);
}}
hardcodeType="video_preview"
contextObjectName={editRouteData.route_name || "Маршрут"}
contextType="sight"
initialFile={fileToUpload || undefined}
afterUpload={handleVideoUpload}
/>
</Paper> </Paper>
); );
}); });

View File

@@ -50,6 +50,22 @@ export const RouteListPage = observer(() => {
); );
}, },
}, },
{
field: "route_name",
headerName: "Название маршрута",
flex: 1,
renderCell: (params: GridRenderCellParams) => {
return (
<div className="w-full h-full flex items-center">
{params.value ? (
params.value
) : (
<Minus size={20} className="text-red-500" />
)}
</div>
);
},
},
{ {
field: "route_number", field: "route_number",
headerName: "Номер маршрута", headerName: "Номер маршрута",
@@ -122,6 +138,7 @@ export const RouteListPage = observer(() => {
carrier_id: route.carrier_id, carrier_id: route.carrier_id,
route_number: route.route_number, route_number: route.route_number,
route_direction: route.route_direction ? "Прямой" : "Обратный", route_direction: route.route_direction ? "Прямой" : "Обратный",
route_name: route.route_name,
})); }));
return ( return (

File diff suppressed because it is too large Load Diff

View File

@@ -2,3 +2,4 @@ export * from "./SelectArticleDialog";
export * from "./SelectMediaDialog"; export * from "./SelectMediaDialog";
export * from "./PreviewMediaDialog"; export * from "./PreviewMediaDialog";
export * from "./UploadMediaDialog"; export * from "./UploadMediaDialog";
export * from "./ArticleSelectOrCreateDialog";

View File

@@ -2,6 +2,7 @@ import { makeAutoObservable, runInAction } from "mobx";
import { authInstance } from "@shared"; import { authInstance } from "@shared";
export type Route = { export type Route = {
route_name: string;
carrier: string; carrier: string;
carrier_id: number; carrier_id: number;
center_latitude: number; center_latitude: number;
@@ -97,6 +98,7 @@ class RouteStore {
}; };
editRouteData = { editRouteData = {
route_name: "",
carrier: "", carrier: "",
carrier_id: 0, carrier_id: 0,
center_latitude: "", center_latitude: "",
@@ -110,7 +112,7 @@ class RouteStore {
route_sys_number: "", route_sys_number: "",
scale_max: 0, scale_max: 0,
scale_min: 0, scale_min: 0,
video_preview: "", video_preview: "" as string | undefined,
}; };
setEditRouteData = (data: any) => { setEditRouteData = (data: any) => {
@@ -118,6 +120,9 @@ class RouteStore {
}; };
editRoute = async (id: number) => { editRoute = async (id: number) => {
if (!this.editRouteData.video_preview) {
delete this.editRouteData.video_preview;
}
const response = await authInstance.patch(`/route/${id}`, { const response = await authInstance.patch(`/route/${id}`, {
...this.editRouteData, ...this.editRouteData,
center_latitude: parseFloat(this.editRouteData.center_latitude), center_latitude: parseFloat(this.editRouteData.center_latitude),

View File

@@ -11,6 +11,7 @@ interface VideoPreviewCardProps {
onDeleteVideoClick: () => void; onDeleteVideoClick: () => void;
onSelectVideoClick: (file?: File) => void; onSelectVideoClick: (file?: File) => void;
tooltipText?: string; tooltipText?: string;
className?: string;
} }
export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
@@ -20,6 +21,7 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
onDeleteVideoClick, onDeleteVideoClick,
onSelectVideoClick, onSelectVideoClick,
tooltipText, tooltipText,
className,
}) => { }) => {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
@@ -89,7 +91,10 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
gap: 1, gap: 1,
flex: 1, flex: 1,
minWidth: 150, minWidth: 150,
width: "min-content",
mx: "auto",
}} }}
className={className}
> >
<Box sx={{ display: "flex", alignItems: "center" }}> <Box sx={{ display: "flex", alignItems: "center" }}>
<Typography variant="subtitle2" gutterBottom sx={{ mb: 0, mr: 0.5 }}> <Typography variant="subtitle2" gutterBottom sx={{ mb: 0, mr: 0.5 }}>
@@ -127,7 +132,10 @@ export const VideoPreviewCard: React.FC<VideoPreviewCardProps> = ({
</button> </button>
)} )}
{videoId ? ( {videoId ? (
<Box sx={{ position: "relative", width: "100%", height: "100%" }}> <Box
sx={{ position: "relative", width: "100%", height: "100%" }}
className={className}
>
<video <video
src={`${ src={`${
import.meta.env.VITE_KRBL_MEDIA import.meta.env.VITE_KRBL_MEDIA

File diff suppressed because one or more lines are too long