fix: Fix 3d models

This commit is contained in:
2025-10-02 22:20:37 +03:00
parent a357994025
commit 26e4d70b95
9 changed files with 666 additions and 34 deletions

View 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;
}
}

View File

@@ -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>
); );

View File

@@ -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,

View 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 };
};

View File

@@ -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" }}
/> />

View File

@@ -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" }}
/> />

View File

@@ -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>
); );
}; };

View 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;
}
}

View File

@@ -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>
); );