Improved authentication; added fallbacks to 3D; cleaner dashboard charts

This commit is contained in:
iv_vuytsik
2025-10-22 21:28:10 +03:00
parent 34e84213c7
commit 932b16d4f4
18 changed files with 478 additions and 171 deletions

View File

@@ -29,7 +29,7 @@ class UserDataView(APIView):
value={ value={
"id": 1, "id": 1,
"email": "user@example.com", "email": "user@example.com",
"account_type": "engieneer", "account_type": "engineer",
"name": "Иван", "name": "Иван",
"surname": "Иванов", "surname": "Иванов",
"imageURL": "https://example.com/avatar.jpg", "imageURL": "https://example.com/avatar.jpg",

View File

@@ -45,7 +45,7 @@ class LoginViewSet(AuthBaseViewSet):
"user": { "user": {
"id": 1, "id": 1,
"email": "user@example.com", "email": "user@example.com",
"account_type": "engieneer", "account_type": "engineer",
"name": "Иван", "name": "Иван",
"surname": "Иванов", "surname": "Иванов",
"imageURL": "https://example.com/avatar.jpg", "imageURL": "https://example.com/avatar.jpg",

View File

@@ -1,4 +1,5 @@
import React from 'react' import React from 'react'
import AuthGuard from '@/components/auth/AuthGuard'
export default function ProtectedLayout({ export default function ProtectedLayout({
children, children,
@@ -6,8 +7,10 @@ export default function ProtectedLayout({
children: React.ReactNode children: React.ReactNode
}) { }) {
return ( return (
<div className="protected-layout"> <AuthGuard>
{children} <div className="protected-layout">
</div> {children}
</div>
</AuthGuard>
) )
} }

View File

@@ -79,6 +79,8 @@ const NavigationPage: React.FC = () => {
const [detectorsData, setDetectorsData] = useState<{ detectors: Record<string, DetectorType> }>({ detectors: {} }) const [detectorsData, setDetectorsData] = useState<{ detectors: Record<string, DetectorType> }>({ detectors: {} })
const [detectorsError, setDetectorsError] = useState<string | null>(null) const [detectorsError, setDetectorsError] = useState<string | null>(null)
const [modelError, setModelError] = useState<string | null>(null)
const [isModelReady, setIsModelReady] = useState(false)
const urlObjectId = searchParams.get('objectId') const urlObjectId = searchParams.get('objectId')
const urlObjectTitle = searchParams.get('objectTitle') const urlObjectTitle = searchParams.get('objectTitle')
@@ -86,10 +88,14 @@ const NavigationPage: React.FC = () => {
const objectTitle = currentObject.title || urlObjectTitle const objectTitle = currentObject.title || urlObjectTitle
const handleModelLoaded = useCallback(() => { const handleModelLoaded = useCallback(() => {
setIsModelReady(true)
setModelError(null)
}, []) }, [])
const handleModelError = useCallback((error: string) => { const handleModelError = useCallback((error: string) => {
console.error('Model loading error:', error) console.error('Model loading error:', error)
setModelError(error)
setIsModelReady(false)
}, []) }, [])
useEffect(() => { useEffect(() => {
@@ -131,6 +137,12 @@ const NavigationPage: React.FC = () => {
serial_number: detector.serial_number, serial_number: detector.serial_number,
}) })
// Проверяем, что детектор имеет необходимые данные
if (!detector || !detector.detector_id || !detector.serial_number) {
console.warn('[NavigationPage] Invalid detector data, skipping menu display:', detector)
return
}
if (selectedDetector?.detector_id === detector.detector_id && showDetectorMenu) { if (selectedDetector?.detector_id === detector.detector_id && showDetectorMenu) {
setShowDetectorMenu(false) setShowDetectorMenu(false)
setSelectedDetector(null) setSelectedDetector(null)
@@ -197,6 +209,7 @@ const NavigationPage: React.FC = () => {
detectorsData={detectorsData} detectorsData={detectorsData}
onDetectorMenuClick={handleDetectorMenuClick} onDetectorMenuClick={handleDetectorMenuClick}
onClose={closeFloorNavigation} onClose={closeFloorNavigation}
is3DReady={isModelReady && !modelError}
/> />
</div> </div>
</div> </div>
@@ -263,24 +276,40 @@ const NavigationPage: React.FC = () => {
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<div className="h-full"> <div className="h-full">
<ModelViewer {modelError ? (
modelPath='/static-models/AerBIM_Monitor_ASM_HT_Viewer_Expo2017Astana_Level_+1430_custom_prop.glb' <div className="h-full flex items-center justify-center bg-[#0e111a]">
onModelLoaded={handleModelLoaded} <div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md">
onError={handleModelError} <div className="text-red-400 text-lg font-semibold mb-4">
focusSensorId={selectedDetector?.serial_number ?? null} Ошибка загрузки 3D модели
renderOverlay={({ anchor }) => ( </div>
selectedDetector && showDetectorMenu && anchor ? ( <div className="text-gray-300 mb-4">
<DetectorMenu {modelError}
detector={selectedDetector} </div>
isOpen={true} <div className="text-sm text-gray-400">
onClose={closeDetectorMenu} Используйте навигацию по этажам для просмотра детекторов
getStatusText={getStatusText} </div>
compact={true} </div>
anchor={anchor} </div>
/> ) : (
) : null <ModelViewer
)} modelPath='/static-models/AerBIM_Monitor_ASM_HT_Viewer_Expo2017Astana_Level_+1430_custom_prop.glb'
/> onModelLoaded={handleModelLoaded}
onError={handleModelError}
focusSensorId={selectedDetector?.serial_number ?? null}
renderOverlay={({ anchor }) => (
selectedDetector && showDetectorMenu && anchor ? (
<DetectorMenu
detector={selectedDetector}
isOpen={true}
onClose={closeDetectorMenu}
getStatusText={getStatusText}
compact={true}
anchor={anchor}
/>
) : null
)}
/>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -93,14 +93,9 @@ const ObjectsPage: React.FC = () => {
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" /> <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg> </svg>
</div> </div>
<h3 className="text-lg font-medium text-white mb-2">Ошибка загрузки</h3> <h3 className="text-lg font-medium text-white mb-2">Ошибка загрузки данных</h3>
<p className="text-[#71717a] mb-4">{error}</p> <p className="text-[#71717a] mb-4">{error}</p>
<button <p className="text-sm text-gray-500">Если проблема повторяется, обратитесь к администратору</p>
onClick={() => window.location.reload()}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-[#3193f5] hover:bg-[#2563eb] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 transition-colors duration-200"
>
Попробовать снова
</button>
</div> </div>
</div> </div>
) )

View File

@@ -0,0 +1,53 @@
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { NextRequest } from 'next/server'
import { getToken } from 'next-auth/jwt'
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
const secret = process.env.NEXTAUTH_SECRET
const token = await getToken({ req, secret }).catch(() => null)
const accessToken = (session as any)?.accessToken || (token as any)?.accessToken
const refreshToken = (session as any)?.refreshToken || (token as any)?.refreshToken
const backendUrl = process.env.BACKEND_URL
if (!backendUrl) {
return new Response(JSON.stringify({ success: false, error: 'BACKEND_URL is not configured' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
})
}
if (!refreshToken && !accessToken) {
return new Response(JSON.stringify({ success: false, error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})
}
const payload = refreshToken ? { refresh: refreshToken } : {}
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`
const res = await fetch(`${backendUrl}/auth/logout/`, {
method: 'POST',
headers,
body: JSON.stringify(payload),
})
const text = await res.text().catch(() => '')
const contentType = res.headers.get('Content-Type') || 'application/json'
return new Response(text || JSON.stringify({ success: res.ok }), {
status: res.status,
headers: { 'Content-Type': contentType },
})
} catch (error) {
console.error('Error in logout route:', error)
return new Response(JSON.stringify({ success: false, error: 'Failed to logout' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
})
}
}

View File

@@ -50,7 +50,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
email: userData.email || session.user.email || '', email: userData.email || session.user.email || '',
image: userData.imageURL || userData.image, image: userData.imageURL || userData.image,
account_type: userData.account_type, account_type: userData.account_type,
login: userData.login, login: userData.login ?? session.user.name ?? '',
uuid: userData.uuid, uuid: userData.uuid,
}) })
setAuthenticated(true) setAuthenticated(true)

View File

@@ -1,5 +1,4 @@
import { create } from 'zustand' import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { User, UserState } from '../types' import { User, UserState } from '../types'
interface UserStore extends UserState { interface UserStore extends UserState {
@@ -16,28 +15,19 @@ interface UserStore extends UserState {
//! что пользователь может делать асинхронно? //! что пользователь может делать асинхронно?
} }
const useUserStore = create<UserStore>()( const useUserStore = create<UserStore>(set => ({
persist( // начальное состояние
set => ({ isAuthenticated: false,
// начальное состояние user: null,
isAuthenticated: false, favorites: [],
user: null,
favorites: [],
// синхронные действия // синхронные действия
setUser: user => set({ user }), setUser: user => set({ user }),
setAuthenticated: isAuthenticated => set({ isAuthenticated }), setAuthenticated: isAuthenticated => set({ isAuthenticated }),
logout: () => logout: () => {
set({ set({ isAuthenticated: false, user: null })
isAuthenticated: false, },
user: null, }))
}),
}),
//! асинхронщина?
{ name: 'user-store' }
)
)
export default useUserStore export default useUserStore

View File

@@ -1,9 +1,2 @@
export interface ValidationRules { import type { ValidationRules, ValidationErrors, User, UserState } from './types/index'
required?: boolean export type { ValidationRules, ValidationErrors, User, UserState }
minLength?: number
pattern?: RegExp
}
export type ValidationErrors = Record<string, string>
export type User = object
export type UserState = object

View File

@@ -0,0 +1,52 @@
'use client'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
interface AuthGuardProps {
children: React.ReactNode
}
export default function AuthGuard({ children }: AuthGuardProps) {
const { data: session, status } = useSession()
const router = useRouter()
const [isChecking, setIsChecking] = useState(true)
useEffect(() => {
if (status === 'loading') {
return // Ждем загрузки сессии
}
if (status === 'unauthenticated' || !session) {
console.log('[AuthGuard] Нет активной сессии, перенаправление на логин')
router.replace('/login')
return
}
if (status === 'authenticated' && session) {
console.log('[AuthGuard] Сессия активна, разрешаем доступ')
setIsChecking(false)
}
}, [session, status, router])
// Показываем загрузку пока проверяем аутентификацию
if (status === 'loading' || isChecking) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="flex flex-col items-center space-y-4">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent"></div>
<p className="text-gray-600">Проверка аутентификации...</p>
</div>
</div>
)
}
// Если нет сессии, не рендерим детей (будет перенаправление)
if (status === 'unauthenticated' || !session) {
return null
}
// Если все в порядке, рендерим детей
return <>{children}</>
}

View File

@@ -215,28 +215,28 @@ const Dashboard: React.FC = () => {
title="Тренды детекторов" title="Тренды детекторов"
subtitle="За последний месяц" subtitle="За последний месяц"
> >
<DetectorChart type="line" /> <DetectorChart type="line" data={chartData?.map((d: any) => ({ value: d.value }))} />
</ChartCard> </ChartCard>
<ChartCard <ChartCard
title="Статистика по месяцам" title="Статистика по месяцам"
subtitle="Активность детекторов" subtitle="Активность детекторов"
> >
<DetectorChart type="bar" /> <DetectorChart type="bar" data={chartData?.map((d: any) => ({ value: d.value }))} />
</ChartCard> </ChartCard>
<ChartCard <ChartCard
title="Анализ производительности" title="Анализ производительности"
subtitle="Эффективность работы" subtitle="Эффективность работы"
> >
<DetectorChart type="line" /> <DetectorChart type="line" data={chartData?.map((d: any) => ({ value: d.value }))} />
</ChartCard> </ChartCard>
<ChartCard <ChartCard
title="Сводка по статусам" title="Сводка по статусам"
subtitle="Распределение состояний" subtitle="Распределение состояний"
> >
<DetectorChart type="bar" /> <DetectorChart type="bar" data={chartData?.map((d: any) => ({ value: d.value }))} />
</ChartCard> </ChartCard>
</div> </div>
</div> </div>

View File

@@ -17,10 +17,11 @@ interface DetectorChartProps {
const DetectorChart: React.FC<DetectorChartProps> = ({ const DetectorChart: React.FC<DetectorChartProps> = ({
className = '', className = '',
data,
type = 'line' type = 'line'
}) => { }) => {
if (type === 'bar') { if (type === 'bar') {
const barData = [ const defaultBarData = [
{ value: 85, label: 'Янв' }, { value: 85, label: 'Янв' },
{ value: 70, label: 'Фев' }, { value: 70, label: 'Фев' },
{ value: 90, label: 'Мар' }, { value: 90, label: 'Мар' },
@@ -29,6 +30,13 @@ const DetectorChart: React.FC<DetectorChartProps> = ({
{ value: 95, label: 'Июн' } { value: 95, label: 'Июн' }
] ]
const barData = (Array.isArray(data) && data.length > 0)
? data.slice(0, 6).map((d, i) => ({
value: d.value || 0,
label: d.label || defaultBarData[i]?.label || `${i + 1}`
}))
: defaultBarData
return ( return (
<div className={`w-full h-full ${className}`}> <div className={`w-full h-full ${className}`}>
<svg className="w-full h-full" viewBox="0 0 400 200"> <svg className="w-full h-full" viewBox="0 0 400 200">
@@ -69,9 +77,40 @@ const DetectorChart: React.FC<DetectorChartProps> = ({
) )
} }
// Line chart implementation
const defaultLineData = [
{ value: 150 },
{ value: 120 },
{ value: 100 },
{ value: 80 },
{ value: 90 },
{ value: 70 },
{ value: 60 }
]
const lineData = (Array.isArray(data) && data.length > 0)
? data.slice(0, 7)
: defaultLineData
const maxVal = Math.max(...lineData.map(d => d.value || 0), 1)
const width = 400
const height = 200
const padding = 20
const plotHeight = height - 40
const stepX = lineData.length > 1 ? (width - 2 * padding) / (lineData.length - 1) : 0
const points = lineData.map((d, i) => {
const x = padding + i * stepX
const y = height - padding - ((d.value || 0) / maxVal) * plotHeight
return { x, y }
})
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
const areaPath = `${linePath} L${width - padding},${height - padding} L${padding},${height - padding} Z`
return ( return (
<div className={`w-full h-full ${className}`}> <div className={`w-full h-full ${className}`}>
<svg className="w-full h-full" viewBox="0 0 400 200"> <svg className="w-full h-full" viewBox={`0 0 ${width} ${height}`}>
<defs> <defs>
<linearGradient id="detectorGradient" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="detectorGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="rgb(231, 110, 80)" stopOpacity="0.3" /> <stop offset="0%" stopColor="rgb(231, 110, 80)" stopOpacity="0.3" />
@@ -79,22 +118,18 @@ const DetectorChart: React.FC<DetectorChartProps> = ({
</linearGradient> </linearGradient>
</defs> </defs>
<path <path
d="M20,150 L80,120 L140,100 L200,80 L260,90 L320,70 L380,60 L380,180 L20,180 Z" d={areaPath}
fill="url(#detectorGradient)" fill="url(#detectorGradient)"
/> />
<path <path
d="M20,150 L80,120 L140,100 L200,80 L260,90 L320,70 L380,60" d={linePath}
stroke="rgb(231, 110, 80)" stroke="rgb(231, 110, 80)"
strokeWidth="2" strokeWidth="2"
fill="none" fill="none"
/> />
<circle cx="20" cy="150" r="3" fill="rgb(231, 110, 80)" /> {points.map((p, i) => (
<circle cx="80" cy="120" r="3" fill="rgb(231, 110, 80)" /> <circle key={i} cx={p.x} cy={p.y} r="3" fill="rgb(231, 110, 80)" />
<circle cx="140" cy="100" r="3" fill="rgb(231, 110, 80)" /> ))}
<circle cx="200" cy="80" r="3" fill="rgb(231, 110, 80)" />
<circle cx="260" cy="90" r="3" fill="rgb(231, 110, 80)" />
<circle cx="320" cy="70" r="3" fill="rgb(231, 110, 80)" />
<circle cx="380" cy="60" r="3" fill="rgb(231, 110, 80)" />
</svg> </svg>
</div> </div>
) )

View File

@@ -141,7 +141,14 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
}, []) }, [])
useEffect(() => { useEffect(() => {
if (!isInitializedRef.current || !modelPath || isDisposedRef.current) { if (!isInitializedRef.current || isDisposedRef.current) {
return
}
// Check if modelPath is provided
if (!modelPath) {
console.warn('[ModelViewer] No model path provided')
onError?.('Путь к 3D модели не задан')
return return
} }
@@ -209,13 +216,14 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
}, 500) }, 500)
} else { } else {
console.warn('No meshes found in model') console.warn('No meshes found in model')
onError?.('No geometry found in model') onError?.('В модели не найдена геометрия')
setIsLoading(false) setIsLoading(false)
} }
} catch (error) { } catch (error) {
clearInterval(progressInterval) clearInterval(progressInterval)
console.error('Error loading GLTF model:', error) console.error('Error loading GLTF model:', error)
onError?.(`Failed to load model: ${error}`) const errorMessage = error instanceof Error ? error.message : String(error)
onError?.(`Ошибка загрузки модели: ${errorMessage}`)
setIsLoading(false) setIsLoading(false)
} }
} }
@@ -239,7 +247,25 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
} }
const allMeshes = importedMeshesRef.current || [] const allMeshes = importedMeshesRef.current || []
const sensorMeshes = allMeshes.filter((m: any) => ((m.id ?? '').includes('IfcSensor') || (m.name ?? '').includes('IfcSensor')))
// Safeguard: Check if we have any meshes at all
if (allMeshes.length === 0) {
console.warn('[ModelViewer] No meshes available for sensor matching')
highlightLayerRef.current?.removeAllMeshes()
chosenMeshRef.current = null
setOverlayPos(null)
setOverlayData(null)
return
}
const sensorMeshes = allMeshes.filter((m: any) => {
try {
return ((m.id ?? '').includes('IfcSensor') || (m.name ?? '').includes('IfcSensor'))
} catch (error) {
console.warn('[ModelViewer] Error filtering sensor mesh:', error)
return false
}
})
const chosen = sensorMeshes.find((m: any) => { const chosen = sensorMeshes.find((m: any) => {
try { try {
@@ -248,7 +274,8 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
const sid = extras?.Sensor_ID ?? extras?.sensor_id ?? extras?.SERIAL_NUMBER ?? extras?.serial_number const sid = extras?.Sensor_ID ?? extras?.sensor_id ?? extras?.SERIAL_NUMBER ?? extras?.serial_number
if (sid == null) return false if (sid == null) return false
return String(sid).trim() === sensorId return String(sid).trim() === sensorId
} catch { } catch (error) {
console.warn('[ModelViewer] Error matching sensor mesh:', error)
return false return false
} }
}) })
@@ -264,44 +291,52 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
const scene = sceneRef.current! const scene = sceneRef.current!
if (chosen) { if (chosen) {
const camera = scene.activeCamera as ArcRotateCamera try {
const bbox = (typeof chosen.getHierarchyBoundingVectors === 'function') const camera = scene.activeCamera as ArcRotateCamera
? chosen.getHierarchyBoundingVectors() const bbox = (typeof chosen.getHierarchyBoundingVectors === 'function')
: { min: chosen.getBoundingInfo().boundingBox.minimumWorld, max: chosen.getBoundingInfo().boundingBox.maximumWorld } ? chosen.getHierarchyBoundingVectors()
const center = bbox.min.add(bbox.max).scale(0.5) : { min: chosen.getBoundingInfo().boundingBox.minimumWorld, max: chosen.getBoundingInfo().boundingBox.maximumWorld }
const size = bbox.max.subtract(bbox.min) const center = bbox.min.add(bbox.max).scale(0.5)
const maxDimension = Math.max(size.x, size.y, size.z) const size = bbox.max.subtract(bbox.min)
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5) const maxDimension = Math.max(size.x, size.y, size.z)
const targetRadius = Math.max(camera.lowerRadiusLimit ?? 2, maxDimension * 1.5)
scene.stopAnimation(camera) scene.stopAnimation(camera)
const ease = new CubicEase() const ease = new CubicEase()
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT) ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT)
const frameRate = 60 const frameRate = 60
const durationMs = 600 const durationMs = 600
const totalFrames = Math.round((durationMs / 1000) * frameRate) const totalFrames = Math.round((durationMs / 1000) * frameRate)
Animation.CreateAndStartAnimation('camTarget', camera, 'target', frameRate, totalFrames, camera.target.clone(), center.clone(), Animation.ANIMATIONLOOPMODE_CONSTANT, ease) Animation.CreateAndStartAnimation('camTarget', camera, 'target', frameRate, totalFrames, camera.target.clone(), center.clone(), Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
Animation.CreateAndStartAnimation('camRadius', camera, 'radius', frameRate, totalFrames, camera.radius, targetRadius, Animation.ANIMATIONLOOPMODE_CONSTANT, ease) Animation.CreateAndStartAnimation('camRadius', camera, 'radius', frameRate, totalFrames, camera.radius, targetRadius, Animation.ANIMATIONLOOPMODE_CONSTANT, ease)
const hl = highlightLayerRef.current const hl = highlightLayerRef.current
if (hl) { if (hl) {
hl.removeAllMeshes() hl.removeAllMeshes()
if (chosen instanceof Mesh) { if (chosen instanceof Mesh) {
hl.addMesh(chosen, new Color3(1, 1, 0)) hl.addMesh(chosen, new Color3(1, 1, 0))
} else if (chosen instanceof InstancedMesh) { } else if (chosen instanceof InstancedMesh) {
hl.addMesh(chosen.sourceMesh, new Color3(1, 1, 0)) hl.addMesh(chosen.sourceMesh, new Color3(1, 1, 0))
} else { } else {
const children = typeof (chosen as any)?.getChildMeshes === 'function' ? (chosen as any).getChildMeshes() : [] const children = typeof (chosen as any)?.getChildMeshes === 'function' ? (chosen as any).getChildMeshes() : []
for (const cm of children) { for (const cm of children) {
if (cm instanceof Mesh) { if (cm instanceof Mesh) {
hl.addMesh(cm, new Color3(1, 1, 0)) hl.addMesh(cm, new Color3(1, 1, 0))
}
} }
} }
} }
chosenMeshRef.current = chosen
setOverlayData({ name: chosen.name, sensorId })
} catch (error) {
console.error('[ModelViewer] Error focusing on sensor mesh:', error)
highlightLayerRef.current?.removeAllMeshes()
chosenMeshRef.current = null
setOverlayPos(null)
setOverlayData(null)
} }
chosenMeshRef.current = chosen
setOverlayData({ name: chosen.name, sensorId })
} else { } else {
highlightLayerRef.current?.removeAllMeshes() highlightLayerRef.current?.removeAllMeshes()
chosenMeshRef.current = null chosenMeshRef.current = null
@@ -316,15 +351,22 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
const observer = scene.onAfterRenderObservable.add(() => { const observer = scene.onAfterRenderObservable.add(() => {
const chosen = chosenMeshRef.current const chosen = chosenMeshRef.current
if (!chosen) return if (!chosen) return
const engine = scene.getEngine()
const cam = scene.activeCamera try {
if (!cam) return const engine = scene.getEngine()
const center = chosen.getBoundingInfo().boundingBox.centerWorld const cam = scene.activeCamera
const world = Matrix.IdentityReadOnly if (!cam) return
const transform = scene.getTransformMatrix()
const viewport = new Viewport(0, 0, engine.getRenderWidth(), engine.getRenderHeight()) const center = chosen.getBoundingInfo().boundingBox.centerWorld
const projected = Vector3.Project(center, world, transform, viewport) const world = Matrix.IdentityReadOnly
setOverlayPos({ left: projected.x, top: projected.y }) const transform = scene.getTransformMatrix()
const viewport = new Viewport(0, 0, engine.getRenderWidth(), engine.getRenderHeight())
const projected = Vector3.Project(center, world, transform, viewport)
setOverlayPos({ left: projected.x, top: projected.y })
} catch (error) {
console.warn('[ModelViewer] Error updating overlay position:', error)
setOverlayPos(null)
}
}) })
return () => { return () => {
scene.onAfterRenderObservable.remove(observer) scene.onAfterRenderObservable.remove(observer)
@@ -333,20 +375,50 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
return ( return (
<div className="w-full h-screen relative bg-gray-900 overflow-hidden"> <div className="w-full h-screen relative bg-gray-900 overflow-hidden">
<canvas {!modelPath ? (
ref={canvasRef} <div className="h-full flex items-center justify-center">
className={`w-full h-full outline-none block transition-opacity duration-500 ${ <div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md">
showModel ? 'opacity-100' : 'opacity-0' <div className="text-amber-400 text-lg font-semibold mb-4">
}`} 3D модель недоступна
/> </div>
{isLoading && ( <div className="text-gray-300 mb-4">
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-50"> Путь к 3D модели не задан
<LoadingSpinner </div>
progress={loadingProgress} <div className="text-sm text-gray-400">
size={120} Обратитесь к администратору для настройки модели
strokeWidth={8} </div>
/> </div>
</div> </div>
) : (
<>
<canvas
ref={canvasRef}
className={`w-full h-full outline-none block transition-opacity duration-500 ${
showModel ? 'opacity-100' : 'opacity-0'
}`}
/>
{isLoading && (
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-50">
<LoadingSpinner
progress={loadingProgress}
size={120}
strokeWidth={8}
/>
</div>
)}
{!modelReady && !isLoading && (
<div className="absolute inset-0 bg-gray-900 flex items-center justify-center z-40">
<div className="text-center p-8 bg-[#161824] rounded-lg border border-gray-700 max-w-md">
<div className="text-gray-400 text-lg font-semibold mb-4">
3D модель не загружена
</div>
<div className="text-sm text-gray-400">
Модель не готова к отображению
</div>
</div>
</div>
)}
</>
)} )}
{renderOverlay {renderOverlay
? renderOverlay({ anchor: overlayPos, info: overlayData }) ? renderOverlay({ anchor: overlayPos, info: overlayData })
@@ -357,7 +429,8 @@ const ModelViewer: React.FC<ModelViewerProps> = ({
{overlayData.sensorId && <div className="opacity-80">ID: {overlayData.sensorId}</div>} {overlayData.sensorId && <div className="opacity-80">ID: {overlayData.sensorId}</div>}
</div> </div>
</div> </div>
))} ))
}
</div> </div>
) )
} }

View File

@@ -11,6 +11,7 @@ interface FloorNavigationProps {
detectorsData: DetectorsDataType detectorsData: DetectorsDataType
onDetectorMenuClick: (detector: DetectorType) => void onDetectorMenuClick: (detector: DetectorType) => void
onClose?: () => void onClose?: () => void
is3DReady?: boolean
} }
interface DetectorType { interface DetectorType {
@@ -33,7 +34,7 @@ interface DetectorType {
}> }>
} }
const FloorNavigation: React.FC<FloorNavigationProps> = ({ objectId, detectorsData, onDetectorMenuClick, onClose }) => { const FloorNavigation: React.FC<FloorNavigationProps> = ({ objectId, detectorsData, onDetectorMenuClick, onClose, is3DReady = true }) => {
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set()) const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set())
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
@@ -95,6 +96,12 @@ const FloorNavigation: React.FC<FloorNavigationProps> = ({ objectId, detectorsDa
} }
const handleDetectorMenuClick = (detector: DetectorType) => { const handleDetectorMenuClick = (detector: DetectorType) => {
// Проверяем валидность данных детектора перед передачей
if (!detector || !detector.detector_id || !detector.serial_number) {
console.warn('[FloorNavigation] Invalid detector data, skipping menu click:', detector)
return
}
onDetectorMenuClick(detector) onDetectorMenuClick(detector)
} }
@@ -184,10 +191,20 @@ const FloorNavigation: React.FC<FloorNavigationProps> = ({ objectId, detectorsDa
</svg> </svg>
)} )}
<button <button
onClick={() => handleDetectorMenuClick(detector)} onClick={() => {
if (is3DReady) {
handleDetectorMenuClick(detector)
} else {
console.warn('[FloorNavigation] 3D model not ready, skipping detector focus')
}
}}
className="w-6 h-6 bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] rounded-full flex items-center justify-center transition-colors relative" className="w-6 h-6 bg-[rgb(27,29,41)] hover:bg-[rgb(37,39,51)] rounded-full flex items-center justify-center transition-colors relative"
title={is3DReady ? "Показать детектор на 3D модели" : "3D модель недоступна"}
> >
<div className="w-2 h-2 bg-white rounded-full"></div> <div className="w-2 h-2 bg-white rounded-full"></div>
{!is3DReady && (
<div className="absolute -top-1 -right-1 w-3 h-3 bg-amber-500 rounded-full text-[8px] flex items-center justify-center text-black font-bold">!</div>
)}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useState } from 'react';
import Image from 'next/image'; import Image from 'next/image';
interface MonitoringProps { interface MonitoringProps {
@@ -7,6 +7,8 @@ interface MonitoringProps {
} }
const Monitoring: React.FC<MonitoringProps> = ({ onClose }) => { const Monitoring: React.FC<MonitoringProps> = ({ onClose }) => {
const [objectImageError, setObjectImageError] = useState(false);
return ( return (
<div className="w-full"> <div className="w-full">
<div className="bg-[rgb(22,24,36)] rounded-[12px] p-4 space-y-4"> <div className="bg-[rgb(22,24,36)] rounded-[12px] p-4 space-y-4">
@@ -26,18 +28,26 @@ const Monitoring: React.FC<MonitoringProps> = ({ onClose }) => {
<div className="bg-[rgb(158,168,183)] rounded-lg p-3 h-[200px] flex items-center justify-center"> <div className="bg-[rgb(158,168,183)] rounded-lg p-3 h-[200px] flex items-center justify-center">
<div className="w-full h-full bg-gray-300 rounded flex items-center justify-center"> <div className="w-full h-full bg-gray-300 rounded flex items-center justify-center">
<Image {objectImageError ? (
src="/images/test_image.png" <div className="text-center p-4">
alt="Object Model" <div className="text-gray-600 text-sm font-semibold mb-2">
width={200} Предпросмотр 3D недоступен
height={200} </div>
className="max-w-full max-h-full object-contain" <div className="text-gray-500 text-xs">
style={{ height: 'auto' }} Изображение модели не найдено
onError={(e) => { </div>
const target = e.target as HTMLImageElement; </div>
target.style.display = 'none'; ) : (
}} <Image
/> src="/images/test_image.png"
alt="Object Model"
width={200}
height={200}
className="max-w-full max-h-full object-contain"
style={{ height: 'auto' }}
onError={() => setObjectImageError(true)}
/>
)}
</div> </div>
</div> </div>

View File

@@ -6,6 +6,8 @@ import Image from 'next/image'
import useUIStore from '../../app/store/uiStore' import useUIStore from '../../app/store/uiStore'
import useNavigationStore from '../../app/store/navigationStore' import useNavigationStore from '../../app/store/navigationStore'
import { useNavigationService } from '@/services/navigationService' import { useNavigationService } from '@/services/navigationService'
import useUserStore from '../../app/store/userStore'
import { signOut } from 'next-auth/react'
interface NavigationItem { interface NavigationItem {
id: number id: number
@@ -130,8 +132,8 @@ const navigationSubItems: NavigationItem[] = [
const Sidebar: React.FC<SidebarProps> = ({ const Sidebar: React.FC<SidebarProps> = ({
logoSrc, logoSrc,
userInfo = { userInfo = {
name: 'Александр', name: '',
role: 'Администратор' role: ''
}, },
activeItem: propActiveItem, activeItem: propActiveItem,
onCustomItemClick onCustomItemClick
@@ -150,7 +152,35 @@ const Sidebar: React.FC<SidebarProps> = ({
setNavigationSubMenuExpanded: setShowNavigationSubItems, setNavigationSubMenuExpanded: setShowNavigationSubItems,
toggleNavigationSubMenu toggleNavigationSubMenu
} = useUIStore() } = useUIStore()
const { user, logout } = useUserStore()
const roleLabelMap: Record<string, string> = {
engineer: 'Инженер',
operator: 'Оператор',
admin: 'Администратор',
}
const fullName = [user?.name, user?.surname].filter(Boolean).join(' ').trim()
const uiUserInfo = {
name: fullName || user?.login || userInfo?.name || '—',
role: roleLabelMap[(user?.account_type ?? '').toLowerCase()] || userInfo?.role || '—',
avatar: user?.image || userInfo?.avatar,
}
const handleLogout = async () => {
try {
await fetch('/api/auth/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
})
} catch (e) {
console.error('Logout request failed:', e)
} finally {
logout()
await signOut({ redirect: true, callbackUrl: '/login' })
}
}
const { const {
openMonitoring, openMonitoring,
openFloorNavigation, openFloorNavigation,
@@ -401,9 +431,9 @@ const Sidebar: React.FC<SidebarProps> = ({
role="img" role="img"
aria-label="User avatar" aria-label="User avatar"
> >
{userInfo.avatar && ( {uiUserInfo.avatar && (
<Image <Image
src={userInfo.avatar} src={uiUserInfo.avatar}
alt="User avatar" alt="User avatar"
className="w-full h-full rounded-lg object-cover" className="w-full h-full rounded-lg object-cover"
fill fill
@@ -417,21 +447,24 @@ const Sidebar: React.FC<SidebarProps> = ({
<div className="flex p-2 flex-1 grow items-center gap-2 relative rounded-md"> <div className="flex p-2 flex-1 grow items-center gap-2 relative rounded-md">
<div className="flex flex-col items-start justify-center gap-0.5 relative flex-1 grow"> <div className="flex flex-col items-start justify-center gap-0.5 relative flex-1 grow">
<div className="self-stretch mt-[-1.00px] [font-family:'Inter-SemiBold',Helvetica] font-semibold text-white text-sm leading-[14px] relative tracking-[0] overflow-hidden text-ellipsis [display:-webkit-box] [-webkit-line-clamp:1] [-webkit-box-orient:vertical]"> <div className="self-stretch mt-[-1.00px] [font-family:'Inter-SemiBold',Helvetica] font-semibold text-white text-sm leading-[14px] relative tracking-[0] overflow-hidden text-ellipsis [display:-webkit-box] [-webkit-line-clamp:1] [-webkit-box-orient:vertical]">
{userInfo.name} {uiUserInfo.name}
</div> </div>
<div className="self-stretch [font-family:'Inter-Regular',Helvetica] font-normal text-[#71717a] text-[10px] leading-[10px] relative tracking-[0] overflow-hidden text-ellipsis [display:-webkit-box] [-webkit-line-clamp:1] [-webkit-box-orient:vertical]"> <div className="self-stretch [font-family:'Inter-Regular',Helvetica] font-normal text-[#71717a] text-[10px] leading-[10px] relative tracking-[0] overflow-hidden text-ellipsis [display:-webkit-box] [-webkit-line-clamp:1] [-webkit-box-orient:vertical]">
{userInfo.role} {uiUserInfo.role}
</div> </div>
</div> </div>
</div> </div>
<button <button
className="relative w-4 h-4 aspect-[1] p-1 rounded hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-200" className="relative w-4 h-4 aspect-[1] p-1 rounded hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-200"
aria-label="User menu" aria-label="Logout"
title="Выйти"
type="button" type="button"
onClick={handleLogout}
> >
<svg className="w-3.5 h-3.5 text-white" fill="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" /> <path d="M10 17l-5-5 5-5v3h8v4h-8v3z" />
<path d="M20 3h-8v2h8v14h-8v2h8c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z" />
</svg> </svg>
</button> </button>
</footer> </footer>

View File

@@ -41,7 +41,8 @@ interface GoogleToken extends JWT {
async function refreshAccessToken(token: GoogleToken): Promise<GoogleToken> { async function refreshAccessToken(token: GoogleToken): Promise<GoogleToken> {
try { try {
const response = await fetch(`${process.env.BACKEND_URL}/auth/refresh/`, { const BACKEND = process.env.BACKEND_URL || 'http://127.0.0.1:8000/api/v1'
const response = await fetch(`${BACKEND}/auth/refresh/`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -52,8 +53,14 @@ async function refreshAccessToken(token: GoogleToken): Promise<GoogleToken> {
}) })
if (!response.ok) { if (!response.ok) {
const errorData = await response.json() const errorText = await response.text()
throw errorData let errorData: { error?: string; [key: string]: unknown } = {}
try {
errorData = JSON.parse(errorText)
} catch {
errorData = { error: errorText }
}
throw new Error(errorData.error || 'Token refresh failed')
} }
const refreshedTokens = await response.json() const refreshedTokens = await response.json()
@@ -85,7 +92,8 @@ export const authOptions: NextAuthOptions = {
}, },
async authorize(credentials) { async authorize(credentials) {
try { try {
const res = await fetch(`${process.env.BACKEND_URL}/auth/login/`, { const BACKEND = process.env.BACKEND_URL || 'http://127.0.0.1:8000/api/v1'
const res = await fetch(`${BACKEND}/auth/login/`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -94,18 +102,34 @@ export const authOptions: NextAuthOptions = {
}), }),
}) })
const data = await res.json() const raw = await res.text()
let data: {
error?: string;
user?: {
id: string | number;
email: string;
name: string;
};
access?: string;
refresh?: string;
[key: string]: unknown
}
try {
data = JSON.parse(raw)
} catch {
data = { error: raw }
}
if (!res.ok) { if (!res.ok) {
throw new Error(data.error || 'Authentication failed') throw new Error(data.error || `Authentication failed (${res.status})`)
} }
return { return {
id: data.user.id.toString(), id: data.user?.id?.toString?.() ?? String(data.user?.id),
email: data.user.email, email: data.user?.email ?? '',
name: data.user.firstName, name: data.user?.name ?? '', // backend uses `name`, not `firstName`
accessToken: data.access, accessToken: data.access ?? '',
refreshToken: data.refresh, refreshToken: data.refresh ?? '',
} }
} catch (error) { } catch (error) {
console.error('Login error:', error) console.error('Login error:', error)