fix: fix upload bug 3d

This commit is contained in:
2025-10-20 20:00:28 +03:00
parent f5142ec95d
commit 1b8fc3d215
9 changed files with 722 additions and 9 deletions

View File

@@ -0,0 +1,98 @@
/**
* Утилита для управления кешем GLTF и blob URL
*/
// Динамический импорт useGLTF для избежания проблем с SSR
let useGLTF: any = null;
const initializeUseGLTF = async () => {
if (!useGLTF) {
try {
const drei = await import("@react-three/drei");
useGLTF = drei.useGLTF;
} catch (error) {
console.warn(
"⚠️ GLTFCacheManager: Не удалось импортировать useGLTF",
error
);
}
}
return useGLTF;
};
/**
* Очищает кеш GLTF для конкретного URL
*/
export const clearGLTFCacheForUrl = async (url: string) => {
try {
const gltf = await initializeUseGLTF();
if (gltf && gltf.clear) {
gltf.clear(url);
console.log("🧹 GLTFCacheManager: Очистка кеша для URL", { url });
}
} catch (error) {
console.warn("⚠️ GLTFCacheManager: Ошибка при очистке кеша для URL", error);
}
};
/**
* Очищает весь кеш GLTF
*/
export const clearAllGLTFCache = async () => {
try {
const gltf = await initializeUseGLTF();
if (gltf && gltf.clear) {
gltf.clear();
console.log("🧹 GLTFCacheManager: Очистка всего кеша GLTF");
}
} catch (error) {
console.warn("⚠️ GLTFCacheManager: Ошибка при очистке всего кеша", error);
}
};
/**
* Очищает blob URL из памяти браузера
*/
export const revokeBlobURL = (url: string) => {
if (url && url.startsWith("blob:")) {
try {
URL.revokeObjectURL(url);
console.log("🧹 GLTFCacheManager: Отзыв blob URL", { url });
} catch (error) {
console.warn("⚠️ GLTFCacheManager: Ошибка при отзыве blob URL", error);
}
}
};
/**
* Комплексная очистка: blob URL + кеш GLTF
*/
export const clearBlobAndGLTFCache = async (url: string) => {
// Сначала отзываем blob URL
revokeBlobURL(url);
// Затем очищаем кеш GLTF
await clearGLTFCacheForUrl(url);
console.log("🧹 GLTFCacheManager: Комплексная очистка выполнена", { url });
};
/**
* Очистка при смене медиа (для предотвращения конфликтов)
*/
export const clearMediaTransitionCache = async (
previousMediaId: string | number | null,
newMediaId: string | number | null,
newMediaType?: number
) => {
console.log("🔄 GLTFCacheManager: Очистка кеша при смене медиа", {
previousMediaId,
newMediaId,
newMediaType,
});
// Если переключаемся с/на 3D модель, очищаем весь кеш
if (newMediaType === 6 || previousMediaId) {
await clearAllGLTFCache();
}
};

View File

