fix: fix upload bug 3d
This commit is contained in:
98
src/shared/lib/gltfCacheManager.ts
Normal file
98
src/shared/lib/gltfCacheManager.ts
Normal 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();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from "./mui/theme";
|
export * from "./mui/theme";
|
||||||
export * from "./DecodeJWT";
|
export * from "./DecodeJWT";
|
||||||
|
export * from "./gltfCacheManager";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Генерирует название медиа по умолчанию в разных форматах
|
* Генерирует название медиа по умолчанию в разных форматах
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import {
|
|||||||
MEDIA_TYPE_VALUES,
|
MEDIA_TYPE_VALUES,
|
||||||
editSightStore,
|
editSightStore,
|
||||||
generateDefaultMediaName,
|
generateDefaultMediaName,
|
||||||
|
clearBlobAndGLTFCache,
|
||||||
} from "@shared";
|
} from "@shared";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
@@ -82,18 +83,41 @@ export const UploadMediaDialog = observer(
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
const [isPreviewLoaded, setIsPreviewLoaded] = useState(false);
|
const [isPreviewLoaded, setIsPreviewLoaded] = useState(false);
|
||||||
|
const previousMediaUrlRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialFile) {
|
if (initialFile) {
|
||||||
|
// Очищаем предыдущий blob URL если он существует
|
||||||
|
if (
|
||||||
|
previousMediaUrlRef.current &&
|
||||||
|
previousMediaUrlRef.current.startsWith("blob:")
|
||||||
|
) {
|
||||||
|
clearBlobAndGLTFCache(previousMediaUrlRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
setMediaFile(initialFile);
|
setMediaFile(initialFile);
|
||||||
setMediaFilename(initialFile.name);
|
setMediaFilename(initialFile.name);
|
||||||
setAvailableMediaTypes([2]);
|
setAvailableMediaTypes([2]);
|
||||||
setMediaType(2);
|
setMediaType(2);
|
||||||
setMediaUrl(URL.createObjectURL(initialFile));
|
const newBlobUrl = URL.createObjectURL(initialFile);
|
||||||
|
setMediaUrl(newBlobUrl);
|
||||||
|
previousMediaUrlRef.current = newBlobUrl;
|
||||||
setMediaName(initialFile.name.replace(/\.[^/.]+$/, ""));
|
setMediaName(initialFile.name.replace(/\.[^/.]+$/, ""));
|
||||||
}
|
}
|
||||||
}, [initialFile]);
|
}, [initialFile]);
|
||||||
|
|
||||||
|
// Очистка blob URL при размонтировании компонента
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (
|
||||||
|
previousMediaUrlRef.current &&
|
||||||
|
previousMediaUrlRef.current.startsWith("blob:")
|
||||||
|
) {
|
||||||
|
clearBlobAndGLTFCache(previousMediaUrlRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []); // Пустой массив зависимостей - выполняется только при размонтировании
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fileToUpload) {
|
if (fileToUpload) {
|
||||||
setMediaFile(fileToUpload);
|
setMediaFile(fileToUpload);
|
||||||
@@ -211,10 +235,24 @@ export const UploadMediaDialog = observer(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mediaFile) {
|
if (mediaFile) {
|
||||||
setMediaUrl(URL.createObjectURL(mediaFile as Blob));
|
// Очищаем предыдущий blob URL и кеш GLTF если он существует
|
||||||
setIsPreviewLoaded(false); // Сбрасываем состояние загрузки при смене файла
|
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 fileFormat = useEffect(() => {
|
||||||
// const handleKeyPress = (event: KeyboardEvent) => {
|
// const handleKeyPress = (event: KeyboardEvent) => {
|
||||||
@@ -263,8 +301,20 @@ export const UploadMediaDialog = observer(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
// Очищаем blob URL и кеш GLTF при закрытии диалога
|
||||||
|
if (
|
||||||
|
previousMediaUrlRef.current &&
|
||||||
|
previousMediaUrlRef.current.startsWith("blob:")
|
||||||
|
) {
|
||||||
|
clearBlobAndGLTFCache(previousMediaUrlRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
setSuccess(false);
|
setSuccess(false);
|
||||||
|
setMediaUrl(null);
|
||||||
|
setMediaFile(null);
|
||||||
|
setIsPreviewLoaded(false);
|
||||||
|
previousMediaUrlRef.current = null; // Очищаем ref
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
101
src/shared/store/ModelLoadingStore/index.ts
Normal file
101
src/shared/store/ModelLoadingStore/index.ts
Normal 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();
|
||||||
143
src/shared/ui/LoadingSpinner/index.tsx
Normal file
143
src/shared/ui/LoadingSpinner/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
196
src/shared/ui/ModelLoadingIndicator/index.tsx
Normal file
196
src/shared/ui/ModelLoadingIndicator/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,6 +3,78 @@ import { OrbitControls, Stage, useGLTF } from "@react-three/drei";
|
|||||||
import { useEffect, Suspense } from "react";
|
import { useEffect, Suspense } from "react";
|
||||||
import { Box, CircularProgress, Typography } from "@mui/material";
|
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 = {
|
type ModelViewerProps = {
|
||||||
width?: string;
|
width?: string;
|
||||||
fileUrl: string;
|
fileUrl: string;
|
||||||
@@ -10,6 +82,18 @@ type ModelViewerProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Model = ({ fileUrl }: { fileUrl: string }) => {
|
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);
|
const { scene } = useGLTF(fileUrl);
|
||||||
return <primitive object={scene} />;
|
return <primitive object={scene} />;
|
||||||
};
|
};
|
||||||
@@ -49,11 +133,26 @@ export const ThreeView = ({
|
|||||||
height = "100%",
|
height = "100%",
|
||||||
width = "100%",
|
width = "100%",
|
||||||
}: ModelViewerProps) => {
|
}: ModelViewerProps) => {
|
||||||
// Очищаем кеш при размонтировании
|
// Проверяем валидность файла (только для blob URL)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (fileUrl.startsWith("blob:") && !isValid3DFile(fileUrl)) {
|
||||||
|
console.warn("⚠️ ThreeView: Невалидный 3D файл", { fileUrl });
|
||||||
|
}
|
||||||
|
}, [fileUrl]);
|
||||||
|
|
||||||
|
// Очищаем кеш при размонтировании и при смене URL
|
||||||
|
useEffect(() => {
|
||||||
|
// Очищаем кеш сразу при монтировании компонента
|
||||||
|
clearGLTFCache(fileUrl);
|
||||||
|
console.log("🧹 ThreeView: Очистка кеша модели при монтировании", {
|
||||||
|
fileUrl,
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
useGLTF.clear(fileUrl);
|
clearGLTFCache(fileUrl);
|
||||||
console.log("🧹 ThreeView: Очистка кеша модели", { fileUrl });
|
console.log("🧹 ThreeView: Очистка кеша модели при размонтировании", {
|
||||||
|
fileUrl,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}, [fileUrl]);
|
}, [fileUrl]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Box } from "@mui/material";
|
import { Box } from "@mui/material";
|
||||||
import { useState } from "react";
|
import { useState, useEffect } 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";
|
import { ThreeViewErrorBoundary } from "./ThreeViewErrorBoundary";
|
||||||
|
import { clearMediaTransitionCache } from "../../shared/lib/gltfCacheManager";
|
||||||
|
|
||||||
export interface MediaData {
|
export interface MediaData {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
@@ -28,6 +29,30 @@ export function MediaViewer({
|
|||||||
}>) {
|
}>) {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
const [resetKey, setResetKey] = useState(0);
|
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 = () => {
|
const handleReset = () => {
|
||||||
console.log("🔄 MediaViewer: handleReset вызван", {
|
console.log("🔄 MediaViewer: handleReset вызван", {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user