fix: Fix 3d models
This commit is contained in:
158
src/app/GlobalErrorBoundary.tsx
Normal file
158
src/app/GlobalErrorBoundary.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import React, { Component, ReactNode } from "react";
|
||||||
|
import { Box, Button, Typography, Paper, Container } from "@mui/material";
|
||||||
|
import { RefreshCw, Home, AlertTriangle } from "lucide-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GlobalErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return {
|
||||||
|
hasError: true,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||||
|
console.error("❌ GlobalErrorBoundary: Критическая ошибка приложения", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
componentStack: errorInfo.componentStack,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReset = () => {
|
||||||
|
this.setState({
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
handleGoHome = () => {
|
||||||
|
this.setState({
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
window.location.href = "/";
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minHeight: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "background.default",
|
||||||
|
p: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Container maxWidth="sm">
|
||||||
|
<Paper
|
||||||
|
elevation={3}
|
||||||
|
sx={{
|
||||||
|
p: 4,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={64} color="#f44336" />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
|
Упс! Что-то пошло не так
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
Приложение столкнулось с неожиданной ошибкой. Попробуйте
|
||||||
|
перезагрузить страницу или вернуться на главную.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{this.state.error?.message && (
|
||||||
|
<Paper
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
mb: 3,
|
||||||
|
backgroundColor: "error.light",
|
||||||
|
color: "error.contrastText",
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ fontWeight: "bold", display: "block", mb: 1 }}
|
||||||
|
>
|
||||||
|
Информация об ошибке:
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{this.state.error.message}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 2,
|
||||||
|
justifyContent: "center",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<Home size={16} />}
|
||||||
|
onClick={this.handleGoHome}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
На главную
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<RefreshCw size={16} />}
|
||||||
|
onClick={this.handleReset}
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
Перезагрузить
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,13 @@ import { Router } from "./router";
|
|||||||
import { CustomTheme } from "@shared";
|
import { CustomTheme } from "@shared";
|
||||||
import { ThemeProvider } from "@mui/material/styles";
|
import { ThemeProvider } from "@mui/material/styles";
|
||||||
import { ToastContainer } from "react-toastify";
|
import { ToastContainer } from "react-toastify";
|
||||||
|
import { GlobalErrorBoundary } from "./GlobalErrorBoundary";
|
||||||
|
|
||||||
export const App: React.FC = () => (
|
export const App: React.FC = () => (
|
||||||
|
<GlobalErrorBoundary>
|
||||||
<ThemeProvider theme={CustomTheme.Light}>
|
<ThemeProvider theme={CustomTheme.Light}>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
<Router />
|
<Router />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
</GlobalErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export const MEDIA_TYPE_LABELS = {
|
|||||||
6: "3Д-модель",
|
6: "3Д-модель",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export * from "./mediaTypes";
|
||||||
|
|
||||||
export const MEDIA_TYPE_VALUES = {
|
export const MEDIA_TYPE_VALUES = {
|
||||||
image: 1,
|
image: 1,
|
||||||
video: 2,
|
video: 2,
|
||||||
|
|||||||
85
src/shared/const/mediaTypes.ts
Normal file
85
src/shared/const/mediaTypes.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// Допустимые типы и расширения файлов для медиа
|
||||||
|
export const ALLOWED_MEDIA_TYPES = {
|
||||||
|
image: {
|
||||||
|
extensions: [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"],
|
||||||
|
mimeTypes: [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/gif",
|
||||||
|
"image/webp",
|
||||||
|
"image/bmp",
|
||||||
|
"image/svg+xml",
|
||||||
|
],
|
||||||
|
accept: "image/*",
|
||||||
|
},
|
||||||
|
video: {
|
||||||
|
extensions: [".mp4", ".webm", ".ogg", ".mov", ".avi"],
|
||||||
|
mimeTypes: [
|
||||||
|
"video/mp4",
|
||||||
|
"video/webm",
|
||||||
|
"video/ogg",
|
||||||
|
"video/quicktime",
|
||||||
|
"video/x-msvideo",
|
||||||
|
],
|
||||||
|
accept: "video/*",
|
||||||
|
},
|
||||||
|
model3d: {
|
||||||
|
extensions: [".glb", ".gltf"],
|
||||||
|
mimeTypes: ["model/gltf-binary", "model/gltf+json"],
|
||||||
|
accept: ".glb,.gltf",
|
||||||
|
},
|
||||||
|
panorama: {
|
||||||
|
extensions: [".jpg", ".jpeg", ".png"],
|
||||||
|
mimeTypes: ["image/jpeg", "image/png"],
|
||||||
|
accept: "image/*",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const getAllAllowedExtensions = (): string[] => {
|
||||||
|
return [
|
||||||
|
...ALLOWED_MEDIA_TYPES.image.extensions,
|
||||||
|
...ALLOWED_MEDIA_TYPES.video.extensions,
|
||||||
|
...ALLOWED_MEDIA_TYPES.model3d.extensions,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllAcceptString = (): string => {
|
||||||
|
return `${ALLOWED_MEDIA_TYPES.image.accept},${ALLOWED_MEDIA_TYPES.video.accept},${ALLOWED_MEDIA_TYPES.model3d.accept}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateFileExtension = (
|
||||||
|
file: File
|
||||||
|
): { valid: boolean; error?: string } => {
|
||||||
|
const fileName = file.name.toLowerCase();
|
||||||
|
const extension = fileName.substring(fileName.lastIndexOf("."));
|
||||||
|
const allowedExtensions = getAllAllowedExtensions();
|
||||||
|
|
||||||
|
if (!allowedExtensions.includes(extension)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `Неподдерживаемый формат файла "${extension}". Допустимые форматы: ${allowedExtensions.join(
|
||||||
|
", "
|
||||||
|
)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const filterValidFiles = (
|
||||||
|
files: File[]
|
||||||
|
): { validFiles: File[]; errors: string[] } => {
|
||||||
|
const validFiles: File[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
files.forEach((file) => {
|
||||||
|
const validation = validateFileExtension(file);
|
||||||
|
if (validation.valid) {
|
||||||
|
validFiles.push(file);
|
||||||
|
} else {
|
||||||
|
errors.push(`${file.name}: ${validation.error}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { validFiles, errors };
|
||||||
|
};
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import { Box, Button } from "@mui/material";
|
import { Box, Button } from "@mui/material";
|
||||||
import { MediaViewer } from "@widgets";
|
import { MediaViewer } from "@widgets";
|
||||||
import { PreviewMediaDialog } from "@shared";
|
import {
|
||||||
|
PreviewMediaDialog,
|
||||||
|
filterValidFiles,
|
||||||
|
getAllAcceptString,
|
||||||
|
} from "@shared";
|
||||||
import { X, Upload } from "lucide-react";
|
import { X, Upload } from "lucide-react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { useState, DragEvent, useRef } from "react";
|
import { useState, DragEvent, useRef } from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
export const MediaArea = observer(
|
export const MediaArea = observer(
|
||||||
({
|
({
|
||||||
@@ -36,7 +41,15 @@ export const MediaArea = observer(
|
|||||||
|
|
||||||
const files = Array.from(e.dataTransfer.files);
|
const files = Array.from(e.dataTransfer.files);
|
||||||
if (files.length && onFilesDrop) {
|
if (files.length && onFilesDrop) {
|
||||||
onFilesDrop(files);
|
const { validFiles, errors } = filterValidFiles(files);
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
errors.forEach((error) => toast.error(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validFiles.length > 0) {
|
||||||
|
onFilesDrop(validFiles);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,7 +69,15 @@ export const MediaArea = observer(
|
|||||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(event.target.files || []);
|
const files = Array.from(event.target.files || []);
|
||||||
if (files.length && onFilesDrop) {
|
if (files.length && onFilesDrop) {
|
||||||
onFilesDrop(files);
|
const { validFiles, errors } = filterValidFiles(files);
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
errors.forEach((error) => toast.error(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validFiles.length > 0) {
|
||||||
|
onFilesDrop(validFiles);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Сбрасываем значение input, чтобы можно было выбрать тот же файл снова
|
// Сбрасываем значение input, чтобы можно было выбрать тот же файл снова
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
@@ -68,7 +89,7 @@ export const MediaArea = observer(
|
|||||||
type="file"
|
type="file"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
onChange={handleFileSelect}
|
onChange={handleFileSelect}
|
||||||
accept="image/*,video/*,.glb,.gltf"
|
accept={getAllAcceptString()}
|
||||||
multiple
|
multiple
|
||||||
style={{ display: "none" }}
|
style={{ display: "none" }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { Box, Button } from "@mui/material";
|
import { Box, Button } from "@mui/material";
|
||||||
import { editSightStore, SelectMediaDialog, UploadMediaDialog } from "@shared";
|
import {
|
||||||
|
editSightStore,
|
||||||
|
SelectMediaDialog,
|
||||||
|
UploadMediaDialog,
|
||||||
|
filterValidFiles,
|
||||||
|
getAllAcceptString,
|
||||||
|
} from "@shared";
|
||||||
import { Upload } from "lucide-react";
|
import { Upload } from "lucide-react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { useState, DragEvent, useRef } from "react";
|
import { useState, DragEvent, useRef } from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
export const MediaAreaForSight = observer(
|
export const MediaAreaForSight = observer(
|
||||||
({
|
({
|
||||||
@@ -38,11 +45,18 @@ export const MediaAreaForSight = observer(
|
|||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
|
|
||||||
const files = Array.from(e.dataTransfer.files);
|
const files = Array.from(e.dataTransfer.files);
|
||||||
if (files.length && onFilesDrop) {
|
if (files.length) {
|
||||||
setFileToUpload(files[0]);
|
const { validFiles, errors } = filterValidFiles(files);
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
errors.forEach((error: string) => toast.error(error));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (validFiles.length > 0 && onFilesDrop) {
|
||||||
|
setFileToUpload(validFiles[0]);
|
||||||
setUploadMediaDialogOpen(true);
|
setUploadMediaDialogOpen(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||||
@@ -60,11 +74,19 @@ export const MediaAreaForSight = observer(
|
|||||||
|
|
||||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(event.target.files || []);
|
const files = Array.from(event.target.files || []);
|
||||||
if (files.length && onFilesDrop) {
|
if (files.length) {
|
||||||
setFileToUpload(files[0]);
|
const { validFiles, errors } = filterValidFiles(files);
|
||||||
onFilesDrop(files);
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
errors.forEach((error: string) => toast.error(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validFiles.length > 0 && onFilesDrop) {
|
||||||
|
setFileToUpload(validFiles[0]);
|
||||||
|
onFilesDrop(validFiles);
|
||||||
setUploadMediaDialogOpen(true);
|
setUploadMediaDialogOpen(true);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Сбрасываем значение input, чтобы можно было выбрать тот же файл снова
|
// Сбрасываем значение input, чтобы можно было выбрать тот же файл снова
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
@@ -76,7 +98,7 @@ export const MediaAreaForSight = observer(
|
|||||||
type="file"
|
type="file"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
onChange={handleFileSelect}
|
onChange={handleFileSelect}
|
||||||
accept="image/*,video/*,.glb,.gltf"
|
accept={getAllAcceptString()}
|
||||||
multiple
|
multiple
|
||||||
style={{ display: "none" }}
|
style={{ display: "none" }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Canvas } from "@react-three/fiber";
|
import { Canvas } from "@react-three/fiber";
|
||||||
import { OrbitControls, Stage, useGLTF } from "@react-three/drei";
|
import { OrbitControls, Stage, useGLTF } from "@react-three/drei";
|
||||||
|
import { useEffect, Suspense } from "react";
|
||||||
|
import { Box, CircularProgress, Typography } from "@mui/material";
|
||||||
|
|
||||||
type ModelViewerProps = {
|
type ModelViewerProps = {
|
||||||
width?: string;
|
width?: string;
|
||||||
@@ -7,21 +9,72 @@ type ModelViewerProps = {
|
|||||||
height?: string;
|
height?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const Model = ({ fileUrl }: { fileUrl: string }) => {
|
||||||
|
const { scene } = useGLTF(fileUrl);
|
||||||
|
return <primitive object={scene} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LoadingFallback = () => {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 2,
|
||||||
|
zIndex: 1000,
|
||||||
|
backgroundColor: "background.paper",
|
||||||
|
p: 3,
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress size={48} />
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
style={{ whiteSpace: "nowrap" }}
|
||||||
|
>
|
||||||
|
Загрузка 3D модели...
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const ThreeView = ({
|
export const ThreeView = ({
|
||||||
fileUrl,
|
fileUrl,
|
||||||
height = "100%",
|
height = "100%",
|
||||||
width = "100%",
|
width = "100%",
|
||||||
}: ModelViewerProps) => {
|
}: ModelViewerProps) => {
|
||||||
const { scene } = useGLTF(fileUrl);
|
// Очищаем кеш при размонтировании
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
useGLTF.clear(fileUrl);
|
||||||
|
console.log("🧹 ThreeView: Очистка кеша модели", { fileUrl });
|
||||||
|
};
|
||||||
|
}, [fileUrl]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Canvas style={{ height: height, width: width }}>
|
<Box sx={{ position: "relative", width, height }}>
|
||||||
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
|
<Canvas
|
||||||
|
style={{ height: height, width: width }}
|
||||||
|
camera={{
|
||||||
|
position: [1, 1, 1],
|
||||||
|
fov: 30,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ambientLight />
|
<ambientLight />
|
||||||
<directionalLight />
|
<directionalLight />
|
||||||
<Stage environment="city" intensity={0.6}>
|
<Stage environment="city" intensity={0.6} adjustCamera={false}>
|
||||||
<primitive object={scene} />
|
<Model fileUrl={fileUrl} />
|
||||||
</Stage>
|
</Stage>
|
||||||
<OrbitControls />
|
<OrbitControls />
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
</Suspense>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
263
src/widgets/MediaViewer/ThreeViewErrorBoundary.tsx
Normal file
263
src/widgets/MediaViewer/ThreeViewErrorBoundary.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import React, { Component, ReactNode } from "react";
|
||||||
|
import { Box, Button, Typography, Paper } from "@mui/material";
|
||||||
|
import { RefreshCw, AlertTriangle } from "lucide-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
onReset?: () => void;
|
||||||
|
resetKey?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
lastResetKey?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ThreeViewErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
lastResetKey: props.resetKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||||
|
return {
|
||||||
|
hasError: true,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromProps(
|
||||||
|
props: Props,
|
||||||
|
state: State
|
||||||
|
): Partial<State> | null {
|
||||||
|
// Сбрасываем ошибку ТОЛЬКО при смене медиа (когда меняется ID в resetKey)
|
||||||
|
if (
|
||||||
|
props.resetKey !== state.lastResetKey &&
|
||||||
|
state.lastResetKey !== undefined
|
||||||
|
) {
|
||||||
|
const oldMediaId = String(state.lastResetKey).split("-")[0];
|
||||||
|
const newMediaId = String(props.resetKey).split("-")[0];
|
||||||
|
|
||||||
|
// Сбрасываем ошибку только если изменился ID медиа (пользователь выбрал другую модель)
|
||||||
|
if (oldMediaId !== newMediaId) {
|
||||||
|
console.log(
|
||||||
|
"🔄 ThreeViewErrorBoundary: Сброс ошибки при смене модели",
|
||||||
|
{
|
||||||
|
oldKey: state.lastResetKey,
|
||||||
|
newKey: props.resetKey,
|
||||||
|
oldMediaId,
|
||||||
|
newMediaId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
lastResetKey: props.resetKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если изменился только счетчик (нажата кнопка "Попробовать снова"), обновляем lastResetKey
|
||||||
|
// но не сбрасываем ошибку автоматически - ждем результата загрузки
|
||||||
|
console.log(
|
||||||
|
"🔄 ThreeViewErrorBoundary: Обновление lastResetKey без сброса ошибки",
|
||||||
|
{
|
||||||
|
oldKey: state.lastResetKey,
|
||||||
|
newKey: props.resetKey,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
lastResetKey: props.resetKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.lastResetKey === undefined && props.resetKey !== undefined) {
|
||||||
|
return {
|
||||||
|
lastResetKey: props.resetKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||||
|
console.error("❌ ThreeViewErrorBoundary: Ошибка загрузки 3D модели", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
componentStack: errorInfo.componentStack,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getErrorMessage = () => {
|
||||||
|
const errorMessage = this.state.error?.message || "";
|
||||||
|
|
||||||
|
if (
|
||||||
|
errorMessage.includes("not valid JSON") ||
|
||||||
|
errorMessage.includes("Unexpected token")
|
||||||
|
) {
|
||||||
|
return "Неверный формат файла. Убедитесь, что файл является корректной 3D моделью в формате GLB/GLTF.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorMessage.includes("Could not load")) {
|
||||||
|
return "Не удалось загрузить файл 3D модели. Проверьте, что файл существует и доступен.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorMessage.includes("404") || errorMessage.includes("Not Found")) {
|
||||||
|
return "Файл 3D модели не найден на сервере.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorMessage.includes("Network") || errorMessage.includes("fetch")) {
|
||||||
|
return "Ошибка сети при загрузке 3D модели. Проверьте интернет-соединение.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
errorMessage || "Произошла неизвестная ошибка при загрузке 3D модели"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
getErrorReasons = () => {
|
||||||
|
const errorMessage = this.state.error?.message || "";
|
||||||
|
|
||||||
|
if (
|
||||||
|
errorMessage.includes("not valid JSON") ||
|
||||||
|
errorMessage.includes("Unexpected token")
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
"Файл не является 3D моделью",
|
||||||
|
"Загружен файл неподдерживаемого формата",
|
||||||
|
"Файл поврежден или не полностью загружен",
|
||||||
|
"Используйте только GLB или GLTF форматы",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
"Поврежденный файл модели",
|
||||||
|
"Неподдерживаемый формат",
|
||||||
|
"Проблемы с загрузкой файла",
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
handleReset = () => {
|
||||||
|
console.log(
|
||||||
|
"🔄 ThreeViewErrorBoundary: Перезагрузка компонента и перезапрос модели"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Сначала сбрасываем состояние ошибки
|
||||||
|
this.setState(
|
||||||
|
{
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// После того как состояние обновилось, вызываем callback для изменения resetKey
|
||||||
|
// Это приведет к пересозданию компонента и новой попытке загрузки
|
||||||
|
this.props.onReset?.();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleClose = () => {
|
||||||
|
console.log("❌ ThreeViewErrorBoundary: Закрытие ошибки");
|
||||||
|
this.setState({
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
p: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={3}
|
||||||
|
sx={{
|
||||||
|
p: 3,
|
||||||
|
maxWidth: 500,
|
||||||
|
width: "100%",
|
||||||
|
position: "relative",
|
||||||
|
backgroundColor: "error.light",
|
||||||
|
color: "error.contrastText",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", mb: 2 }}>
|
||||||
|
<AlertTriangle size={32} style={{ marginRight: 12 }} />
|
||||||
|
<Typography variant="h6" component="h2">
|
||||||
|
Ошибка загрузки 3D модели
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||||
|
{this.getErrorMessage()}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="caption" sx={{ mb: 2, display: "block" }}>
|
||||||
|
Возможные причины:
|
||||||
|
<ul style={{ margin: "8px 0", paddingLeft: "20px" }}>
|
||||||
|
{this.getErrorReasons().map((reason, index) => (
|
||||||
|
<li key={index}>{reason}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{this.state.error?.message && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
mb: 2,
|
||||||
|
display: "block",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.1)",
|
||||||
|
p: 1,
|
||||||
|
borderRadius: 1,
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
maxHeight: "100px",
|
||||||
|
overflow: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{this.state.error.message}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", gap: 1 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<RefreshCw size={16} />}
|
||||||
|
onClick={() => {
|
||||||
|
console.log(
|
||||||
|
"🖱️ ThreeViewErrorBoundary: Клик на кнопку 'Попробовать снова'"
|
||||||
|
);
|
||||||
|
this.handleReset();
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
backgroundColor: "error.contrastText",
|
||||||
|
color: "error.main",
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Попробовать снова
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Box } from "@mui/material";
|
import { Box } from "@mui/material";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
|
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
|
||||||
import { ThreeView } from "./ThreeView";
|
import { ThreeView } from "./ThreeView";
|
||||||
|
import { ThreeViewErrorBoundary } from "./ThreeViewErrorBoundary";
|
||||||
|
|
||||||
export interface MediaData {
|
export interface MediaData {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
@@ -25,6 +27,23 @@ export function MediaViewer({
|
|||||||
fullHeight?: boolean;
|
fullHeight?: boolean;
|
||||||
}>) {
|
}>) {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
|
const [resetKey, setResetKey] = useState(0);
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
console.log("🔄 MediaViewer: handleReset вызван", {
|
||||||
|
currentResetKey: resetKey,
|
||||||
|
mediaId: media?.id,
|
||||||
|
});
|
||||||
|
setResetKey((prev) => {
|
||||||
|
const newKey = prev + 1;
|
||||||
|
console.log("🔄 MediaViewer: resetKey обновлен", {
|
||||||
|
oldKey: prev,
|
||||||
|
newKey,
|
||||||
|
});
|
||||||
|
return newKey;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
className={className}
|
className={className}
|
||||||
@@ -108,13 +127,19 @@ export function MediaViewer({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{media?.media_type === 6 && (
|
{media?.media_type === 6 && (
|
||||||
|
<ThreeViewErrorBoundary
|
||||||
|
onReset={handleReset}
|
||||||
|
resetKey={`${media?.id}-${resetKey}`}
|
||||||
|
>
|
||||||
<ThreeView
|
<ThreeView
|
||||||
|
key={`3d-model-${media?.id}-${resetKey}`}
|
||||||
fileUrl={`${import.meta.env.VITE_KRBL_MEDIA}${
|
fileUrl={`${import.meta.env.VITE_KRBL_MEDIA}${
|
||||||
media?.id
|
media?.id
|
||||||
}/download?token=${token}`}
|
}/download?token=${token}`}
|
||||||
height={height ? height : "500px"}
|
height={height ? height : "500px"}
|
||||||
width={width ? width : "500px"}
|
width={width ? width : "500px"}
|
||||||
/>
|
/>
|
||||||
|
</ThreeViewErrorBoundary>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user