This commit is contained in:
2025-12-18 21:13:49 +03:00
parent 84b934036b
commit 030af7ca83
45 changed files with 3106 additions and 0 deletions

View File

@@ -0,0 +1,289 @@
<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>