@@ -1,5 +1,6 @@
export * from "./mui/theme";
export * from "./DecodeJWT";
export * from "./gltfCacheManager";
/**
* Генерирует название медиа по умолчанию в разных форматах

View File

@@ -3,9 +3,10 @@ import {
MEDIA_TYPE_VALUES,
editSightStore,
generateDefaultMediaName,
clearBlobAndGLTFCache,
} from "@shared";
import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
import {
Dialog,
DialogTitle,
@@ -82,18 +83,41 @@ export const UploadMediaDialog = observer(
[]
);
const [isPreviewLoaded, setIsPreviewLoaded] = useState(false);
const previousMediaUrlRef = useRef<string | null>(null);
useEffect(() => {
if (initialFile) {
// Очищаем предыдущий blob URL если он существует
if (
previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:")
) {
clearBlobAndGLTFCache(previousMediaUrlRef.current);
}
setMediaFile(initialFile);
setMediaFilename(initialFile.name);
setAvailableMediaTypes([2]);
setMediaType(2);
setMediaUrl(URL.createObjectURL(initialFile));
const newBlobUrl = URL.createObjectURL(initialFile);
setMediaUrl(newBlobUrl);
previousMediaUrlRef.current = newBlobUrl;
setMediaName(initialFile.name.replace(/\.[^/.]+$/, ""));
}
}, [initialFile]);
// Очистка blob URL при размонтировании компонента
useEffect(() => {
return () => {
if (
previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:")
) {
clearBlobAndGLTFCache(previousMediaUrlRef.current);
}
};
}, []); // Пустой массив зависимостей - выполняется только при размонтировании
useEffect(() => {
if (fileToUpload) {
setMediaFile(fileToUpload);
@@ -211,10 +235,24 @@ export const UploadMediaDialog = observer(
useEffect(() => {
if (mediaFile) {
setMediaUrl(URL.createObjectURL(mediaFile as Blob));
setIsPreviewLoaded(false); // Сбрасываем состояние загрузки при смене файла
// Очищаем предыдущий blob URL и кеш GLTF если он существует
if (
previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:")
) {
clearBlobAndGLTFCache(previousMediaUrlRef.current);
}
}, [mediaFile]);
const newBlobUrl = URL.createObjectURL(mediaFile as Blob);
setMediaUrl(newBlobUrl);
previousMediaUrlRef.current = newBlobUrl; // Сохраняем новый URL в ref
setIsPreviewLoaded(false); // Сбрасываем состояние загрузки при смене файла
console.log("🆕 UploadMediaDialog: Создан новый blob URL", {
newBlobUrl,
fileName: mediaFile.name,
});
}
}, [mediaFile]); // Убираем mediaUrl из зависимостей чтобы избежать зацикливания
// const fileFormat = useEffect(() => {
// const handleKeyPress = (event: KeyboardEvent) => {
@@ -263,8 +301,20 @@ export const UploadMediaDialog = observer(
};
const handleClose = () => {
// Очищаем blob URL и кеш GLTF при закрытии диалога
if (
previousMediaUrlRef.current &&
previousMediaUrlRef.current.startsWith("blob:")
) {
clearBlobAndGLTFCache(previousMediaUrlRef.current);
}
setError(null);
setSuccess(false);
setMediaUrl(null);
setMediaFile(null);
setIsPreviewLoaded(false);
previousMediaUrlRef.current = null; // Очищаем ref
onClose();
};

View File

@@ -0,0 +1,101 @@
import { makeAutoObservable } from "mobx";
export interface ModelLoadingState {
isLoading: boolean;
progress: number;
modelId: string | null;
error?: string;
startTime?: number;
}
class ModelLoadingStore {
private loadingStates: Map<string, ModelLoadingState> = new Map();
constructor() {
makeAutoObservable(this);
}
// Начать отслеживание загрузки модели
startLoading(modelId: string) {
this.loadingStates.set(modelId, {
isLoading: true,
progress: 0,
modelId,
startTime: Date.now(),
});
}
// Обновить прогресс загрузки
updateProgress(modelId: string, progress: number) {
const state = this.loadingStates.get(modelId);
if (state) {
state.progress = Math.min(100, Math.max(0, progress));
}
}
// Завершить загрузку модели
finishLoading(modelId: string) {
const state = this.loadingStates.get(modelId);
if (state) {
state.isLoading = false;
state.progress = 100;
}
}
// Остановить загрузку (в случае ошибки)
stopLoading(modelId: string) {
this.loadingStates.delete(modelId);
}
// Обработать ошибку загрузки
handleError(modelId: string, error?: string) {
const state = this.loadingStates.get(modelId);
if (state) {
state.isLoading = false;
state.error = error || "Ошибка загрузки модели";
}
}
// Получить состояние загрузки для конкретной модели
getLoadingState(modelId: string): ModelLoadingState | undefined {
return this.loadingStates.get(modelId);
}
// Проверить, загружается ли какая-либо модель
get isAnyModelLoading(): boolean {
return Array.from(this.loadingStates.values()).some(
(state) => state.isLoading
);
}
// Получить все загружающиеся модели
get loadingModels(): ModelLoadingState[] {
return Array.from(this.loadingStates.values()).filter(
(state) => state.isLoading
);
}
// Получить общий прогресс всех загружающихся моделей
get overallProgress(): number {
const loadingModels = this.loadingModels;
if (loadingModels.length === 0) return 100;
const totalProgress = loadingModels.reduce(
(sum, model) => sum + model.progress,
0
);
return Math.round(totalProgress / loadingModels.length);
}
// Проверить, заблокировано ли сохранение (есть ли загружающиеся модели)
get isSaveBlocked(): boolean {
return this.isAnyModelLoading;
}
// Очистить все состояния загрузки
clearAll() {
this.loadingStates.clear();
}
}
export const modelLoadingStore = new ModelLoadingStore();

View File

@@ -0,0 +1,143 @@
import React from "react";
import {
Box,
CircularProgress,
Typography,
LinearProgress,
} from "@mui/material";
interface LoadingSpinnerProps {
progress?: number;
message?: string;
size?: number;
color?: "primary" | "secondary" | "error" | "info" | "success" | "warning";
variant?: "circular" | "linear";
showPercentage?: boolean;
thickness?: number;
className?: string;
}
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
progress,
message = "Загрузка...",
size = 40,
color = "primary",
variant = "circular",
showPercentage = true,
thickness = 4,
className,
}) => {
if (variant === "linear") {
return (
<Box
className={className}
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 2,
padding: 3,
width: "100%",
}}
>
<Box sx={{ width: "100%", maxWidth: 300 }}>
<LinearProgress
variant={progress !== undefined ? "determinate" : "indeterminate"}
value={progress}
color={color}
sx={{
height: 8,
borderRadius: 4,
backgroundColor: "rgba(0, 0, 0, 0.1)",
"& .MuiLinearProgress-bar": {
borderRadius: 4,
},
}}
/>
{showPercentage && progress !== undefined && (
<Typography
variant="caption"
color="text.secondary"
sx={{
display: "block",
textAlign: "center",
mt: 1,
fontSize: "0.875rem",
fontWeight: 500,
}}
>
{`${Math.round(progress)}%`}
</Typography>
)}
</Box>
{message && (
<Typography variant="body2" color="text.secondary" textAlign="center">
{message}
</Typography>
)}
</Box>
);
}
return (
<Box
className={className}
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 2,
padding: 3,
}}
>
<Box sx={{ position: "relative", display: "inline-flex" }}>
<CircularProgress
size={size}
color={color}
variant={progress !== undefined ? "determinate" : "indeterminate"}
value={progress}
thickness={thickness}
sx={{
"& .MuiCircularProgress-circle": {
strokeLinecap: "round",
},
}}
/>
{showPercentage && progress !== undefined && (
<Box
sx={{
top: 0,
left: 0,
bottom: 0,
right: 0,
position: "absolute",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Typography
variant="caption"
component="div"
color="text.secondary"
sx={{
fontSize: size * 0.25,
fontWeight: 600,
lineHeight: 1,
}}
>
{`${Math.round(progress)}%`}
</Typography>
</Box>
)}
</Box>
{message && (
<Typography variant="body2" color="text.secondary" textAlign="center">
{message}
</Typography>
)}
</Box>
);
};

View File

@@ -0,0 +1,196 @@
import React from "react";
import {
Box,
Typography,
LinearProgress,
CircularProgress,
} from "@mui/material";
interface ModelLoadingIndicatorProps {
progress?: number;
message?: string;
isVisible?: boolean;
variant?: "overlay" | "inline";
size?: "small" | "medium" | "large";
showDetails?: boolean;
}
export const ModelLoadingIndicator: React.FC<ModelLoadingIndicatorProps> = ({
progress = 0,
message = "Загрузка 3D модели...",
isVisible = true,
variant = "overlay",
size = "medium",
showDetails = true,
}) => {
const sizeConfig = {
small: {
spinnerSize: 32,
fontSize: "0.75rem",
progressBarWidth: 150,
padding: 2,
},
medium: {
spinnerSize: 48,
fontSize: "0.875rem",
progressBarWidth: 200,
padding: 3,
},
large: {
spinnerSize: 64,
fontSize: "1rem",
progressBarWidth: 250,
padding: 4,
},
};
const currentSize = sizeConfig[size];
if (!isVisible) return null;
const getProgressStage = (progress: number): string => {
if (progress < 20) return "Инициализация...";
if (progress < 40) return "Загрузка геометрии...";
if (progress < 60) return "Обработка материалов...";
if (progress < 80) return "Загрузка текстур...";
if (progress < 95) return "Финализация...";
if (progress === 100) return "Готово!";
return "Загрузка...";
};
const content = (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 2,
padding: currentSize.padding,
textAlign: "center",
width: "100%",
}}
>
{/* Крутяшка с процентами */}
<Box sx={{ position: "relative", display: "inline-flex" }}>
<CircularProgress
size={currentSize.spinnerSize}
variant="determinate"
value={progress}
thickness={4}
sx={{
color: "primary.main",
}}
/>
<Box
sx={{
top: 0,
left: 0,
bottom: 0,
right: 0,
position: "absolute",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Typography
variant="caption"
component="div"
color="text.secondary"
sx={{
fontSize: currentSize.spinnerSize * 0.25,
fontWeight: 600,
lineHeight: 1,
}}
>
{`${Math.round(progress)}%`}
</Typography>
</Box>
</Box>
{/* Линейный прогресс бар */}
<Box sx={{ width: "100%", maxWidth: currentSize.progressBarWidth }}>
<LinearProgress
variant="determinate"
value={progress}
color="primary"
sx={{
height: 8,
borderRadius: 4,
backgroundColor: "rgba(0, 0, 0, 0.1)",
}}
/>
</Box>
{/* Основное сообщение */}
<Typography
variant="body2"
color="text.secondary"
sx={{
fontSize: currentSize.fontSize,
fontWeight: 500,
maxWidth: 280,
lineHeight: 1.4,
}}
>
{message}
</Typography>
{/* Детальная информация о прогрессе */}
{showDetails && progress > 0 && (
<Typography
variant="caption"
color="text.disabled"
sx={{
fontSize: "0.75rem",
opacity: 0.8,
fontWeight: 400,
}}
>
{getProgressStage(progress)}
</Typography>
)}
</Box>
);
if (variant === "overlay") {
return (
<Box
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(255, 255, 255, 0.95)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
borderRadius: 1,
border: "1px solid rgba(0, 0, 0, 0.05)",
}}
>
{content}
</Box>
);
}
return (
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: 200,
backgroundColor: "rgba(0, 0, 0, 0.02)",
borderRadius: 2,
border: "1px dashed",
borderColor: "divider",
}}
>
{content}
</Box>
);
};

