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,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"]

View 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>

View 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"
}
}

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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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')

View 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
}
}
})