290 lines
6.8 KiB
Vue
290 lines
6.8 KiB
Vue
<template>
|
|
<n-config-provider :theme="darkTheme">
|
|
<n-layout class="app-layout">
|
|
<!-- Заголовок -->
|
|
<n-layout-header class="app-header">
|
|
<div class="header-content">
|
|
<h1>🚗 Мониторинг транспорта</h1>
|
|
<n-space>
|
|
<n-tag :type="wsConnected ? 'success' : 'error'" size="small">
|
|
{{ wsConnected ? '● Онлайн' : '○ Офлайн' }}
|
|
</n-tag>
|
|
<n-tag type="info" size="small">
|
|
Объектов: {{ vehicles.length }}
|
|
</n-tag>
|
|
</n-space>
|
|
</div>
|
|
</n-layout-header>
|
|
|
|
<n-layout has-sider class="app-content">
|
|
<!-- Боковая панель -->
|
|
<n-layout-sider
|
|
:width="320"
|
|
:collapsed-width="0"
|
|
show-trigger="bar"
|
|
bordered
|
|
>
|
|
<div class="sidebar-content">
|
|
<!-- Поиск -->
|
|
<n-input
|
|
v-model:value="searchQuery"
|
|
placeholder="Поиск по названию..."
|
|
clearable
|
|
class="search-input"
|
|
>
|
|
<template #prefix>
|
|
<span>🔍</span>
|
|
</template>
|
|
</n-input>
|
|
|
|
<!-- Список транспорта -->
|
|
<VehicleList
|
|
:vehicles="filteredVehicles"
|
|
:selected-id="selectedVehicleId"
|
|
@select="selectVehicle"
|
|
/>
|
|
|
|
<!-- Карточка выбранного объекта -->
|
|
<VehicleCard
|
|
v-if="selectedVehicle"
|
|
:vehicle="selectedVehicle"
|
|
@show-track="showTrack"
|
|
/>
|
|
|
|
<!-- История трека -->
|
|
<TrackHistory
|
|
v-if="currentTrack"
|
|
:track="currentTrack"
|
|
@close="currentTrack = null"
|
|
@select-point="centerOnPoint"
|
|
/>
|
|
|
|
<!-- Лента событий -->
|
|
<EventFeed
|
|
:events="recentEvents"
|
|
:vehicles="vehicles"
|
|
@select-vehicle="selectVehicle"
|
|
/>
|
|
</div>
|
|
</n-layout-sider>
|
|
|
|
<!-- Карта -->
|
|
<n-layout-content>
|
|
<MapView
|
|
ref="mapRef"
|
|
:vehicles="vehicles"
|
|
:selected-id="selectedVehicleId"
|
|
:track="currentTrack"
|
|
@select="selectVehicle"
|
|
/>
|
|
</n-layout-content>
|
|
</n-layout>
|
|
</n-layout>
|
|
</n-config-provider>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
import { darkTheme } from 'naive-ui'
|
|
import axios from 'axios'
|
|
import MapView from './components/MapView.vue'
|
|
import VehicleList from './components/VehicleList.vue'
|
|
import VehicleCard from './components/VehicleCard.vue'
|
|
import TrackHistory from './components/TrackHistory.vue'
|
|
import EventFeed from './components/EventFeed.vue'
|
|
|
|
// State
|
|
const vehicles = ref([])
|
|
const selectedVehicleId = ref(null)
|
|
const searchQuery = ref('')
|
|
const wsConnected = ref(false)
|
|
const recentEvents = ref([])
|
|
const currentTrack = ref(null)
|
|
const mapRef = ref(null)
|
|
|
|
let ws = null
|
|
|
|
// Computed
|
|
const filteredVehicles = computed(() => {
|
|
if (!searchQuery.value) return vehicles.value
|
|
const query = searchQuery.value.toLowerCase()
|
|
return vehicles.value.filter(v =>
|
|
v.name.toLowerCase().includes(query)
|
|
)
|
|
})
|
|
|
|
const selectedVehicle = computed(() =>
|
|
vehicles.value.find(v => v.id === selectedVehicleId.value)
|
|
)
|
|
|
|
// Methods
|
|
const fetchVehicles = async () => {
|
|
try {
|
|
const response = await axios.get('/api/vehicles')
|
|
vehicles.value = response.data
|
|
} catch (error) {
|
|
console.error('Failed to fetch vehicles:', error)
|
|
}
|
|
}
|
|
|
|
const fetchEvents = async () => {
|
|
try {
|
|
const response = await axios.get('/api/events', {
|
|
params: { limit: 20 }
|
|
})
|
|
recentEvents.value = response.data
|
|
} catch (error) {
|
|
console.error('Failed to fetch events:', error)
|
|
}
|
|
}
|
|
|
|
const selectVehicle = (id) => {
|
|
selectedVehicleId.value = id
|
|
currentTrack.value = null
|
|
|
|
if (id && mapRef.value) {
|
|
const vehicle = vehicles.value.find(v => v.id === id)
|
|
if (vehicle?.last_position) {
|
|
mapRef.value.centerOn(vehicle.last_position.lat, vehicle.last_position.lon)
|
|
}
|
|
}
|
|
}
|
|
|
|
const showTrack = async (vehicleId, minutes = 30) => {
|
|
try {
|
|
const from = new Date(Date.now() - minutes * 60 * 1000).toISOString()
|
|
const response = await axios.get(`/api/vehicles/${vehicleId}/positions`, {
|
|
params: { from }
|
|
})
|
|
currentTrack.value = {
|
|
vehicleId,
|
|
positions: response.data.reverse() // От старых к новым
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch track:', error)
|
|
}
|
|
}
|
|
|
|
const centerOnPoint = (point) => {
|
|
if (mapRef.value && point) {
|
|
mapRef.value.centerOn(point.lat, point.lon, 16)
|
|
}
|
|
}
|
|
|
|
const connectWebSocket = () => {
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
ws = new WebSocket(`${protocol}//${window.location.host}/ws/positions`)
|
|
|
|
ws.onopen = () => {
|
|
wsConnected.value = true
|
|
console.log('WebSocket connected')
|
|
}
|
|
|
|
ws.onmessage = (event) => {
|
|
const message = JSON.parse(event.data)
|
|
|
|
if (message.type === 'position_update') {
|
|
updateVehiclePosition(message.data)
|
|
} else if (message.type === 'event') {
|
|
addEvent(message.data)
|
|
}
|
|
}
|
|
|
|
ws.onclose = () => {
|
|
wsConnected.value = false
|
|
console.log('WebSocket disconnected, reconnecting...')
|
|
setTimeout(connectWebSocket, 3000)
|
|
}
|
|
|
|
ws.onerror = (error) => {
|
|
console.error('WebSocket error:', error)
|
|
}
|
|
}
|
|
|
|
const updateVehiclePosition = (data) => {
|
|
const vehicle = vehicles.value.find(v => v.id === data.vehicle_id)
|
|
if (vehicle) {
|
|
vehicle.last_position = {
|
|
lat: data.lat,
|
|
lon: data.lon,
|
|
speed: data.speed,
|
|
heading: data.heading,
|
|
timestamp: data.timestamp
|
|
}
|
|
vehicle.status = data.speed > 2 ? 'moving' : 'stopped'
|
|
}
|
|
}
|
|
|
|
const addEvent = (data) => {
|
|
recentEvents.value.unshift(data)
|
|
if (recentEvents.value.length > 20) {
|
|
recentEvents.value.pop()
|
|
}
|
|
}
|
|
|
|
// Lifecycle
|
|
onMounted(() => {
|
|
fetchVehicles()
|
|
fetchEvents()
|
|
connectWebSocket()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (ws) {
|
|
ws.close()
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
html, body, #app {
|
|
height: 100%;
|
|
width: 100%;
|
|
}
|
|
|
|
.app-layout {
|
|
height: 100vh;
|
|
}
|
|
|
|
.app-header {
|
|
padding: 12px 20px;
|
|
background: #1e1e2e;
|
|
border-bottom: 1px solid #333;
|
|
}
|
|
|
|
.header-content {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.header-content h1 {
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
color: #fff;
|
|
}
|
|
|
|
.app-content {
|
|
height: calc(100vh - 56px);
|
|
}
|
|
|
|
.sidebar-content {
|
|
padding: 12px;
|
|
height: 100%;
|
|
overflow-y: auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.search-input {
|
|
flex-shrink: 0;
|
|
}
|
|
</style>
|