View File

@@ -3,6 +3,78 @@ import { OrbitControls, Stage, useGLTF } from "@react-three/drei";
import { useEffect, Suspense } from "react";
import { Box, CircularProgress, Typography } from "@mui/material";
// Утилита для очистки кеша GLTF
const clearGLTFCache = (url?: string) => {
try {
if (url) {
// Если это blob URL, очищаем его из кеша
if (url.startsWith("blob:")) {
useGLTF.clear(url);
console.log("🧹 clearGLTFCache: Очистка blob URL из кеша GLTF", {
url,
});
} else {
useGLTF.clear(url);
console.log("🧹 clearGLTFCache: Очистка кеша для URL", { url });
}
} else {
// Очищаем весь кеш GLTF
useGLTF.clear();
console.log("🧹 clearGLTFCache: Очистка всего кеша GLTF");
}
} catch (error) {
console.warn("⚠️ clearGLTFCache: Ошибка при очистке кеша", error);
}
};
// Утилита для проверки типа файла
const isValid3DFile = (url: string): boolean => {
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname.toLowerCase();
const searchParams = urlObj.searchParams;
// Проверяем расширение файла в пути
const validExtensions = [".glb", ".gltf"];
const hasValidExtension = validExtensions.some((ext) =>
pathname.endsWith(ext)
);
// Проверяем параметры запроса на наличие типа файла
const fileType = searchParams.get("type") || searchParams.get("format");
const hasValidType =
fileType && ["glb", "gltf"].includes(fileType.toLowerCase());
// Если это blob URL, считаем его валидным (пользователь выбрал файл)
const isBlobUrl = url.startsWith("blob:");
// Если это URL с токеном и нет явного расширения, считаем валидным
// (предполагаем что сервер вернет правильный файл)
const hasToken = searchParams.has("token");
const isServerUrl = hasToken && !hasValidExtension;
const isValid =
hasValidExtension || hasValidType || isBlobUrl || isServerUrl;
console.log("🔍 isValid3DFile: Проверка файла", {
url,
pathname,
hasValidExtension,
fileType,
hasValidType,
isBlobUrl,
isServerUrl,
isValid,
});
return isValid;
} catch (error) {
console.warn("⚠️ isValid3DFile: Ошибка при проверке URL", error);
// В случае ошибки парсинга URL, считаем валидным (пусть useGLTF сам разберется)
return true;
}
};
type ModelViewerProps = {
width?: string;
fileUrl: string;
@@ -10,6 +82,18 @@ type ModelViewerProps = {
};
const Model = ({ fileUrl }: { fileUrl: string }) => {
// Очищаем кеш перед загрузкой новой модели
useEffect(() => {
// Очищаем кеш для текущего URL
clearGLTFCache(fileUrl);
}, [fileUrl]);
// Проверяем валидность файла перед загрузкой (только для blob URL)
if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) {
console.warn("⚠️ Model: Попытка загрузить невалидный 3D файл", { fileUrl });
throw new Error(`Файл не является корректной 3D моделью: ${fileUrl}`);
}
const { scene } = useGLTF(fileUrl);
return <primitive object={scene} />;
};
@@ -49,11 +133,26 @@ export const ThreeView = ({
height = "100%",
width = "100%",
}: ModelViewerProps) => {
// Очищаем кеш при размонтировании
// Проверяем валидность файла (только для blob URL)
useEffect(() => {
if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) {
console.warn("⚠️ ThreeView: Невалидный 3D файл", { fileUrl });
}
}, [fileUrl]);
// Очищаем кеш при размонтировании и при смене URL
useEffect(() => {
// Очищаем кеш сразу при монтировании компонента
clearGLTFCache(fileUrl);
console.log("🧹 ThreeView: Очистка кеша модели при монтировании", {
fileUrl,
});
return () => {
useGLTF.clear(fileUrl);
console.log("🧹 ThreeView: Очистка кеша модели", { fileUrl });
clearGLTFCache(fileUrl);
console.log("🧹 ThreeView: Очистка кеша модели при размонтировании", {
fileUrl,
});
};
}, [fileUrl]);

