init
This commit is contained in:
16
transport/frontend/Dockerfile
Normal file
16
transport/frontend/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Expose Vite dev server port
|
||||
EXPOSE 5173
|
||||
|
||||
# Start Vite dev server with host flag for Docker
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
14
transport/frontend/index.html
Normal file
14
transport/frontend/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Мониторинг транспорта</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
23
transport/frontend/package.json
Normal file
23
transport/frontend/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "transport-monitoring-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0",
|
||||
"axios": "^1.6.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"naive-ui": "^2.35.0",
|
||||
"pinia": "^2.1.7",
|
||||
"@vueuse/core": "^10.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.5.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
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>
|
||||
153
transport/frontend/src/components/EventFeed.vue
Normal file
153
transport/frontend/src/components/EventFeed.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<n-card title="События" size="small" class="event-feed">
|
||||
<template #header-extra>
|
||||
<n-select
|
||||
v-model:value="filterType"
|
||||
:options="filterOptions"
|
||||
size="tiny"
|
||||
style="width: 100px"
|
||||
placeholder="Все"
|
||||
clearable
|
||||
/>
|
||||
</template>
|
||||
|
||||
<n-scrollbar style="max-height: 200px">
|
||||
<div
|
||||
v-for="event in filteredEvents"
|
||||
:key="event.id"
|
||||
class="event-item"
|
||||
@click="handleClick(event)"
|
||||
>
|
||||
<div class="event-icon">
|
||||
{{ getIcon(event.type) }}
|
||||
</div>
|
||||
<div class="event-content">
|
||||
<div class="event-title">
|
||||
{{ getTitle(event) }}
|
||||
</div>
|
||||
<div class="event-meta">
|
||||
<span class="event-vehicle">{{ getVehicleName(event.vehicle_id) }}</span>
|
||||
<span class="event-time">{{ formatTime(event.timestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-empty v-if="filteredEvents.length === 0" description="Нет событий" size="small" />
|
||||
</n-scrollbar>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
events: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
vehicles: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select-vehicle'])
|
||||
|
||||
const filterType = ref(null)
|
||||
|
||||
const filterOptions = [
|
||||
{ label: '⚠️ Скорость', value: 'OVERSPEED' },
|
||||
{ label: '⏸️ Остановка', value: 'LONG_STOP' },
|
||||
{ label: '📡 Связь', value: 'CONNECTION_LOST' }
|
||||
]
|
||||
|
||||
const filteredEvents = computed(() => {
|
||||
if (!filterType.value) return props.events
|
||||
return props.events.filter(e => e.type === filterType.value)
|
||||
})
|
||||
|
||||
const getIcon = (type) => {
|
||||
const icons = {
|
||||
OVERSPEED: '⚠️',
|
||||
LONG_STOP: '⏸️',
|
||||
CONNECTION_LOST: '📡'
|
||||
}
|
||||
return icons[type] || '📌'
|
||||
}
|
||||
|
||||
const getTitle = (event) => {
|
||||
const titles = {
|
||||
OVERSPEED: `Превышение: ${event.payload?.speed?.toFixed(0) || '?'} км/ч`,
|
||||
LONG_STOP: `Остановка: ${event.payload?.duration_minutes?.toFixed(0) || '?'} мин`,
|
||||
CONNECTION_LOST: 'Потеря связи'
|
||||
}
|
||||
return titles[event.type] || event.type
|
||||
}
|
||||
|
||||
const getVehicleName = (vehicleId) => {
|
||||
const vehicle = props.vehicles.find(v => v.id === vehicleId)
|
||||
return vehicle?.name || `#${vehicleId}`
|
||||
}
|
||||
|
||||
const formatTime = (timestamp) => {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleTimeString('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const handleClick = (event) => {
|
||||
emit('select-vehicle', event.vehicle_id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.event-feed {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 8px 4px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.event-item:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.event-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.event-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.event-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.event-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.event-vehicle {
|
||||
color: #6b9eff;
|
||||
}
|
||||
</style>
|
||||
204
transport/frontend/src/components/MapView.vue
Normal file
204
transport/frontend/src/components/MapView.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div ref="mapContainer" class="map-container"></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, defineExpose } from 'vue'
|
||||
import L from 'leaflet'
|
||||
|
||||
const props = defineProps({
|
||||
vehicles: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
selectedId: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
track: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select'])
|
||||
|
||||
const mapContainer = ref(null)
|
||||
let map = null
|
||||
const markers = new Map()
|
||||
let trackLine = null
|
||||
|
||||
// Иконки для разных типов и статусов
|
||||
const createIcon = (type, status, isSelected) => {
|
||||
const colors = {
|
||||
moving: '#22c55e',
|
||||
stopped: '#eab308',
|
||||
offline: '#6b7280'
|
||||
}
|
||||
|
||||
const icons = {
|
||||
bus: '🚌',
|
||||
truck: '🚚',
|
||||
car: '🚗'
|
||||
}
|
||||
|
||||
const color = colors[status] || colors.offline
|
||||
const icon = icons[type] || icons.car
|
||||
const size = isSelected ? 36 : 28
|
||||
const border = isSelected ? '3px solid #3b82f6' : '2px solid #fff'
|
||||
|
||||
return L.divIcon({
|
||||
className: 'vehicle-marker',
|
||||
html: `
|
||||
<div style="
|
||||
width: ${size}px;
|
||||
height: ${size}px;
|
||||
background: ${color};
|
||||
border-radius: 50%;
|
||||
border: ${border};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: ${size * 0.5}px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
cursor: pointer;
|
||||
">
|
||||
${icon}
|
||||
</div>
|
||||
`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2]
|
||||
})
|
||||
}
|
||||
|
||||
const initMap = () => {
|
||||
map = L.map(mapContainer.value, {
|
||||
center: [55.0304, 82.9204], // Новосибирск
|
||||
zoom: 13,
|
||||
zoomControl: true
|
||||
})
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(map)
|
||||
}
|
||||
|
||||
const updateMarkers = () => {
|
||||
if (!map) return
|
||||
|
||||
// Обновляем существующие и добавляем новые маркеры
|
||||
props.vehicles.forEach(vehicle => {
|
||||
if (!vehicle.last_position) return
|
||||
|
||||
const { lat, lon } = vehicle.last_position
|
||||
const isSelected = vehicle.id === props.selectedId
|
||||
|
||||
if (markers.has(vehicle.id)) {
|
||||
// Обновляем существующий маркер
|
||||
const marker = markers.get(vehicle.id)
|
||||
marker.setLatLng([lat, lon])
|
||||
marker.setIcon(createIcon(vehicle.type, vehicle.status, isSelected))
|
||||
} else {
|
||||
// Создаём новый маркер
|
||||
const marker = L.marker([lat, lon], {
|
||||
icon: createIcon(vehicle.type, vehicle.status, isSelected)
|
||||
})
|
||||
|
||||
marker.on('click', () => {
|
||||
emit('select', vehicle.id)
|
||||
})
|
||||
|
||||
// Всплывающая подсказка
|
||||
marker.bindTooltip(`
|
||||
<strong>${vehicle.name}</strong><br>
|
||||
Скорость: ${vehicle.last_position.speed.toFixed(1)} км/ч
|
||||
`, { direction: 'top', offset: [0, -10] })
|
||||
|
||||
marker.addTo(map)
|
||||
markers.set(vehicle.id, marker)
|
||||
}
|
||||
})
|
||||
|
||||
// Удаляем маркеры для несуществующих объектов
|
||||
const vehicleIds = new Set(props.vehicles.map(v => v.id))
|
||||
markers.forEach((marker, id) => {
|
||||
if (!vehicleIds.has(id)) {
|
||||
map.removeLayer(marker)
|
||||
markers.delete(id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const updateTrack = () => {
|
||||
if (!map) return
|
||||
|
||||
// Удаляем старый трек
|
||||
if (trackLine) {
|
||||
map.removeLayer(trackLine)
|
||||
trackLine = null
|
||||
}
|
||||
|
||||
// Добавляем новый трек
|
||||
if (props.track && props.track.positions.length > 0) {
|
||||
const points = props.track.positions.map(p => [p.lat, p.lon])
|
||||
|
||||
trackLine = L.polyline(points, {
|
||||
color: '#3b82f6',
|
||||
weight: 4,
|
||||
opacity: 0.8
|
||||
}).addTo(map)
|
||||
|
||||
// Маркеры начала и конца
|
||||
if (points.length > 1) {
|
||||
L.circleMarker(points[0], {
|
||||
radius: 8,
|
||||
color: '#22c55e',
|
||||
fillColor: '#22c55e',
|
||||
fillOpacity: 1
|
||||
}).bindTooltip('Начало').addTo(map)
|
||||
|
||||
L.circleMarker(points[points.length - 1], {
|
||||
radius: 8,
|
||||
color: '#ef4444',
|
||||
fillColor: '#ef4444',
|
||||
fillOpacity: 1
|
||||
}).bindTooltip('Конец').addTo(map)
|
||||
}
|
||||
|
||||
// Подгоняем карту под трек
|
||||
map.fitBounds(trackLine.getBounds(), { padding: [50, 50] })
|
||||
}
|
||||
}
|
||||
|
||||
const centerOn = (lat, lon, zoom = 15) => {
|
||||
if (map) {
|
||||
map.setView([lat, lon], zoom)
|
||||
}
|
||||
}
|
||||
|
||||
// Expose methods
|
||||
defineExpose({ centerOn })
|
||||
|
||||
// Watchers
|
||||
watch(() => props.vehicles, updateMarkers, { deep: true })
|
||||
watch(() => props.selectedId, updateMarkers)
|
||||
watch(() => props.track, updateTrack, { deep: true })
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
initMap()
|
||||
updateMarkers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.map-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.vehicle-marker) {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
155
transport/frontend/src/components/TrackHistory.vue
Normal file
155
transport/frontend/src/components/TrackHistory.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<n-card title="История трека" size="small" class="track-history" v-if="track">
|
||||
<template #header-extra>
|
||||
<n-space>
|
||||
<n-button size="tiny" @click="exportCsv">📥 CSV</n-button>
|
||||
<n-button size="tiny" @click="$emit('close')">✕</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<div class="track-info">
|
||||
<n-tag type="info" size="small">
|
||||
{{ track.positions.length }} точек
|
||||
</n-tag>
|
||||
<n-tag type="success" size="small" v-if="track.positions.length > 0">
|
||||
{{ formatDuration }}
|
||||
</n-tag>
|
||||
</div>
|
||||
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="track.positions"
|
||||
:max-height="200"
|
||||
size="small"
|
||||
:row-key="row => row.id"
|
||||
:row-class-name="getRowClassName"
|
||||
@update:checked-row-keys="handleRowClick"
|
||||
striped
|
||||
/>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, h } from 'vue'
|
||||
import { NTag } from 'naive-ui'
|
||||
|
||||
const props = defineProps({
|
||||
track: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'select-point'])
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'Время',
|
||||
key: 'timestamp',
|
||||
width: 80,
|
||||
render(row) {
|
||||
return formatTime(row.timestamp)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Координаты',
|
||||
key: 'coords',
|
||||
width: 140,
|
||||
render(row) {
|
||||
return `${row.lat.toFixed(4)}, ${row.lon.toFixed(4)}`
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Скорость',
|
||||
key: 'speed',
|
||||
width: 70,
|
||||
render(row) {
|
||||
const speed = row.speed.toFixed(0)
|
||||
const type = row.speed > 60 ? 'error' : row.speed > 0 ? 'success' : 'default'
|
||||
return h(NTag, { size: 'small', type }, { default: () => `${speed}` })
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const formatTime = (timestamp) => {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleTimeString('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const formatDuration = computed(() => {
|
||||
if (!props.track || props.track.positions.length < 2) return ''
|
||||
|
||||
const positions = props.track.positions
|
||||
const start = new Date(positions[0].timestamp)
|
||||
const end = new Date(positions[positions.length - 1].timestamp)
|
||||
const diffMs = end - start
|
||||
const diffMins = Math.round(diffMs / 60000)
|
||||
|
||||
if (diffMins < 60) return `${diffMins} мин`
|
||||
const hours = Math.floor(diffMins / 60)
|
||||
const mins = diffMins % 60
|
||||
return `${hours}ч ${mins}м`
|
||||
})
|
||||
|
||||
const getRowClassName = (row, index) => {
|
||||
return 'track-row'
|
||||
}
|
||||
|
||||
const handleRowClick = (keys) => {
|
||||
// Найти точку по индексу и отправить событие
|
||||
if (keys.length > 0) {
|
||||
const point = props.track.positions.find(p => p.id === keys[0])
|
||||
if (point) {
|
||||
emit('select-point', point)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const exportCsv = () => {
|
||||
if (!props.track || props.track.positions.length === 0) return
|
||||
|
||||
const headers = ['Время', 'Широта', 'Долгота', 'Скорость (км/ч)', 'Направление']
|
||||
const rows = props.track.positions.map(p => [
|
||||
new Date(p.timestamp).toISOString(),
|
||||
p.lat,
|
||||
p.lon,
|
||||
p.speed,
|
||||
p.heading
|
||||
])
|
||||
|
||||
const csv = [headers, ...rows].map(row => row.join(',')).join('\n')
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `track_${props.track.vehicleId}_${new Date().toISOString().slice(0, 10)}.csv`
|
||||
link.click()
|
||||
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.track-history {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.track-info {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
:deep(.track-row) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:deep(.track-row:hover) {
|
||||
background: rgba(59, 130, 246, 0.1) !important;
|
||||
}
|
||||
</style>
|
||||
111
transport/frontend/src/components/VehicleCard.vue
Normal file
111
transport/frontend/src/components/VehicleCard.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<n-card :title="vehicle.name" size="small" class="vehicle-card">
|
||||
<template #header-extra>
|
||||
<n-tag :type="getStatusType(vehicle.status)" size="small">
|
||||
{{ getStatusText(vehicle.status) }}
|
||||
</n-tag>
|
||||
</template>
|
||||
|
||||
<div class="card-content" v-if="vehicle.last_position">
|
||||
<div class="info-row">
|
||||
<span class="label">📍 Координаты:</span>
|
||||
<span class="value">
|
||||
{{ vehicle.last_position.lat.toFixed(5) }},
|
||||
{{ vehicle.last_position.lon.toFixed(5) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<span class="label">🚀 Скорость:</span>
|
||||
<span class="value">{{ vehicle.last_position.speed.toFixed(1) }} км/ч</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<span class="label">🧭 Направление:</span>
|
||||
<span class="value">{{ vehicle.last_position.heading.toFixed(0) }}°</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<span class="label">🕐 Обновлено:</span>
|
||||
<span class="value">{{ formatTime(vehicle.last_position.timestamp) }}</span>
|
||||
</div>
|
||||
|
||||
<n-divider />
|
||||
|
||||
<n-space>
|
||||
<n-button size="small" @click="$emit('show-track', vehicle.id, 30)">
|
||||
Трек 30 мин
|
||||
</n-button>
|
||||
<n-button size="small" @click="$emit('show-track', vehicle.id, 60)">
|
||||
Трек 1 час
|
||||
</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
|
||||
<n-empty v-else description="Нет данных о позиции" size="small" />
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
vehicle: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['show-track'])
|
||||
|
||||
const getStatusType = (status) => {
|
||||
const types = {
|
||||
moving: 'success',
|
||||
stopped: 'warning',
|
||||
offline: 'default'
|
||||
}
|
||||
return types[status] || 'default'
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const texts = {
|
||||
moving: 'Движется',
|
||||
stopped: 'Остановлен',
|
||||
offline: 'Нет связи'
|
||||
}
|
||||
return texts[status] || 'Неизвестно'
|
||||
}
|
||||
|
||||
const formatTime = (timestamp) => {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleTimeString('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vehicle-card {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
121
transport/frontend/src/components/VehicleList.vue
Normal file
121
transport/frontend/src/components/VehicleList.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<n-card title="Транспорт" size="small" class="vehicle-list">
|
||||
<n-scrollbar style="max-height: 250px">
|
||||
<div
|
||||
v-for="vehicle in vehicles"
|
||||
:key="vehicle.id"
|
||||
class="vehicle-item"
|
||||
:class="{ selected: vehicle.id === selectedId }"
|
||||
@click="$emit('select', vehicle.id)"
|
||||
>
|
||||
<div class="vehicle-icon">
|
||||
{{ getIcon(vehicle.type) }}
|
||||
</div>
|
||||
<div class="vehicle-info">
|
||||
<div class="vehicle-name">{{ vehicle.name }}</div>
|
||||
<div class="vehicle-speed" v-if="vehicle.last_position">
|
||||
{{ vehicle.last_position.speed.toFixed(1) }} км/ч
|
||||
</div>
|
||||
<div class="vehicle-speed" v-else>Нет данных</div>
|
||||
</div>
|
||||
<div class="vehicle-status">
|
||||
<n-badge
|
||||
:type="getStatusType(vehicle.status)"
|
||||
:value="getStatusText(vehicle.status)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-empty v-if="vehicles.length === 0" description="Нет объектов" />
|
||||
</n-scrollbar>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
vehicles: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
selectedId: {
|
||||
type: Number,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['select'])
|
||||
|
||||
const getIcon = (type) => {
|
||||
const icons = {
|
||||
bus: '🚌',
|
||||
truck: '🚚',
|
||||
car: '🚗'
|
||||
}
|
||||
return icons[type] || '🚗'
|
||||
}
|
||||
|
||||
const getStatusType = (status) => {
|
||||
const types = {
|
||||
moving: 'success',
|
||||
stopped: 'warning',
|
||||
offline: 'default'
|
||||
}
|
||||
return types[status] || 'default'
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const texts = {
|
||||
moving: 'Едет',
|
||||
stopped: 'Стоит',
|
||||
offline: 'Офлайн'
|
||||
}
|
||||
return texts[status] || 'Неизвестно'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vehicle-list {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vehicle-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.vehicle-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.vehicle-item.selected {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
border: 1px solid rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
.vehicle-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.vehicle-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.vehicle-name {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.vehicle-speed {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.vehicle-status {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
11
transport/frontend/src/main.js
Normal file
11
transport/frontend/src/main.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import naive from 'naive-ui'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(naive)
|
||||
|
||||
app.mount('#app')
|
||||
13
transport/frontend/vite.config.js
Normal file
13
transport/frontend/vite.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
watch: {
|
||||
usePolling: true
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user