Compare commits
8 Commits
d557664b25
...
feature/we
| Author | SHA1 | Date | |
|---|---|---|---|
| 73070fe233 | |||
| 7cf188a55c | |||
| 2a9449ba58 | |||
| 1c097a4ca2 | |||
| 048848faa0 | |||
| 8fe6505249 | |||
| 58abe15ec4 | |||
| 144e7cb00c |
6
.env
6
.env
@@ -1,4 +1,4 @@
|
|||||||
VITE_API_URL='https://wn.krbl.ru'
|
VITE_API_URL='https://wn.st.unprism.ru'
|
||||||
VITE_REACT_APP ='https://wn.krbl.ru'
|
VITE_REACT_APP ='https://wn.st.unprism.ru/'
|
||||||
VITE_KRBL_MEDIA='https://wn.krbl.ru/media/'
|
VITE_KRBL_MEDIA='https://wn.st.unprism.ru/media/'
|
||||||
VITE_NEED_AUTH='true'
|
VITE_NEED_AUTH='true'
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "white-nights",
|
"name": "white-nights",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.0.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -162,7 +162,10 @@ const LinkedItemsContentsInner = <
|
|||||||
|
|
||||||
const filteredAvailableItems = availableItems.filter((item) => {
|
const filteredAvailableItems = availableItems.filter((item) => {
|
||||||
if (!searchQuery.trim()) return true;
|
if (!searchQuery.trim()) return true;
|
||||||
return String(item.name).toLowerCase().includes(searchQuery.toLowerCase());
|
const query = searchQuery.toLowerCase();
|
||||||
|
const name = String(item.name || "").toLowerCase();
|
||||||
|
const description = String(item.description || "").toLowerCase();
|
||||||
|
return name.includes(query) || description.includes(query);
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -206,12 +209,11 @@ const LinkedItemsContentsInner = <
|
|||||||
authInstance
|
authInstance
|
||||||
.post(`/${parentResource}/${parentId}/${childResource}`, {
|
.post(`/${parentResource}/${parentId}/${childResource}`, {
|
||||||
stations: reorderedItems.map((item) => {
|
stations: reorderedItems.map((item) => {
|
||||||
const stationData: any = { id: item.id };
|
|
||||||
const transfers = getStationTransfers(item.id, item.transfers);
|
const transfers = getStationTransfers(item.id, item.transfers);
|
||||||
if (transfers) {
|
return {
|
||||||
stationData.transfers = transfers;
|
...item,
|
||||||
}
|
transfers: transfers || item.transfers,
|
||||||
return stationData;
|
};
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -263,24 +265,23 @@ const LinkedItemsContentsInner = <
|
|||||||
const requestData = {
|
const requestData = {
|
||||||
stations: insertAtPosition(
|
stations: insertAtPosition(
|
||||||
linkedItems.map((item) => {
|
linkedItems.map((item) => {
|
||||||
const stationData: any = { id: item.id };
|
|
||||||
const transfers = getStationTransfers(item.id, item.transfers);
|
const transfers = getStationTransfers(item.id, item.transfers);
|
||||||
if (transfers) {
|
return {
|
||||||
stationData.transfers = transfers;
|
...item,
|
||||||
}
|
transfers: transfers || item.transfers,
|
||||||
return stationData;
|
};
|
||||||
}),
|
}),
|
||||||
position,
|
position,
|
||||||
(() => {
|
(() => {
|
||||||
const newStationData: any = { id: selectedItemId };
|
if (!selectedItem) return { id: selectedItemId };
|
||||||
const transfers = getStationTransfers(
|
const transfers = getStationTransfers(
|
||||||
selectedItemId,
|
selectedItemId,
|
||||||
selectedItem?.transfers
|
selectedItem.transfers
|
||||||
);
|
);
|
||||||
if (transfers) {
|
return {
|
||||||
newStationData.transfers = transfers;
|
...selectedItem,
|
||||||
}
|
transfers: transfers || selectedItem.transfers,
|
||||||
return newStationData;
|
};
|
||||||
})()
|
})()
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
@@ -365,22 +366,21 @@ const LinkedItemsContentsInner = <
|
|||||||
setIsLinkingBulk(true);
|
setIsLinkingBulk(true);
|
||||||
const selectedStations = Array.from(selectedItems).map((id) => {
|
const selectedStations = Array.from(selectedItems).map((id) => {
|
||||||
const item = allItems.find((item) => item.id === id);
|
const item = allItems.find((item) => item.id === id);
|
||||||
const stationData: any = { id };
|
if (!item) return { id };
|
||||||
const transfers = getStationTransfers(id, item?.transfers);
|
const transfers = getStationTransfers(id, item.transfers);
|
||||||
if (transfers) {
|
return {
|
||||||
stationData.transfers = transfers;
|
...item,
|
||||||
}
|
transfers: transfers || item.transfers,
|
||||||
return stationData;
|
};
|
||||||
});
|
});
|
||||||
const requestData = {
|
const requestData = {
|
||||||
stations: [
|
stations: [
|
||||||
...linkedItems.map((item) => {
|
...linkedItems.map((item) => {
|
||||||
const stationData: any = { id: item.id };
|
|
||||||
const transfers = getStationTransfers(item.id, item.transfers);
|
const transfers = getStationTransfers(item.id, item.transfers);
|
||||||
if (transfers) {
|
return {
|
||||||
stationData.transfers = transfers;
|
...item,
|
||||||
}
|
transfers: transfers || item.transfers,
|
||||||
return stationData;
|
};
|
||||||
}),
|
}),
|
||||||
...selectedStations,
|
...selectedStations,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export const MapDataProvider = observer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setIconSize(size: number) {
|
function setIconSize(size: number) {
|
||||||
const clamped = Math.max(50, Math.min(300, size));
|
const clamped = Math.max(1, Math.min(300, size));
|
||||||
setRouteChanges((prev) => {
|
setRouteChanges((prev) => {
|
||||||
if (prev.icon_size === clamped) {
|
if (prev.icon_size === clamped) {
|
||||||
return prev;
|
return prev;
|
||||||
@@ -179,7 +179,7 @@ export const MapDataProvider = observer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setFontSize(size: number) {
|
function setFontSize(size: number) {
|
||||||
const clamped = Math.max(50, Math.min(300, size));
|
const clamped = Math.max(1, Math.min(300, size));
|
||||||
setRouteChanges((prev) => {
|
setRouteChanges((prev) => {
|
||||||
if (prev.font_size === clamped) {
|
if (prev.font_size === clamped) {
|
||||||
return prev;
|
return prev;
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export function RightSidebar() {
|
|||||||
if (!Number.isFinite(value)) {
|
if (!Number.isFinite(value)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const clamped = Math.max(50, Math.min(300, Math.round(value)));
|
const clamped = Math.max(1, Math.min(300, Math.round(value)));
|
||||||
setIconSize(clamped);
|
setIconSize(clamped);
|
||||||
updateIconSize(clamped);
|
updateIconSize(clamped);
|
||||||
};
|
};
|
||||||
@@ -110,7 +110,7 @@ export function RightSidebar() {
|
|||||||
if (!Number.isFinite(value)) {
|
if (!Number.isFinite(value)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const clamped = Math.max(50, Math.min(300, Math.round(value)));
|
const clamped = Math.max(1, Math.min(300, Math.round(value)));
|
||||||
setFontSize(clamped);
|
setFontSize(clamped);
|
||||||
updateFontSize(clamped);
|
updateFontSize(clamped);
|
||||||
};
|
};
|
||||||
@@ -307,60 +307,58 @@ export function RightSidebar() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Typography variant="body2" sx={{ color: "#fff", textAlign: "center" }}>
|
<TextField
|
||||||
Размер иконок: {iconSize}%
|
type="number"
|
||||||
</Typography>
|
label="Размер иконок (%)"
|
||||||
|
variant="filled"
|
||||||
<Slider
|
|
||||||
value={iconSize}
|
value={iconSize}
|
||||||
onChange={(_, value) => {
|
onChange={(e) => {
|
||||||
if (typeof value === "number") {
|
const value = Number(e.target.value);
|
||||||
|
if (!isNaN(value)) {
|
||||||
handleIconSizeChange(value);
|
handleIconSizeChange(value);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
min={50}
|
style={{ backgroundColor: "#222", borderRadius: 4 }}
|
||||||
max={300}
|
|
||||||
step={1}
|
|
||||||
sx={{
|
sx={{
|
||||||
color: "#fff",
|
"& .MuiInputLabel-root": {
|
||||||
"& .MuiSlider-thumb": {
|
color: "#fff",
|
||||||
backgroundColor: "#fff",
|
|
||||||
},
|
},
|
||||||
"& .MuiSlider-track": {
|
"& .MuiInputBase-input": {
|
||||||
backgroundColor: "#fff",
|
color: "#fff",
|
||||||
},
|
|
||||||
"& .MuiSlider-rail": {
|
|
||||||
backgroundColor: "#666",
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
inputProps={{
|
||||||
|
min: 1,
|
||||||
|
max: 300,
|
||||||
|
step: 1,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Typography variant="body2" sx={{ color: "#fff", textAlign: "center" }}>
|
<TextField
|
||||||
Размер шрифта: {fontSize}%
|
type="number"
|
||||||
</Typography>
|
label="Размер шрифта (%)"
|
||||||
|
variant="filled"
|
||||||
<Slider
|
|
||||||
value={fontSize}
|
value={fontSize}
|
||||||
onChange={(_, value) => {
|
onChange={(e) => {
|
||||||
if (typeof value === "number") {
|
const value = Number(e.target.value);
|
||||||
|
if (!isNaN(value)) {
|
||||||
handleFontSizeChange(value);
|
handleFontSizeChange(value);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
min={50}
|
style={{ backgroundColor: "#222", borderRadius: 4 }}
|
||||||
max={300}
|
|
||||||
step={1}
|
|
||||||
sx={{
|
sx={{
|
||||||
color: "#fff",
|
"& .MuiInputLabel-root": {
|
||||||
"& .MuiSlider-thumb": {
|
color: "#fff",
|
||||||
backgroundColor: "#fff",
|
|
||||||
},
|
},
|
||||||
"& .MuiSlider-track": {
|
"& .MuiInputBase-input": {
|
||||||
backgroundColor: "#fff",
|
color: "#fff",
|
||||||
},
|
|
||||||
"& .MuiSlider-rail": {
|
|
||||||
backgroundColor: "#666",
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
inputProps={{
|
||||||
|
min: 1,
|
||||||
|
max: 300,
|
||||||
|
step: 1,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { coordinatesToLocal, localToCoordinates } from "../utils";
|
|||||||
import { BACKGROUND_COLOR, SCALE_FACTOR, UP_SCALE } from "../Constants";
|
import { BACKGROUND_COLOR, SCALE_FACTOR, UP_SCALE } from "../Constants";
|
||||||
import { languageStore } from "@shared";
|
import { languageStore } from "@shared";
|
||||||
import { SightData } from "../types";
|
import { SightData } from "../types";
|
||||||
|
import { isMediaIdEmpty } from "../../../../shared/lib/index";
|
||||||
|
|
||||||
const SIGHT_ICON_URL = "/sight_icon.svg";
|
const SIGHT_ICON_URL = "/sight_icon.svg";
|
||||||
|
|
||||||
@@ -1960,9 +1961,13 @@ 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
|
let isMediaIdEmptyResult = isMediaIdEmpty(station.icon);
|
||||||
? `${import.meta.env.VITE_KRBL_MEDIA}${station.icon}/download?token=${localStorage.getItem("token") ?? ""}`
|
const iconUrl = isMediaIdEmptyResult
|
||||||
: null;
|
? null
|
||||||
|
: `${import.meta.env.VITE_KRBL_MEDIA}${
|
||||||
|
station.icon
|
||||||
|
}/download?token=${localStorage.getItem("token") ?? ""}`;
|
||||||
|
|
||||||
const iconSizePx = Math.round(primaryFontSize * 1.2);
|
const iconSizePx = Math.round(primaryFontSize * 1.2);
|
||||||
|
|
||||||
const secondaryLineHeight = 1.2;
|
const secondaryLineHeight = 1.2;
|
||||||
@@ -2282,4 +2287,4 @@ export const WebGLRouteMapPrototype = observer(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export default WebGLRouteMapPrototype;
|
export default WebGLRouteMapPrototype;
|
||||||
|
|||||||
@@ -168,8 +168,8 @@ export const UserEditPage = observer(() => {
|
|||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Пароль"
|
label="Пароль"
|
||||||
|
placeholder="Оставить пустым, чтобы не менять"
|
||||||
value={editUserData.password || ""}
|
value={editUserData.password || ""}
|
||||||
required
|
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditUserData(
|
setEditUserData(
|
||||||
editUserData.name || "",
|
editUserData.name || "",
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ class UserStore {
|
|||||||
editUser = async (id: number) => {
|
editUser = async (id: number) => {
|
||||||
const payload = { ...this.editUserData };
|
const payload = { ...this.editUserData };
|
||||||
if (!payload.icon) delete payload.icon;
|
if (!payload.icon) delete payload.icon;
|
||||||
|
if (!payload.password?.trim()) delete payload.password;
|
||||||
const response = await authInstance.patch(`/user/${id}`, payload);
|
const response = await authInstance.patch(`/user/${id}`, payload);
|
||||||
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { languageInstance } from "@shared";
|
import { authInstance, languageInstance } from "@shared";
|
||||||
import { makeAutoObservable, runInAction } from "mobx";
|
import { makeAutoObservable, runInAction } from "mobx";
|
||||||
|
|
||||||
export type Vehicle = {
|
export type Vehicle = {
|
||||||
@@ -12,6 +12,9 @@ export type Vehicle = {
|
|||||||
model?: string;
|
model?: string;
|
||||||
current_snapshot_uuid?: string;
|
current_snapshot_uuid?: string;
|
||||||
snapshot_update_blocked?: boolean;
|
snapshot_update_blocked?: boolean;
|
||||||
|
demo_mode_enabled?: boolean;
|
||||||
|
maintenance_mode_on?: boolean;
|
||||||
|
city_id?: number;
|
||||||
};
|
};
|
||||||
device_status?: {
|
device_status?: {
|
||||||
device_uuid: string;
|
device_uuid: string;
|
||||||
@@ -37,11 +40,75 @@ class VehicleStore {
|
|||||||
makeAutoObservable(this);
|
makeAutoObservable(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeVehicleItem = (item: any): Vehicle => {
|
||||||
|
if (item && typeof item === "object" && "vehicle" in item) {
|
||||||
|
return {
|
||||||
|
vehicle: item.vehicle ?? {},
|
||||||
|
device_status: item.device_status,
|
||||||
|
} as Vehicle;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
vehicle: item ?? {},
|
||||||
|
} as Vehicle;
|
||||||
|
};
|
||||||
|
|
||||||
|
private mergeVehicleInCaches = (updatedVehicle: any) => {
|
||||||
|
if (!updatedVehicle) return;
|
||||||
|
|
||||||
|
const updatedId = updatedVehicle.id;
|
||||||
|
const updatedUuid = updatedVehicle.uuid;
|
||||||
|
|
||||||
|
const mergeItem = (item: Vehicle): Vehicle => ({
|
||||||
|
...item,
|
||||||
|
vehicle: {
|
||||||
|
...item.vehicle,
|
||||||
|
...updatedVehicle,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.vehicles.data = this.vehicles.data.map((item) => {
|
||||||
|
const sameId = updatedId != null && item.vehicle.id === updatedId;
|
||||||
|
const sameUuid =
|
||||||
|
updatedUuid != null &&
|
||||||
|
item.vehicle.uuid != null &&
|
||||||
|
item.vehicle.uuid === updatedUuid;
|
||||||
|
|
||||||
|
if (!sameId && !sameUuid) return item;
|
||||||
|
|
||||||
|
return mergeItem(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updatedId != null) {
|
||||||
|
const existing = this.vehicle[updatedId];
|
||||||
|
this.vehicle[updatedId] = existing
|
||||||
|
? mergeItem(existing)
|
||||||
|
: ({ vehicle: updatedVehicle } as Vehicle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedUuid != null) {
|
||||||
|
const entry = Object.entries(this.vehicle).find(
|
||||||
|
([, item]) => item.vehicle.uuid === updatedUuid
|
||||||
|
);
|
||||||
|
|
||||||
|
if (entry) {
|
||||||
|
const [key, item] = entry;
|
||||||
|
this.vehicle[key] = mergeItem(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
getVehicles = async () => {
|
getVehicles = async () => {
|
||||||
const response = await languageInstance("ru").get(`/vehicle`);
|
const response = await languageInstance("ru").get(`/vehicle`);
|
||||||
|
const vehiclesList = Array.isArray(response.data)
|
||||||
|
? response.data
|
||||||
|
: Array.isArray(response.data?.vehicles)
|
||||||
|
? response.data.vehicles
|
||||||
|
: [];
|
||||||
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.vehicles.data = response.data;
|
this.vehicles.data = vehiclesList.map(this.normalizeVehicleItem);
|
||||||
this.vehicles.loaded = true;
|
this.vehicles.loaded = true;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -58,9 +125,10 @@ class VehicleStore {
|
|||||||
|
|
||||||
getVehicle = async (id: number) => {
|
getVehicle = async (id: number) => {
|
||||||
const response = await languageInstance("ru").get(`/vehicle/${id}`);
|
const response = await languageInstance("ru").get(`/vehicle/${id}`);
|
||||||
|
const normalizedVehicle = this.normalizeVehicleItem(response.data);
|
||||||
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.vehicle[id] = response.data;
|
this.vehicle[id] = normalizedVehicle;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,19 +148,13 @@ class VehicleStore {
|
|||||||
// TODO: когда будет бекенд — добавить model в payload и в ответ
|
// TODO: когда будет бекенд — добавить model в payload и в ответ
|
||||||
if (model != null && model !== "") payload.model = model;
|
if (model != null && model !== "") payload.model = model;
|
||||||
const response = await languageInstance("ru").post("/vehicle", payload);
|
const response = await languageInstance("ru").post("/vehicle", payload);
|
||||||
|
const normalizedVehicle = this.normalizeVehicleItem(response.data);
|
||||||
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.vehicles.data.push({
|
this.vehicles.data.push(normalizedVehicle);
|
||||||
vehicle: {
|
if (normalizedVehicle.vehicle?.id != null) {
|
||||||
id: response.data.id,
|
this.vehicle[normalizedVehicle.vehicle.id] = normalizedVehicle;
|
||||||
tail_number: response.data.tail_number,
|
}
|
||||||
type: response.data.type,
|
|
||||||
carrier_id: response.data.carrier_id,
|
|
||||||
carrier: response.data.carrier,
|
|
||||||
uuid: response.data.uuid,
|
|
||||||
model: response.data.model ?? model,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -150,31 +212,51 @@ class VehicleStore {
|
|||||||
`/vehicle/${id}`,
|
`/vehicle/${id}`,
|
||||||
payload
|
payload
|
||||||
);
|
);
|
||||||
|
const normalizedVehicle = this.normalizeVehicleItem(response.data);
|
||||||
|
const updatedVehiclePayload = {
|
||||||
|
...normalizedVehicle.vehicle,
|
||||||
|
model: normalizedVehicle.vehicle.model ?? data.model,
|
||||||
|
snapshot_update_blocked:
|
||||||
|
normalizedVehicle.vehicle.snapshot_update_blocked ??
|
||||||
|
data.snapshot_update_blocked,
|
||||||
|
};
|
||||||
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
const updated = {
|
this.mergeVehicleInCaches({
|
||||||
...response.data,
|
...updatedVehiclePayload,
|
||||||
model: response.data.model ?? data.model,
|
id,
|
||||||
snapshot_update_blocked:
|
});
|
||||||
response.data.snapshot_update_blocked ?? data.snapshot_update_blocked,
|
});
|
||||||
};
|
};
|
||||||
this.vehicle[id] = {
|
|
||||||
vehicle: {
|
setMaintenanceMode = async (uuid: string, enabled: boolean) => {
|
||||||
...this.vehicle[id].vehicle,
|
const response = await authInstance.post(`/devices/${uuid}/maintenance-mode`, {
|
||||||
...updated,
|
enabled,
|
||||||
},
|
});
|
||||||
};
|
const normalizedVehicle = this.normalizeVehicleItem(response.data);
|
||||||
this.vehicles.data = this.vehicles.data.map((vehicle) =>
|
|
||||||
vehicle.vehicle.id === id
|
runInAction(() => {
|
||||||
? {
|
this.mergeVehicleInCaches({
|
||||||
...vehicle,
|
...normalizedVehicle.vehicle,
|
||||||
vehicle: {
|
uuid,
|
||||||
...vehicle.vehicle,
|
maintenance_mode_on:
|
||||||
...updated,
|
normalizedVehicle.vehicle.maintenance_mode_on ?? enabled,
|
||||||
},
|
});
|
||||||
}
|
});
|
||||||
: vehicle
|
};
|
||||||
);
|
|
||||||
|
setDemoMode = async (uuid: string, enabled: boolean) => {
|
||||||
|
const response = await authInstance.post(`/devices/${uuid}/demo-mode`, {
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
const normalizedVehicle = this.normalizeVehicleItem(response.data);
|
||||||
|
|
||||||
|
runInAction(() => {
|
||||||
|
this.mergeVehicleInCaches({
|
||||||
|
...normalizedVehicle.vehicle,
|
||||||
|
uuid,
|
||||||
|
demo_mode_enabled: normalizedVehicle.vehicle.demo_mode_enabled ?? enabled,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { API_URL, authInstance, Modal } from "@shared";
|
import { API_URL, authInstance, Modal } from "@shared";
|
||||||
import { CircularProgress } from "@mui/material";
|
import { Button, CircularProgress, TextField } from "@mui/material";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
interface DeviceLogChunk {
|
interface DeviceLogChunk {
|
||||||
@@ -14,27 +14,117 @@ interface DeviceLogsModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (date: Date) => {
|
const toYYYYMMDD = (d: Date) => d.toISOString().slice(0, 10);
|
||||||
return new Intl.DateTimeFormat("ru-RU", {
|
const shiftYYYYMMDD = (value: string, days: number) => {
|
||||||
day: "2-digit",
|
const d = new Date(`${value}T00:00:00Z`);
|
||||||
month: "2-digit",
|
if (Number.isNaN(d.getTime())) return value;
|
||||||
year: "numeric",
|
d.setUTCDate(d.getUTCDate() + days);
|
||||||
}).format(date);
|
return toYYYYMMDD(d);
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatLogTimestamp = (timestampStr: string) => {
|
type LogLevel = "info" | "warn" | "error" | "debug" | "fatal" | "unknown";
|
||||||
return timestampStr.replace(/^\[|\]$/g, "").trim();
|
|
||||||
|
const LOG_LEVEL_STYLES: Record<LogLevel, { badge: string; text: string }> = {
|
||||||
|
info: {
|
||||||
|
badge: "bg-blue-100 text-blue-700",
|
||||||
|
text: "text-[#000000BF]",
|
||||||
|
},
|
||||||
|
debug: {
|
||||||
|
badge: "bg-gray-100 text-gray-600",
|
||||||
|
text: "text-gray-600",
|
||||||
|
},
|
||||||
|
warn: {
|
||||||
|
badge: "bg-amber-100 text-amber-700",
|
||||||
|
text: "text-amber-800",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
badge: "bg-red-100 text-red-700",
|
||||||
|
text: "text-red-700",
|
||||||
|
},
|
||||||
|
fatal: {
|
||||||
|
badge: "bg-red-200 text-red-900",
|
||||||
|
text: "text-red-900 font-semibold",
|
||||||
|
},
|
||||||
|
unknown: {
|
||||||
|
badge: "bg-gray-100 text-gray-500",
|
||||||
|
text: "text-[#000000BF]",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTs = (raw: string): string => {
|
||||||
|
try {
|
||||||
|
const d = new Date(raw);
|
||||||
|
if (Number.isNaN(d.getTime())) return raw;
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseJsonLogLine = (line: string) => {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(line);
|
||||||
|
if (obj && typeof obj === "object") {
|
||||||
|
const level: LogLevel =
|
||||||
|
obj.level && obj.level in LOG_LEVEL_STYLES
|
||||||
|
? (obj.level as LogLevel)
|
||||||
|
: "unknown";
|
||||||
|
const ts: string = obj.ts ? formatTs(obj.ts) : "";
|
||||||
|
const msg: string = obj.msg ?? "";
|
||||||
|
|
||||||
|
const extra: Record<string, unknown> = { ...obj };
|
||||||
|
delete extra.level;
|
||||||
|
delete extra.ts;
|
||||||
|
delete extra.msg;
|
||||||
|
delete extra.caller;
|
||||||
|
const extraStr = Object.keys(extra).length
|
||||||
|
? " " +
|
||||||
|
Object.entries(extra)
|
||||||
|
.map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`)
|
||||||
|
.join(" ")
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return { ts, level, msg, extraStr };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseLogLine = (line: string, index: number) => {
|
const parseLogLine = (line: string, index: number) => {
|
||||||
const match = line.match(/^(\[[^\]]+\])\s*(.*)$/);
|
const json = parseJsonLogLine(line);
|
||||||
if (match) {
|
if (json) {
|
||||||
return { id: index, time: match[1], text: match[2].trim() || line };
|
return {
|
||||||
|
id: index,
|
||||||
|
time: json.ts,
|
||||||
|
text: json.msg + json.extraStr,
|
||||||
|
level: json.level,
|
||||||
|
sortKey: json.ts,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return { id: index, time: "", text: line };
|
|
||||||
};
|
|
||||||
|
|
||||||
const toYYYYMMDD = (d: Date) => d.toISOString().slice(0, 10);
|
const bracketMatch = line.match(/^(\[[^\]]+\])\s*(.*)$/);
|
||||||
|
if (bracketMatch) {
|
||||||
|
const rawTime = bracketMatch[1].replace(/^\[|\]$/g, "").trim();
|
||||||
|
return {
|
||||||
|
id: index,
|
||||||
|
time: formatTs(rawTime),
|
||||||
|
text: bracketMatch[2].trim() || line,
|
||||||
|
level: "unknown" as LogLevel,
|
||||||
|
sortKey: rawTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: index,
|
||||||
|
time: "",
|
||||||
|
text: line,
|
||||||
|
level: "unknown" as LogLevel,
|
||||||
|
sortKey: "",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const DeviceLogsModal = ({
|
export const DeviceLogsModal = ({
|
||||||
open,
|
open,
|
||||||
@@ -44,10 +134,29 @@ export const DeviceLogsModal = ({
|
|||||||
const [chunks, setChunks] = useState<DeviceLogChunk[]>([]);
|
const [chunks, setChunks] = useState<DeviceLogChunk[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
|
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
|
||||||
const dateStr = toYYYYMMDD(today);
|
const [dateFrom, setDateFrom] = useState(toYYYYMMDD(yesterday));
|
||||||
const dateStrYesterday = toYYYYMMDD(yesterday);
|
const [dateTo, setDateTo] = useState(toYYYYMMDD(today));
|
||||||
|
const dateToMin = shiftYYYYMMDD(dateFrom, 1);
|
||||||
|
const dateFromMax = shiftYYYYMMDD(dateTo, -1);
|
||||||
|
|
||||||
|
const handleDateFromChange = (value: string) => {
|
||||||
|
setDateFrom(value);
|
||||||
|
if (!dateTo || dateTo <= value) {
|
||||||
|
setDateTo(shiftYYYYMMDD(value, 1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateToChange = (value: string) => {
|
||||||
|
if (value <= dateFrom) {
|
||||||
|
toast.info("Дата 'До' должна быть позже даты 'От'");
|
||||||
|
setDateTo(shiftYYYYMMDD(dateFrom, 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDateTo(value);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !deviceUuid) return;
|
if (!open || !deviceUuid) return;
|
||||||
@@ -58,7 +167,12 @@ export const DeviceLogsModal = ({
|
|||||||
try {
|
try {
|
||||||
const { data } = await authInstance.get<DeviceLogChunk[]>(
|
const { data } = await authInstance.get<DeviceLogChunk[]>(
|
||||||
`${API_URL}/devices/${deviceUuid}/logs`,
|
`${API_URL}/devices/${deviceUuid}/logs`,
|
||||||
{ params: { from: dateStrYesterday, to: dateStr } }
|
{
|
||||||
|
params: {
|
||||||
|
from: dateFrom,
|
||||||
|
to: toYYYYMMDD(new Date(new Date(dateTo).getTime() + 86400000)),
|
||||||
|
},
|
||||||
|
}
|
||||||
);
|
);
|
||||||
setChunks(Array.isArray(data) ? data : []);
|
setChunks(Array.isArray(data) ? data : []);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -74,25 +188,108 @@ export const DeviceLogsModal = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchLogs();
|
fetchLogs();
|
||||||
}, [open, deviceUuid, dateStr]);
|
}, [open, deviceUuid, dateFrom, dateTo]);
|
||||||
|
|
||||||
const logs = chunks.flatMap((chunk, chunkIdx) =>
|
const logs = useMemo(() => {
|
||||||
(chunk.lines ?? []).map((line, i) =>
|
const parsed = chunks.flatMap((chunk, chunkIdx) =>
|
||||||
parseLogLine(line, chunkIdx * 10000 + i)
|
(chunk.lines ?? []).map((line, i) =>
|
||||||
)
|
parseLogLine(line, chunkIdx * 10000 + i)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
parsed.sort((a, b) => {
|
||||||
|
if (!a.sortKey && !b.sortKey) return 0;
|
||||||
|
if (!a.sortKey) return 1;
|
||||||
|
if (!b.sortKey) return -1;
|
||||||
|
return b.sortKey.localeCompare(a.sortKey);
|
||||||
|
});
|
||||||
|
return parsed;
|
||||||
|
}, [chunks]);
|
||||||
|
|
||||||
|
const logsText = useMemo(
|
||||||
|
() =>
|
||||||
|
logs
|
||||||
|
.map((log) => {
|
||||||
|
const level = log.level === "unknown" ? "LOG" : log.level.toUpperCase();
|
||||||
|
const time = log.time ? `[${log.time}] ` : "";
|
||||||
|
return `${time}${level}: ${log.text}`;
|
||||||
|
})
|
||||||
|
.join("\n"),
|
||||||
|
[logs]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDownloadLogs = () => {
|
||||||
|
if (!logsText) {
|
||||||
|
toast.info("Нет логов для сохранения");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const safeDeviceUuid = (deviceUuid ?? "device").replace(
|
||||||
|
/[^a-zA-Z0-9_-]/g,
|
||||||
|
"_"
|
||||||
|
);
|
||||||
|
const fileName = `logs_${safeDeviceUuid}_${dateFrom}_${dateTo}.txt`;
|
||||||
|
const blob = new Blob([`\uFEFF${logsText}`], {
|
||||||
|
type: "text/plain;charset=utf-8",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
|
||||||
|
link.href = url;
|
||||||
|
link.download = fileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
toast.success("Логи сохранены в .txt");
|
||||||
|
} catch {
|
||||||
|
toast.error("Не удалось сохранить логи");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={open} onClose={onClose} sx={{ width: "80vw", p: 1.5 }}>
|
<Modal open={open} onClose={onClose} sx={{ width: "80vw", p: 3 }}>
|
||||||
<div className="flex flex-col gap-6 h-[85vh]">
|
<div className="flex flex-col gap-6 h-[85vh]">
|
||||||
<div className="flex gap-3 items-center justify-between w-full">
|
<div className="flex gap-4 items-center justify-between w-full flex-wrap">
|
||||||
<h2 className="text-2xl font-semibold text-[#000000BF]">Логи</h2>
|
<h2 className="text-2xl font-semibold text-[#000000BF]">Логи</h2>
|
||||||
<span className="text-lg text-[#00000040]">{formatDate(today)}</span>
|
<div className="flex gap-4 items-center">
|
||||||
|
<TextField
|
||||||
|
type="date"
|
||||||
|
label="От"
|
||||||
|
size="small"
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={(e) => handleDateFromChange(e.target.value)}
|
||||||
|
slotProps={{
|
||||||
|
inputLabel: { shrink: true },
|
||||||
|
htmlInput: { max: dateFromMax },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
type="date"
|
||||||
|
label="До"
|
||||||
|
size="small"
|
||||||
|
value={dateTo}
|
||||||
|
onChange={(e) => handleDateToChange(e.target.value)}
|
||||||
|
slotProps={{
|
||||||
|
inputLabel: { shrink: true },
|
||||||
|
htmlInput: { min: dateToMin },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={handleDownloadLogs}
|
||||||
|
disabled={isLoading || Boolean(error) || logs.length === 0}
|
||||||
|
>
|
||||||
|
Скачать .txt
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col min-h-0 w-full">
|
<div className="flex-1 flex flex-col min-h-0 w-full">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="w-full h-full flex items-center justify-center bg-white rounded-xl shadow-inner">
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
<CircularProgress />
|
<CircularProgress />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -104,21 +301,30 @@ export const DeviceLogsModal = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && !error && (
|
{!isLoading && !error && (
|
||||||
<div className="w-full bg-white p-4 h-full overflow-y-auto rounded-xl shadow-inner">
|
<div className="w-full h-full overflow-y-auto rounded-xl">
|
||||||
<div className="flex flex-col gap-2 font-mono">
|
<div className="flex flex-col gap-0.5 font-mono text-[13px]">
|
||||||
{logs.length > 0 ? (
|
{logs.length > 0 ? (
|
||||||
logs.map((log) => (
|
logs.map((log) => {
|
||||||
<div key={log.id} className="flex gap-3 items-start p-2">
|
const style = LOG_LEVEL_STYLES[log.level];
|
||||||
<div className="text-sm text-[#00000050] shrink-0 whitespace-nowrap pt-px">
|
return (
|
||||||
{log.time ? `[${formatLogTimestamp(log.time)}]` : null}
|
<div
|
||||||
|
key={log.id}
|
||||||
|
className={`flex gap-3 items-start px-2 py-1 rounded ${style.text}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`shrink-0 px-1.5 py-0.5 rounded text-[11px] font-semibold uppercase ${style.badge}`}
|
||||||
|
>
|
||||||
|
{log.level === "unknown" ? "LOG" : log.level}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400 shrink-0 whitespace-nowrap">
|
||||||
|
{log.time || null}
|
||||||
|
</span>
|
||||||
|
<span className="break-all">{log.text}</span>
|
||||||
</div>
|
</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 className="h-full flex items-center justify-center text-gray-500 py-10">
|
||||||
Логи отсутствуют.
|
Логи отсутствуют.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ type RowData = {
|
|||||||
device_uuid: string | null;
|
device_uuid: string | null;
|
||||||
current_snapshot_uuid: string | null;
|
current_snapshot_uuid: string | null;
|
||||||
snapshot_update_blocked: boolean;
|
snapshot_update_blocked: boolean;
|
||||||
|
maintenance_mode_on: boolean;
|
||||||
|
demo_mode_enabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getVehicleTypeLabel(vehicle: Vehicle): string {
|
function getVehicleTypeLabel(vehicle: Vehicle): string {
|
||||||
@@ -97,14 +99,16 @@ const transformToRows = (vehicles: Vehicle[]): RowData[] => {
|
|||||||
type,
|
type,
|
||||||
model,
|
model,
|
||||||
tail_number: vehicle.vehicle.tail_number,
|
tail_number: vehicle.vehicle.tail_number,
|
||||||
online: uuid ? vehicle.device_status?.online ?? false : false,
|
online: uuid ? (vehicle.device_status?.online ?? false) : false,
|
||||||
lastUpdate: vehicle.device_status?.last_update ?? null,
|
lastUpdate: vehicle.device_status?.last_update ?? null,
|
||||||
gps: uuid ? vehicle.device_status?.gps_ok ?? false : false,
|
gps: uuid ? (vehicle.device_status?.gps_ok ?? false) : false,
|
||||||
media: uuid ? vehicle.device_status?.media_service_ok ?? false : false,
|
media: uuid ? (vehicle.device_status?.media_service_ok ?? false) : false,
|
||||||
connection: uuid ? vehicle.device_status?.is_connected ?? false : false,
|
connection: uuid ? (vehicle.device_status?.is_connected ?? false) : false,
|
||||||
device_uuid: uuid ?? null,
|
device_uuid: uuid ?? null,
|
||||||
current_snapshot_uuid: vehicle.vehicle.current_snapshot_uuid ?? null,
|
current_snapshot_uuid: vehicle.vehicle.current_snapshot_uuid ?? null,
|
||||||
snapshot_update_blocked: vehicle.vehicle.snapshot_update_blocked ?? false,
|
snapshot_update_blocked: vehicle.vehicle.snapshot_update_blocked ?? false,
|
||||||
|
maintenance_mode_on: vehicle.vehicle.maintenance_mode_on ?? false,
|
||||||
|
demo_mode_enabled: vehicle.vehicle.demo_mode_enabled ?? false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -119,17 +123,29 @@ export const DevicesTable = observer(() => {
|
|||||||
} = devicesStore;
|
} = devicesStore;
|
||||||
|
|
||||||
const { snapshots, getSnapshots } = snapshotStore;
|
const { snapshots, getSnapshots } = snapshotStore;
|
||||||
const { getVehicles, vehicles, deleteVehicle } = vehicleStore;
|
const {
|
||||||
|
getVehicles,
|
||||||
|
vehicles,
|
||||||
|
deleteVehicle,
|
||||||
|
setMaintenanceMode,
|
||||||
|
setDemoMode,
|
||||||
|
} = vehicleStore;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [logsModalOpen, setLogsModalOpen] = useState(false);
|
const [logsModalOpen, setLogsModalOpen] = useState(false);
|
||||||
const [logsModalDeviceUuid, setLogsModalDeviceUuid] = useState<string | null>(
|
const [logsModalDeviceUuid, setLogsModalDeviceUuid] = useState<string | null>(
|
||||||
null
|
null,
|
||||||
);
|
);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [maintenanceLoadingUuids, setMaintenanceLoadingUuids] = useState<
|
||||||
|
Set<string>
|
||||||
|
>(new Set());
|
||||||
|
const [demoLoadingUuids, setDemoLoadingUuids] = useState<Set<string>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
const [collapsedModels, setCollapsedModels] = useState<Set<string>>(
|
const [collapsedModels, setCollapsedModels] = useState<Set<string>>(
|
||||||
new Set()
|
new Set(),
|
||||||
);
|
);
|
||||||
const [paginationModel, setPaginationModel] = useState({
|
const [paginationModel, setPaginationModel] = useState({
|
||||||
page: 0,
|
page: 0,
|
||||||
@@ -146,7 +162,7 @@ export const DevicesTable = observer(() => {
|
|||||||
const carriersInSelectedCityIds = new Set(
|
const carriersInSelectedCityIds = new Set(
|
||||||
carrierStore.carriers.ru.data
|
carrierStore.carriers.ru.data
|
||||||
.filter((carrier) => carrier.city_id === selectedCityId)
|
.filter((carrier) => carrier.city_id === selectedCityId)
|
||||||
.map((carrier) => carrier.id)
|
.map((carrier) => carrier.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (carriersInSelectedCityIds.size === 0) {
|
if (carriersInSelectedCityIds.size === 0) {
|
||||||
@@ -154,17 +170,17 @@ export const DevicesTable = observer(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return vehiclesList.filter((vehicle) =>
|
return vehiclesList.filter((vehicle) =>
|
||||||
carriersInSelectedCityIds.has(vehicle.vehicle.carrier_id)
|
carriersInSelectedCityIds.has(vehicle.vehicle.carrier_id),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredVehicles = filterVehiclesBySelectedCity(
|
const filteredVehicles = filterVehiclesBySelectedCity(
|
||||||
vehicles.data as Vehicle[]
|
vehicles.data as Vehicle[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const rows = useMemo(
|
const rows = useMemo(
|
||||||
() => transformToRows(filteredVehicles),
|
() => transformToRows(filteredVehicles),
|
||||||
[filteredVehicles]
|
[filteredVehicles],
|
||||||
);
|
);
|
||||||
|
|
||||||
const groupsByModel = useMemo(() => {
|
const groupsByModel = useMemo(() => {
|
||||||
@@ -202,11 +218,70 @@ export const DevicesTable = observer(() => {
|
|||||||
(r) =>
|
(r) =>
|
||||||
selectedIds.includes(r.id) &&
|
selectedIds.includes(r.id) &&
|
||||||
r.device_uuid != null &&
|
r.device_uuid != null &&
|
||||||
!r.snapshot_update_blocked
|
!r.snapshot_update_blocked,
|
||||||
)
|
)
|
||||||
.map((r) => r.device_uuid as string);
|
.map((r) => r.device_uuid as string);
|
||||||
}, [rows, selectedIds]);
|
}, [rows, selectedIds]);
|
||||||
|
|
||||||
|
const handleToggleMaintenanceMode = async (row: RowData) => {
|
||||||
|
if (!row.device_uuid) return;
|
||||||
|
|
||||||
|
const nextEnabled = !row.maintenance_mode_on;
|
||||||
|
setMaintenanceLoadingUuids((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(row.device_uuid!);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setMaintenanceMode(row.device_uuid, nextEnabled);
|
||||||
|
await getVehicles();
|
||||||
|
await getDevices();
|
||||||
|
toast.success(
|
||||||
|
nextEnabled ? "Устройство отправлено на ТО" : "Режим ТО отключен",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Error toggling maintenance mode for ${row.device_uuid}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
toast.error("Не удалось изменить режим ТО");
|
||||||
|
} finally {
|
||||||
|
setMaintenanceLoadingUuids((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(row.device_uuid!);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleDemoMode = async (row: RowData) => {
|
||||||
|
if (!row.device_uuid) return;
|
||||||
|
|
||||||
|
const nextEnabled = !row.demo_mode_enabled;
|
||||||
|
setDemoLoadingUuids((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(row.device_uuid!);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await setDemoMode(row.device_uuid, nextEnabled);
|
||||||
|
await getVehicles();
|
||||||
|
await getDevices();
|
||||||
|
toast.success(nextEnabled ? "Демо-режим включен" : "Демо-режим отключен");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error toggling demo mode for ${row.device_uuid}:`, error);
|
||||||
|
toast.error("Не удалось изменить демо-режим");
|
||||||
|
} finally {
|
||||||
|
setDemoLoadingUuids((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(row.device_uuid!);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const columns: GridColDef[] = useMemo(
|
const columns: GridColDef[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -220,7 +295,7 @@ export const DevicesTable = observer(() => {
|
|||||||
field: "tail_number",
|
field: "tail_number",
|
||||||
headerName: "Бортовой номер",
|
headerName: "Бортовой номер",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 90,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -279,6 +354,65 @@ export const DevicesTable = observer(() => {
|
|||||||
</Box>
|
</Box>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
field: "maintenance_mode_on",
|
||||||
|
headerName: "Режим ТО",
|
||||||
|
width: 90,
|
||||||
|
align: "center",
|
||||||
|
headerAlign: "center",
|
||||||
|
type: "boolean",
|
||||||
|
filterable: true,
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
const rowData = params.row as RowData;
|
||||||
|
const isMaintenanceLoading =
|
||||||
|
!!rowData.device_uuid &&
|
||||||
|
maintenanceLoadingUuids.has(rowData.device_uuid);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{ display: "flex", justifyContent: "center", width: "100%" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={rowData.maintenance_mode_on}
|
||||||
|
disabled={!rowData.device_uuid || isMaintenanceLoading}
|
||||||
|
size="small"
|
||||||
|
sx={{ p: 0 }}
|
||||||
|
onChange={() => handleToggleMaintenanceMode(rowData)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "demo_mode_enabled",
|
||||||
|
headerName: "Режим Демо",
|
||||||
|
width: 120,
|
||||||
|
align: "center",
|
||||||
|
headerAlign: "center",
|
||||||
|
type: "boolean",
|
||||||
|
filterable: true,
|
||||||
|
renderCell: (params: GridRenderCellParams) => {
|
||||||
|
const rowData = params.row as RowData;
|
||||||
|
const isDemoLoading =
|
||||||
|
!!rowData.device_uuid && demoLoadingUuids.has(rowData.device_uuid);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{ display: "flex", justifyContent: "center", width: "100%" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={rowData.demo_mode_enabled}
|
||||||
|
disabled={!rowData.device_uuid || isDemoLoading}
|
||||||
|
size="small"
|
||||||
|
sx={{ p: 0 }}
|
||||||
|
onChange={() => handleToggleDemoMode(rowData)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
field: "lastUpdate",
|
field: "lastUpdate",
|
||||||
headerName: "Обновлено",
|
headerName: "Обновлено",
|
||||||
@@ -338,7 +472,7 @@ export const DevicesTable = observer(() => {
|
|||||||
setSelectedDevice(row.device_uuid);
|
setSelectedDevice(row.device_uuid);
|
||||||
try {
|
try {
|
||||||
await authInstance.post(
|
await authInstance.post(
|
||||||
`/devices/${row.device_uuid}/request-status`
|
`/devices/${row.device_uuid}/request-status`,
|
||||||
);
|
);
|
||||||
await getVehicles();
|
await getVehicles();
|
||||||
await getDevices();
|
await getDevices();
|
||||||
@@ -346,7 +480,7 @@ export const DevicesTable = observer(() => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
`Error requesting status for device ${row.device_uuid}:`,
|
`Error requesting status for device ${row.device_uuid}:`,
|
||||||
error
|
error,
|
||||||
);
|
);
|
||||||
toast.error("Ошибка сервера");
|
toast.error("Ошибка сервера");
|
||||||
}
|
}
|
||||||
@@ -356,7 +490,7 @@ export const DevicesTable = observer(() => {
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: "20px",
|
gap: "8px",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
@@ -421,7 +555,13 @@ export const DevicesTable = observer(() => {
|
|||||||
snapshots,
|
snapshots,
|
||||||
setLogsModalDeviceUuid,
|
setLogsModalDeviceUuid,
|
||||||
setLogsModalOpen,
|
setLogsModalOpen,
|
||||||
]
|
maintenanceLoadingUuids,
|
||||||
|
demoLoadingUuids,
|
||||||
|
setMaintenanceMode,
|
||||||
|
setDemoMode,
|
||||||
|
handleToggleMaintenanceMode,
|
||||||
|
handleToggleDemoMode,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -452,7 +592,7 @@ export const DevicesTable = observer(() => {
|
|||||||
selectedDeviceUuids.length - selectedDeviceUuidsAllowed.length;
|
selectedDeviceUuids.length - selectedDeviceUuidsAllowed.length;
|
||||||
if (blockedCount > 0) {
|
if (blockedCount > 0) {
|
||||||
toast.info(
|
toast.info(
|
||||||
`Обновление ПО не отправлено на ${blockedCount} устройств (блокировка)`
|
`Обновление ПО не отправлено на ${blockedCount} устройств (блокировка)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,7 +600,7 @@ export const DevicesTable = observer(() => {
|
|||||||
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) {
|
||||||
@@ -572,7 +712,7 @@ export const DevicesTable = observer(() => {
|
|||||||
const isCollapsed = collapsedModels.has(groupModel);
|
const isCollapsed = collapsedModels.has(groupModel);
|
||||||
const groupRowIds = groupRows.map((r) => r.id);
|
const groupRowIds = groupRows.map((r) => r.id);
|
||||||
const selectedInGroup = selectedIds.filter((id) =>
|
const selectedInGroup = selectedIds.filter((id) =>
|
||||||
groupRowIds.includes(id)
|
groupRowIds.includes(id),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -612,7 +752,7 @@ export const DevicesTable = observer(() => {
|
|||||||
pageSizeOptions={[50]}
|
pageSizeOptions={[50]}
|
||||||
onRowSelectionModelChange={
|
onRowSelectionModelChange={
|
||||||
createSelectionHandler(groupRowIds) as (
|
createSelectionHandler(groupRowIds) as (
|
||||||
ids: unknown
|
ids: unknown,
|
||||||
) => void
|
) => void
|
||||||
}
|
}
|
||||||
rowSelectionModel={{
|
rowSelectionModel={{
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export const MediaArea = observer(
|
|||||||
<div className="w-full flex flex-start flex-wrap gap-2 mt-4 py-10">
|
<div className="w-full flex flex-start flex-wrap gap-2 mt-4 py-10">
|
||||||
{mediaIds.map((m) => (
|
{mediaIds.map((m) => (
|
||||||
<button
|
<button
|
||||||
className="relative w-20 h-20"
|
className="relative w-[100px] h-[80px]"
|
||||||
key={m.id}
|
key={m.id}
|
||||||
onClick={() => handleMediaModal(m.id)}
|
onClick={() => handleMediaModal(m.id)}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -134,7 +134,7 @@ export const MediaArea = observer(
|
|||||||
media_type: m.media_type,
|
media_type: m.media_type,
|
||||||
filename: m.filename,
|
filename: m.filename,
|
||||||
}}
|
}}
|
||||||
height="40px"
|
compact
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="absolute top-2 right-2 bg-white rounded-full p-1 shadow-md hover:shadow-lg transition-shadow"
|
className="absolute top-2 right-2 bg-white rounded-full p-1 shadow-md hover:shadow-lg transition-shadow"
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export const ThreeView = ({
|
|||||||
>
|
>
|
||||||
<ambientLight />
|
<ambientLight />
|
||||||
<directionalLight />
|
<directionalLight />
|
||||||
<Stage environment="city" intensity={0.6} adjustCamera={false}>
|
<Stage environment={null} intensity={0.6} adjustCamera={false}>
|
||||||
<Model fileUrl={fileUrl} />
|
<Model fileUrl={fileUrl} />
|
||||||
</Stage>
|
</Stage>
|
||||||
<OrbitControls />
|
<OrbitControls />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Box } from "@mui/material";
|
import { Box, Typography } from "@mui/material";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { Cuboid } from "lucide-react";
|
||||||
|
|
||||||
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
|
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
|
||||||
import { ThreeView } from "./ThreeView";
|
import { ThreeView } from "./ThreeView";
|
||||||
@@ -19,6 +20,7 @@ export function MediaViewer({
|
|||||||
width,
|
width,
|
||||||
fullWidth,
|
fullWidth,
|
||||||
fullHeight,
|
fullHeight,
|
||||||
|
compact,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
media?: MediaData;
|
media?: MediaData;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -26,6 +28,8 @@ export function MediaViewer({
|
|||||||
width?: string;
|
width?: string;
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
fullHeight?: boolean;
|
fullHeight?: boolean;
|
||||||
|
/** В компактном режиме (миниатюры) 3D модели не рендерятся — показывается placeholder */
|
||||||
|
compact?: boolean;
|
||||||
}>) {
|
}>) {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
const [resetKey, setResetKey] = useState(0);
|
const [resetKey, setResetKey] = useState(0);
|
||||||
@@ -76,8 +80,9 @@ export function MediaViewer({
|
|||||||
}/download?token=${token}`}
|
}/download?token=${token}`}
|
||||||
alt={media?.filename}
|
alt={media?.filename}
|
||||||
style={{
|
style={{
|
||||||
height: fullHeight ? "100%" : height ? height : "auto",
|
height: compact ? "80px" : fullHeight ? "100%" : height ? height : "auto",
|
||||||
width: fullWidth ? "100%" : width ? width : "auto",
|
width: compact ? "100px" : fullWidth ? "100%" : width ? width : "auto",
|
||||||
|
objectFit: "cover",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -88,8 +93,8 @@ export function MediaViewer({
|
|||||||
media?.id
|
media?.id
|
||||||
}/download?token=${token}`}
|
}/download?token=${token}`}
|
||||||
style={{
|
style={{
|
||||||
width: width ? width : "100%",
|
width: compact ? "100px" : width ? width : "100%",
|
||||||
height: height ? height : "100%",
|
height: compact ? "80px" : height ? height : "100%",
|
||||||
objectFit: "cover",
|
objectFit: "cover",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
}}
|
}}
|
||||||
@@ -105,8 +110,9 @@ export function MediaViewer({
|
|||||||
}/download?token=${token}`}
|
}/download?token=${token}`}
|
||||||
alt={media?.filename}
|
alt={media?.filename}
|
||||||
style={{
|
style={{
|
||||||
height: fullHeight ? "100%" : height ? height : "auto",
|
height: compact ? "80px" : fullHeight ? "100%" : height ? height : "auto",
|
||||||
width: fullWidth ? "100%" : width ? width : "auto",
|
width: compact ? "100px" : fullWidth ? "100%" : width ? width : "auto",
|
||||||
|
objectFit: "cover",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -117,7 +123,8 @@ export function MediaViewer({
|
|||||||
}/download?token=${token}`}
|
}/download?token=${token}`}
|
||||||
alt={media?.filename}
|
alt={media?.filename}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: compact ? "100px" : "100%",
|
||||||
|
height: compact ? "80px" : undefined,
|
||||||
objectFit: "cover",
|
objectFit: "cover",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -128,26 +135,46 @@ export function MediaViewer({
|
|||||||
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
src={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||||
media?.id
|
media?.id
|
||||||
}/download?token=${token}`}
|
}/download?token=${token}`}
|
||||||
width={fullWidth ? "100%" : width ? width : "500px"}
|
width={compact ? "100px" : fullWidth ? "100%" : width ? width : "500px"}
|
||||||
height={fullHeight ? "100%" : height ? height : "300px"}
|
height={compact ? "80px" : fullHeight ? "100%" : height ? height : "300px"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{media?.media_type === 6 && (
|
{media?.media_type === 6 &&
|
||||||
<ThreeViewErrorBoundary
|
(compact ? (
|
||||||
onReset={handleReset}
|
<Box
|
||||||
resetKey={`${media?.id}-${resetKey}`}
|
sx={{
|
||||||
>
|
width: "100px",
|
||||||
<ThreeView
|
height: "80px",
|
||||||
key={`3d-model-${media?.id}-${resetKey}`}
|
display: "flex",
|
||||||
fileUrl={`${import.meta.env.VITE_KRBL_MEDIA}${
|
flexDirection: "column",
|
||||||
media?.id
|
alignItems: "center",
|
||||||
}/download?token=${token}`}
|
justifyContent: "center",
|
||||||
height={height ? height : "500px"}
|
backgroundColor: "action.hover",
|
||||||
width={width ? width : "500px"}
|
borderRadius: 5,
|
||||||
/>
|
color: "text.secondary",
|
||||||
</ThreeViewErrorBoundary>
|
}}
|
||||||
)}
|
>
|
||||||
|
<Cuboid size={24} />
|
||||||
|
<Typography variant="caption" sx={{ mt: 0.5 }}>
|
||||||
|
3D
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<ThreeViewErrorBoundary
|
||||||
|
onReset={handleReset}
|
||||||
|
resetKey={`${media?.id}-${resetKey}`}
|
||||||
|
>
|
||||||
|
<ThreeView
|
||||||
|
key={`3d-model-${media?.id}-${resetKey}`}
|
||||||
|
fileUrl={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||||
|
media?.id
|
||||||
|
}/download?token=${token}`}
|
||||||
|
height={height ? height : "500px"}
|
||||||
|
width={width ? width : "500px"}
|
||||||
|
/>
|
||||||
|
</ThreeViewErrorBoundary>
|
||||||
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user