init
This commit is contained in:
289
transport/frontend/src/App.vue
Normal file
289
transport/frontend/src/App.vue
Normal 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>
|
||||
Reference in New Issue
Block a user