View File

@@ -1,9 +1,10 @@
import { Box } from "@mui/material";
import { useState } from "react";
import { useState, useEffect } from "react";
import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer";
import { ThreeView } from "./ThreeView";
import { ThreeViewErrorBoundary } from "./ThreeViewErrorBoundary";
import { clearMediaTransitionCache } from "../../shared/lib/gltfCacheManager";
export interface MediaData {
id: string | number;
@@ -28,6 +29,30 @@ export function MediaViewer({
}>) {
const token = localStorage.getItem("token");
const [resetKey, setResetKey] = useState(0);
const [previousMediaId, setPreviousMediaId] = useState<
string | number | null
>(null);
// Сбрасываем ключ при смене медиа
useEffect(() => {
if (media?.id !== previousMediaId) {
console.log("🔄 MediaViewer: Смена медиа, сброс ключа", {
previousMediaId,
newMediaId: media?.id,
mediaType: media?.media_type,
});
// Используем новый cache manager для очистки кеша
clearMediaTransitionCache(
previousMediaId,
media?.id || null,
media?.media_type
);
setResetKey(0);
setPreviousMediaId(media?.id || null);
}
}, [media?.id, media?.media_type, previousMediaId]);
const handleReset = () => {
console.log("🔄 MediaViewer: handleReset вызван", {

File diff suppressed because one or more lines are too long