Browse Source

feat: 完善通信卡与运营套餐管理

main
root 2 weeks ago
parent
commit
5ad820af0a
  1. 227
      src/components/DockEditModal.vue
  2. 197
      src/config/urls.js
  3. 840
      src/stores/modules/devicesStore.js
  4. 2142
      src/views/AccountView/AccountView.vue
  5. 1442
      src/views/DeviceDetailView/DeviceDetailView.vue
  6. 1658
      src/views/DevicesView/DevicesView.vue
  7. 2048
      src/views/TrafficOpsView/TrafficOpsView.vue

227
src/components/DockEditModal.vue

@ -1,114 +1,113 @@
<template>
<t-dialog
:visible="visible"
header="编辑机巢"
width="520px"
destroy-on-close
dialog-class-name="add-dock-dialog"
placement="center"
:close-on-overlay-click="!saving"
:close-on-esc-keydown="!saving"
:close-btn="!saving"
@update:visible="onVisibleUpdate"
@close="close"
>
<form novalidate @submit.prevent="submit">
<label><span>机巢名称<b>*</b></span><t-input v-model="form.name" :maxlength="64" placeholder="例如:青山湖 03 号机巢" /></label>
<label><span>机巢 SN</span><t-input :model-value="dock?.sn || ''" readonly /></label>
<label><span>通信卡 ICCID</span><t-input :model-value="dock?.iccid || ''" readonly /></label>
<div class="form-row">
<label><span>经度<b>*</b></span><t-input-number v-model="form.longitude" theme="normal" :min="-180" :max="180" :step="0.000001" placeholder="例如:120.219375" style="width: 100%" /></label>
<label><span>纬度<b>*</b></span><t-input-number v-model="form.latitude" theme="normal" :min="-90" :max="90" :step="0.000001" placeholder="例如:30.259244" style="width: 100%" /></label>
</div>
<label><span>设备位置<em>选填</em></span><t-input v-model="form.location" :maxlength="128" placeholder="例如:青山湖科技城 C 区" /></label>
<div class="form-error">{{ error }}</div>
</form>
<template #footer>
<t-button variant="outline" type="button" :disabled="saving" @click="close">取消</t-button>
<t-button theme="primary" type="button" :disabled="saving" :loading="saving" @click="submit">{{ saving ? '保存中' : '保存修改' }}</t-button>
</template>
</t-dialog>
</template>
<script setup>
import { reactive, ref, watch } from 'vue'
import request from '@/utils/http'
import * as urls from '@/config/urls'
const props = defineProps({
visible: Boolean,
dock: { type: Object, default: null }
})
const emit = defineEmits(['close', 'saved'])
const form = reactive({ name: '', longitude: undefined, latitude: undefined, location: '' })
const error = ref('')
const saving = ref(false)
watch(() => props.visible, (visible) => {
if (!visible || !props.dock) return
Object.assign(form, {
name: props.dock.name || '',
longitude: props.dock.longitude == null || props.dock.longitude === '' ? undefined : Number(props.dock.longitude),
latitude: props.dock.latitude == null || props.dock.latitude === '' ? undefined : Number(props.dock.latitude),
location: props.dock.location === '--' ? '' : props.dock.location || ''
})
error.value = ''
})
function close() {
if (!saving.value) emit('close')
}
function onVisibleUpdate(next) {
if (!next) close()
}
async function submit() {
const name = form.name.trim()
const longitude = Number(form.longitude)
const latitude = Number(form.latitude)
error.value = ''
if (!name || form.longitude === '' || form.longitude == null || form.latitude === '' || form.latitude == null) {
error.value = '请填写机巢名称、经度和纬度。'
return
}
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
error.value = '经度应在 -180~180 之间。'
return
}
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) {
error.value = '纬度应在 -90~90 之间。'
return
}
if (!props.dock?.id) return
saving.value = true
try {
await request.put(urls.DOCK(props.dock.id), {
name,
longitude,
latitude,
location: form.location.trim()
})
emit('saved')
} catch (e) {
error.value = e.message || '保存失败'
} finally {
saving.value = false
}
}
</script>
<style>
.add-dock-dialog .t-input,
.add-dock-dialog .t-input-number {
width: 100%;
}
.add-dock-dialog .t-dialog__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
<template>
<t-dialog
:visible="visible"
header="编辑机巢"
width="520px"
destroy-on-close
dialog-class-name="add-dock-dialog"
placement="center"
:close-on-overlay-click="!saving"
:close-on-esc-keydown="!saving"
:close-btn="!saving"
@update:visible="onVisibleUpdate"
@close="close"
>
<form novalidate @submit.prevent="submit">
<label><span>机巢名称<b>*</b></span><t-input v-model="form.name" :maxlength="64" placeholder="例如:青山湖 03 号机巢" /></label>
<label><span>机巢 SN</span><t-input :model-value="dock?.sn || ''" readonly /></label>
<div class="form-row">
<label><span>经度<b>*</b></span><t-input-number v-model="form.longitude" theme="normal" :min="-180" :max="180" :step="0.000001" placeholder="例如:120.219375" style="width: 100%" /></label>
<label><span>纬度<b>*</b></span><t-input-number v-model="form.latitude" theme="normal" :min="-90" :max="90" :step="0.000001" placeholder="例如:30.259244" style="width: 100%" /></label>
</div>
<label><span>设备位置<em>选填</em></span><t-input v-model="form.location" :maxlength="128" placeholder="例如:青山湖科技城 C 区" /></label>
<div class="form-error">{{ error }}</div>
</form>
<template #footer>
<t-button variant="outline" type="button" :disabled="saving" @click="close">取消</t-button>
<t-button theme="primary" type="button" :disabled="saving" :loading="saving" @click="submit">{{ saving ? '保存中' : '保存修改' }}</t-button>
</template>
</t-dialog>
</template>
<script setup>
import { reactive, ref, watch } from 'vue'
import request from '@/utils/http'
import * as urls from '@/config/urls'
const props = defineProps({
visible: Boolean,
dock: { type: Object, default: null }
})
const emit = defineEmits(['close', 'saved'])
const form = reactive({ name: '', longitude: undefined, latitude: undefined, location: '' })
const error = ref('')
const saving = ref(false)
watch(() => props.visible, (visible) => {
if (!visible || !props.dock) return
Object.assign(form, {
name: props.dock.name || '',
longitude: props.dock.longitude == null || props.dock.longitude === '' ? undefined : Number(props.dock.longitude),
latitude: props.dock.latitude == null || props.dock.latitude === '' ? undefined : Number(props.dock.latitude),
location: props.dock.location === '--' ? '' : props.dock.location || ''
})
error.value = ''
})
function close() {
if (!saving.value) emit('close')
}
function onVisibleUpdate(next) {
if (!next) close()
}
async function submit() {
const name = form.name.trim()
const longitude = Number(form.longitude)
const latitude = Number(form.latitude)
error.value = ''
if (!name || form.longitude === '' || form.longitude == null || form.latitude === '' || form.latitude == null) {
error.value = '请填写机巢名称、经度和纬度。'
return
}
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
error.value = '经度应在 -180~180 之间。'
return
}
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) {
error.value = '纬度应在 -90~90 之间。'
return
}
if (!props.dock?.id) return
saving.value = true
try {
await request.put(urls.DOCK(props.dock.id), {
name,
longitude,
latitude,
location: form.location.trim()
})
emit('saved')
} catch (e) {
error.value = e.message || '保存失败'
} finally {
saving.value = false
}
}
</script>
<style>
.add-dock-dialog .t-input,
.add-dock-dialog .t-input-number {
width: 100%;
}
.add-dock-dialog .t-dialog__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>

197
src/config/urls.js

@ -1,96 +1,101 @@
/** API 路径集中配置。axios baseURL 已是 /api,此处用相对路径。 */
export const BASE_URL = '/api'
// Auth
export const AUTH_CAPTCHA = '/v1/auth/captcha'
export const AUTH_LOGIN = '/v1/auth/login'
export const AUTH_REGISTER = '/v1/auth/register'
export const AUTH_SMS_CODE = '/v1/auth/sms-code'
export const AUTH_RESET_PASSWORD = '/v1/auth/reset-password'
export const AUTH_REFRESH = '/v1/auth/refresh'
export const AUTH_LOGOUT = '/v1/auth/logout'
// Docks
export const DOCKS = '/v1/docks'
export const DOCK = (id) => `/v1/docks/${id}`
export const DOCK_COMMAND = (id) => `/v1/docks/${id}/command`
export const DOCK_FIRMWARE_UPGRADE = (id) => `/v1/docks/${id}/firmware/upgrade`
// Drones
export const DRONES = '/v1/drones'
export const DRONE = (id) => `/v1/drones/${id}`
// Tasks
export const TASKS = '/v1/tasks'
export const TASK = (id) => `/v1/tasks/${id}`
export const TASK_EXECUTE = (id) => `/v1/tasks/${id}/execute`
// Executions
export const EXECUTIONS = '/v1/executions'
export const EXECUTION = (key) => `/v1/executions/${key}`
export const EXECUTION_TRAJECTORY = (key) => `/v1/executions/${key}/trajectory`
// Routes
export const ROUTES = '/v1/routes'
export const ROUTE = (id) => `/v1/routes/${id}`
// Live / Videos
export const LIVE = '/v1/live'
export const LIVE_SESSIONS = (dockId) => `/v1/live/${dockId}/sessions`
export const LIVE_PLAY_URL = (dockId) => `/v1/live/${dockId}/play-url`
export const LIVE_HEARTBEAT = (dockId, sessionId) => `/v1/live/${dockId}/sessions/${sessionId}/heartbeat`
export const LIVE_LEAVE = (dockId, sessionId) => `/v1/live/${dockId}/sessions/${sessionId}/viewers/me`
export const LIVE_SESSION = (dockId, sessionId) => `/v1/live/${dockId}/sessions/${sessionId}`
export const LIVE_STOP = (dockId) => `/v1/live/${dockId}/stop`
export const VIDEOS = '/v1/videos'
export const VIDEO_DOWNLOAD = (id) => `/v1/videos/${id}/download`
// Alarms
export const ALARMS = '/v1/alarms'
export const ALARM_RESOLVE = (id) => `/v1/alarms/${id}/resolve`
// Commands
export const COMMANDS = '/v1/commands'
export const COMMAND = (id) => `/v1/commands/${id}`
// Account
export const ACCOUNT_PROFILE = '/v1/account/profile'
export const TRAFFIC_BALANCE = '/v1/account/traffic/balance'
export const TRAFFIC_PACKAGES = '/v1/account/traffic-packages'
export const TRAFFIC_ORDERS = '/v1/account/traffic/orders'
export const TRAFFIC_ORDER = (id) => `/v1/account/traffic/orders/${id}`
export const TRAFFIC_USAGE = '/v1/account/traffic/usage'
export const ACCOUNT_RESOURCE_SUMMARY = '/v1/account/resource-summary'
export const ACCOUNT_DOWNLOADS = '/v1/account/downloads'
export const ACCOUNT_CHANGE_PASSWORD = '/v1/account/security/change-password'
export const ACCOUNT_SESSIONS = '/v1/account/security/sessions'
export const INVOICE_PROFILES = '/v1/account/invoice-profiles'
export const INVOICE_PROFILE = (id) => `/v1/account/invoice-profiles/${id}`
export const INVOICE_ORDERS = '/v1/account/invoice-orders'
export const INVOICE_REQUESTS = '/v1/account/invoice-requests'
export const PAYMENTS = '/v1/account/payments'
export const PAYMENT = (id) => `/v1/account/payments/${id}`
export const SIM_CARDS = '/v1/account/sim-cards'
export const SIM_CARD = (id) => `/v1/account/sim-cards/${id}`
export const SIM_PACKAGES = '/v1/account/sim-packages'
export const SIM_CARD_RECHARGE_ORDERS = (id) => `/v1/account/sim-cards/${id}/recharge-orders`
export const SIM_RECHARGE_ORDER = (id) => `/v1/account/sim-recharge-orders/${id}`
export const SIM_RECHARGE_LOGS = '/v1/account/sim-recharge-logs'
// Admin traffic ops
export const ADMIN_TRAFFIC_POOL = '/v1/admin/traffic/pool'
export const ADMIN_TRAFFIC_PURCHASES = '/v1/admin/traffic/purchases'
export const ADMIN_TRAFFIC_PACKAGES = '/v1/admin/traffic/packages'
export const ADMIN_TRAFFIC_PACKAGE = (id) => `/v1/admin/traffic/packages/${id}`
export const ADMIN_TRAFFIC_LEDGER = '/v1/admin/traffic/ledger'
export const ADMIN_TRAFFIC_TEST_GRANTS = '/v1/admin/traffic/test-grants'
// Users
export const USERS = '/v1/users'
// Firmwares
export const FIRMWARES = '/v1/firmwares'
export const FIRMWARES_RELEASED = '/v1/firmwares/released'
export const FIRMWARE = (id) => `/v1/firmwares/${id}`
// Health
export const HEALTH = '/health'
/** API 路径集中配置。axios baseURL 已是 /api,此处用相对路径。 */
export const BASE_URL = '/api'
// Auth
export const AUTH_CAPTCHA = '/v1/auth/captcha'
export const AUTH_LOGIN = '/v1/auth/login'
export const AUTH_REGISTER = '/v1/auth/register'
export const AUTH_SMS_CODE = '/v1/auth/sms-code'
export const AUTH_RESET_PASSWORD = '/v1/auth/reset-password'
export const AUTH_REFRESH = '/v1/auth/refresh'
export const AUTH_LOGOUT = '/v1/auth/logout'
// Docks
export const DOCKS = '/v1/docks'
export const DOCK = (id) => `/v1/docks/${id}`
export const DOCK_COMMAND = (id) => `/v1/docks/${id}/command`
export const DOCK_FIRMWARE_UPGRADE = (id) => `/v1/docks/${id}/firmware/upgrade`
// Drones
export const DRONES = '/v1/drones'
export const DRONE = (id) => `/v1/drones/${id}`
// Tasks
export const TASKS = '/v1/tasks'
export const TASK = (id) => `/v1/tasks/${id}`
export const TASK_EXECUTE = (id) => `/v1/tasks/${id}/execute`
// Executions
export const EXECUTIONS = '/v1/executions'
export const EXECUTION = (key) => `/v1/executions/${key}`
export const EXECUTION_TRAJECTORY = (key) => `/v1/executions/${key}/trajectory`
// Routes
export const ROUTES = '/v1/routes'
export const ROUTE = (id) => `/v1/routes/${id}`
// Live / Videos
export const LIVE = '/v1/live'
export const LIVE_SESSIONS = (dockId) => `/v1/live/${dockId}/sessions`
export const LIVE_PLAY_URL = (dockId) => `/v1/live/${dockId}/play-url`
export const LIVE_HEARTBEAT = (dockId, sessionId) => `/v1/live/${dockId}/sessions/${sessionId}/heartbeat`
export const LIVE_LEAVE = (dockId, sessionId) => `/v1/live/${dockId}/sessions/${sessionId}/viewers/me`
export const LIVE_SESSION = (dockId, sessionId) => `/v1/live/${dockId}/sessions/${sessionId}`
export const LIVE_STOP = (dockId) => `/v1/live/${dockId}/stop`
export const VIDEOS = '/v1/videos'
export const VIDEO_DOWNLOAD = (id) => `/v1/videos/${id}/download`
// Alarms
export const ALARMS = '/v1/alarms'
export const ALARM_RESOLVE = (id) => `/v1/alarms/${id}/resolve`
// Commands
export const COMMANDS = '/v1/commands'
export const COMMAND = (id) => `/v1/commands/${id}`
// Account
export const ACCOUNT_PROFILE = '/v1/account/profile'
export const TRAFFIC_BALANCE = '/v1/account/traffic/balance'
export const TRAFFIC_PACKAGES = '/v1/account/traffic-packages'
export const TRAFFIC_ORDERS = '/v1/account/traffic/orders'
export const TRAFFIC_ORDER = (id) => `/v1/account/traffic/orders/${id}`
export const TRAFFIC_USAGE = '/v1/account/traffic/usage'
export const ACCOUNT_RESOURCE_SUMMARY = '/v1/account/resource-summary'
export const ACCOUNT_DOWNLOADS = '/v1/account/downloads'
export const ACCOUNT_CHANGE_PASSWORD = '/v1/account/security/change-password'
export const ACCOUNT_SESSIONS = '/v1/account/security/sessions'
export const INVOICE_PROFILES = '/v1/account/invoice-profiles'
export const INVOICE_PROFILE = (id) => `/v1/account/invoice-profiles/${id}`
export const INVOICE_ORDERS = '/v1/account/invoice-orders'
export const INVOICE_REQUESTS = '/v1/account/invoice-requests'
export const PAYMENTS = '/v1/account/payments'
export const PAYMENT = (id) => `/v1/account/payments/${id}`
export const SIM_CARDS = '/v1/account/sim-cards'
export const SIM_CARD_CREATE = '/v1/account/sim-cards'
export const SIM_CARD = (id) => `/v1/account/sim-cards/${id}`
export const SIM_PACKAGES = '/v1/account/sim-packages'
export const SIM_CARD_RECHARGE_ORDERS = (id) => `/v1/account/sim-cards/${id}/recharge-orders`
export const SIM_RECHARGE_ORDER = (id) => `/v1/account/sim-recharge-orders/${id}`
export const SIM_RECHARGE_LOGS = '/v1/account/sim-recharge-logs'
// Admin traffic ops
export const ADMIN_TRAFFIC_POOL = '/v1/admin/traffic/pool'
export const ADMIN_TRAFFIC_PURCHASES = '/v1/admin/traffic/purchases'
export const ADMIN_TRAFFIC_PACKAGES = '/v1/admin/traffic/packages'
export const ADMIN_TRAFFIC_PACKAGE = (id) => `/v1/admin/traffic/packages/${id}`
export const ADMIN_TRAFFIC_LEDGER = '/v1/admin/traffic/ledger'
export const ADMIN_TRAFFIC_TEST_GRANTS = '/v1/admin/traffic/test-grants'
// Sim 通信卡套餐管理
export const ADMIN_SIM_PACKAGES = '/v1/admin/sim-packages'
export const ADMIN_SIM_PROVIDER_PLANS = '/v1/admin/sim-packages/provider-plans'
export const ADMIN_SIM_PACKAGE = (id) => `/v1/admin/sim-packages/${id}`
export const USERS = '/v1/users'
// Firmwares
export const FIRMWARES = '/v1/firmwares'
export const FIRMWARES_RELEASED = '/v1/firmwares/released'
export const FIRMWARE = (id) => `/v1/firmwares/${id}`
// Health
export const HEALTH = '/health'

840
src/stores/modules/devicesStore.js

@ -1,420 +1,420 @@
import { defineStore } from 'pinia'
import { reactive, ref } from 'vue'
import request from '@/utils/http'
import * as urls from '@/config/urls'
// 后端 status 值 → 展示文案 + 状态样式
const DOCK_STATUS = {
online: { status: '在线', statusClass: 'online' },
offline: { status: '离线', statusClass: 'offline' }
}
function fmtTime(t) {
if (!t) return '--'
const d = new Date(t)
if (Number.isNaN(d.getTime())) return '--'
return d.toLocaleString('zh-CN', { hour12: false })
}
function gpsText(q) {
const key = String(q || '').toUpperCase().replace(/-/g, '_')
switch (key) {
case 'RTK_FIX':
case 'RTK_FIXED': return 'RTK 固定解'
case 'RTK_FLOAT': return 'RTK 浮点解'
case '3D_FIX': return '3D 定位'
case '2D_FIX': return '2D 定位'
default: return q ? String(q) : '无定位'
}
}
function modeText(m) {
const key = String(m || '').toUpperCase()
switch (key) {
case 'STANDBY': return '待命'
case 'AUTO': return 'AUTO'
case 'GUIDED': return 'GUIDED'
case 'LOITER': return '悬停'
case 'RTL': return '返航'
default: return m || '--'
}
}
function controlModeText(m) {
const key = String(m || '').toLowerCase()
if (key === 'auto' || key === 'automatic') return '自动'
if (key === 'manual') return '手动'
return m || '--'
}
function parseJson(value, fallback = null) {
if (!value) return fallback
if (typeof value === 'object') return value
try { return JSON.parse(value) } catch { return fallback }
}
function environmentValues(realtime) {
const nested = parseJson(realtime?.environment, {}) || {}
const value = (key, ...aliases) => {
for (const name of [key, ...aliases]) {
if (nested[name] != null && nested[name] !== '') return nested[name]
if (realtime?.[name] != null && realtime[name] !== '') return realtime[name]
}
return null
}
return {
rain: value('rain'),
rainfall: value('rainfall', 'rainAmount', 'rainFall', 'precipitation'),
windSpeed: value('windSpeed', 'wind_speed'),
outsideTemperature: value('outsideTemperature', 'outside_temperature'),
outsideHumidity: value('outsideHumidity', 'outside_humidity'),
insideTemperature: value('insideTemperature', 'inside_temperature', 'temperature'),
insideHumidity: value('insideHumidity', 'inside_humidity', 'humidity')
}
}
function alarmCodes(realtime) {
const codes = parseJson(realtime?.alarmCodes, [])
return Array.isArray(codes) ? codes : []
}
function displayValue(value) {
return value == null || value === '' ? '--' : String(value)
}
function coordinate(value, min, max) {
const number = Number(value)
return Number.isFinite(number) && number >= min && number <= max ? number : null
}
// 指令标题 → 后端指令 type
const COMMANDS = {
'一键起飞': 'workflow.one_key_takeoff',
'一键降落': 'workflow.one_key_land',
'起飞准备': 'dock.prepare_takeoff',
'起飞准备完成': 'dock.complete_takeoff',
'降落准备': 'dock.prepare_landing',
'降落准备完成': 'dock.complete_landing',
'打开舱门': 'dock.open',
'关闭舱门': 'dock.close',
'开始充电': 'dock.charge_start',
'整机复位': 'dock.reset',
'机巢复位': 'dock.reset',
'无人机开机': 'dock.drone_power_on',
'无人机关机': 'dock.drone_power_off',
'远程重启': 'dock.reboot',
'清除设备告警': 'dock.clear_alarm',
'设备自检': 'dock.self_check',
'起飞': 'drone.takeoff',
'降落': 'drone.land',
'一键返航': 'drone.return',
'悬停': 'drone.hover',
'紧急停止': 'drone.emergency_stop',
'开始任务': 'drone.mission_start',
'暂停任务': 'drone.mission_pause',
'继续任务': 'drone.mission_resume',
'取消任务': 'drone.mission_cancel'
}
// 经纬度 → 地图百分比坐标(按设备经纬范围动态投影,北向上)
function project(lon, lat, bounds) {
const lonSpan = bounds.maxLon - bounds.minLon || 1
const latSpan = bounds.maxLat - bounds.minLat || 1
const x = ((lon - bounds.minLon) / lonSpan) * 80 + 10
const y = ((bounds.maxLat - lat) / latSpan) * 70 + 15
return { x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 }
}
function dockStatusFromDetail(registerStatus, online, rt) {
const pending = registerStatus === 'pending' && !online
if (pending) return { status: '待连接', statusClass: 'pending' }
if (!online) return DOCK_STATUS.offline
if (alarmCodes(rt).length) return { status: '告警', statusClass: 'alarm' }
return DOCK_STATUS.online
}
function droneStatusFromDetail(online, rt) {
const armed = ['1', 'true', 1, true].includes(rt.armed)
const altitude = coordinate(rt.altitude, -1000, 100000)
const flying = armed || (altitude != null && altitude > 0)
if (!online) return { status: '离线', statusClass: 'offline', altitude, flying }
if (flying) return { status: '飞行中', statusClass: 'mission', altitude, flying }
return { status: '在巢', statusClass: 'online', altitude, flying }
}
export const useDeviceStore = defineStore('devices', () => {
const assets = reactive({})
const docks = ref([])
const drones = ref([])
const loaded = ref(false)
function asset(id) {
return assets[id] || null
}
function record(id) {
const key = String(id)
return docks.value.find((d) => d.id === key) || drones.value.find((d) => d.id === key) || null
}
function commandType(title) {
return COMMANDS[title] || ''
}
async function sendCommand(rec, title, params = {}) {
const type = COMMANDS[title]
if (!type) throw new Error('未知指令')
if (rec.kind === 'dock') {
return request.post(urls.DOCK_COMMAND(rec.id), { type, params })
}
const dock = docks.value.find((d) => d.dockId === rec.dockId)
if (!dock) throw new Error('未找到绑定机巢')
return request.post(urls.DOCK_COMMAND(dock.id), { type, params, droneSn: rec.droneSn })
}
async function load(options = {}) {
const silent = Boolean(options.silent)
const reqOpts = silent ? { skipErrorToast: true } : {}
const [dockPage, dronePage] = await Promise.all([
request.get(urls.DOCKS, { params: { pageNum: 1, pageSize: 100 }, ...reqOpts }),
request.get(urls.DRONES, { params: { pageNum: 1, pageSize: 100 }, ...reqOpts })
])
const dockList = dockPage?.records || []
const droneList = dronePage?.records || []
const [dockDetails, droneDetails] = await Promise.all([
Promise.all(dockList.map((d) => request.get(urls.DOCK(d.id), reqOpts).then((r) => r || {}).catch(() => ({})))),
Promise.all(droneList.map((d) => request.get(urls.DRONE(d.id), reqOpts).then((r) => r || {}).catch(() => ({}))))
])
const coords = dockList
.map((d) => ({ longitude: coordinate(d.longitude, -180, 180), latitude: coordinate(d.latitude, -90, 90) }))
.filter((d) => d.longitude != null && d.latitude != null)
const defaultLon = coords[0]?.longitude ?? 0
const defaultLat = coords[0]?.latitude ?? 0
const bounds = {
minLon: Math.min(...coords.map((d) => Number(d.longitude)), defaultLon),
maxLon: Math.max(...coords.map((d) => Number(d.longitude)), defaultLon),
minLat: Math.min(...coords.map((d) => Number(d.latitude)), defaultLat),
maxLat: Math.max(...coords.map((d) => Number(d.latitude)), defaultLat)
}
if (bounds.minLon === bounds.maxLon) { bounds.minLon -= 0.01; bounds.maxLon += 0.01 }
if (bounds.minLat === bounds.maxLat) { bounds.minLat -= 0.01; bounds.maxLat += 0.01 }
const newDocks = dockList.map((d, i) => {
const id = String(d.id)
const detail = dockDetails[i] || {}
const rt = detail.realtime || {}
const online = Boolean(detail.online)
const registerStatus = d.registerStatus || 'registered'
const st = dockStatusFromDetail(registerStatus, online, rt)
const name = d.name || d.dockId
const environment = environmentValues(rt)
return {
id,
kind: 'dock',
dockId: d.dockId,
name,
code: d.code || d.sn || d.dockId,
sn: d.sn || d.dockId || '--',
iccid: d.iccid || '--',
longitude: coordinate(d.longitude, -180, 180),
latitude: coordinate(d.latitude, -90, 90),
location: d.location || '--',
status: st.status,
statusClass: st.statusClass,
binding: '--',
bindingState: 'unbound',
mode: controlModeText(rt.controlMode),
version: d.softwareVer ? `v${d.softwareVer}` : '--',
updated: fmtTime(d.updatedAt),
protocol: d.protocolVer ? `MQTT v${d.protocolVer}` : '--',
registerStatus,
realtime: rt,
environment,
alarms: alarmCodes(rt),
online
}
})
const newDrones = droneList.map((d, i) => {
const id = String(d.id)
const detail = droneDetails[i] || {}
const rt = detail.realtime || {}
const online = Boolean(detail.online)
const st = droneStatusFromDetail(online, rt)
const altitude = st.altitude
const dock = newDocks.find((dk) => dk.dockId === d.dockId)
const battery = rt.batteryPercent ?? rt.batteryPct ?? d.battery
return {
id,
kind: 'drone',
droneSn: d.droneSn,
dockId: d.dockId,
name: d.name || d.droneSn,
code: d.code || d.droneSn,
status: st.status,
statusClass: st.statusClass,
binding: dock?.name || '--',
bindingState: dock ? 'bound' : 'unbound',
battery: battery != null && battery !== '' ? `${battery}%` : '--',
mode: modeText(rt.flightMode),
mission: displayValue(rt.missionName || rt.mission),
updated: fmtTime(d.updatedAt),
sysid: displayValue(rt.currentSysId),
gps: gpsText(rt.gpsQuality),
sn: d.droneSn || d.code || '--',
model: d.model || '--',
firmwareVer: d.firmwareVer || '--',
localRoutes: [],
realtime: rt,
alarms: alarmCodes(rt),
online,
_lat: coordinate(rt.latitude, -90, 90),
_lon: coordinate(rt.longitude, -180, 180),
_alt: altitude,
_speed: coordinate(rt.groundSpeed, 0, 1000),
_sats: coordinate(rt.satellites, 0, 1000)
}
})
for (const dk of newDocks) {
const dr = newDrones.find((d) => d.dockId === dk.dockId)
if (dr) {
dk.binding = dr.name
dk.bindingState = 'bound'
}
}
docks.value = newDocks
drones.value = newDrones
// 重建地图资产
Object.keys(assets).forEach((k) => delete assets[k])
for (const dk of newDocks) {
const hasCoordinates = dk.longitude != null && dk.latitude != null
const p = hasCoordinates ? project(dk.longitude, dk.latitude, bounds) : { x: null, y: null }
assets[dk.id] = {
id: dk.id, type: 'dock', name: dk.name, location: dk.location,
status: dk.status, statusClass: dk.statusClass, x: p.x, y: p.y, hasCoordinates,
longitude: hasCoordinates ? dk.longitude : null,
latitude: hasCoordinates ? dk.latitude : null,
realtime: dk.realtime, environment: dk.environment, alarms: dk.alarms, online: dk.online
}
}
for (const dr of newDrones) {
const dock = newDocks.find((d) => d.dockId === dr.dockId)
const lon = dr._lon != null ? dr._lon : dock?.longitude
const lat = dr._lat != null ? dr._lat : dock?.latitude
const hasCoordinates = lon != null && lat != null
const p = hasCoordinates ? project(lon, lat, bounds) : { x: null, y: null }
assets[dr.id] = {
id: dr.id, type: 'drone', name: dr.name,
location: dr.statusClass === 'mission' ? `${dock?.location || '--'} · 飞行中` : `停放于${dock?.name || '--'}`,
status: dr.status, statusClass: dr.statusClass, x: p.x, y: p.y, hasCoordinates,
longitude: hasCoordinates ? lon : null,
latitude: hasCoordinates ? lat : null,
parent: dock?.id || '', parentName: dock?.name || '--',
inDock: dr.statusClass !== 'mission', altitude: dr._alt, speed: dr._speed, satellites: dr._sats,
realtime: dr.realtime, alarms: dr.alarms, online: dr.online
}
}
loaded.value = true
return { docks: newDocks, drones: newDrones }
}
function applyDockDetail(id, detail) {
const key = String(id)
const dock = docks.value.find((d) => d.id === key)
if (!dock) {
console.warn('[devices] applyDockDetail missing dock', key)
return false
}
const rt = detail?.realtime || {}
const online = Boolean(detail?.online)
const st = dockStatusFromDetail(dock.registerStatus || 'registered', online, rt)
const environment = environmentValues(rt)
const alarms = alarmCodes(rt)
Object.assign(dock, {
status: st.status,
statusClass: st.statusClass,
mode: controlModeText(rt.controlMode),
realtime: rt,
environment,
alarms,
online,
})
const assetRow = assets[key]
if (assetRow) {
Object.assign(assetRow, {
status: dock.status,
statusClass: dock.statusClass,
realtime: rt,
environment,
alarms,
online,
})
}
return true
}
function applyDroneDetail(id, detail) {
const key = String(id)
const drone = drones.value.find((d) => d.id === key)
if (!drone) {
console.warn('[devices] applyDroneDetail missing drone', key)
return false
}
const rt = detail?.realtime || {}
const online = Boolean(detail?.online)
const st = droneStatusFromDetail(online, rt)
const altitude = st.altitude
const battery = rt.batteryPercent ?? rt.batteryPct
const alarms = alarmCodes(rt)
const dock = docks.value.find((d) => d.dockId === drone.dockId)
Object.assign(drone, {
status: st.status,
statusClass: st.statusClass,
battery: battery != null && battery !== '' ? `${battery}%` : drone.battery,
mode: modeText(rt.flightMode),
mission: displayValue(rt.missionName || rt.mission),
sysid: displayValue(rt.currentSysId),
gps: gpsText(rt.gpsQuality),
realtime: rt,
alarms,
online,
_lat: coordinate(rt.latitude, -90, 90),
_lon: coordinate(rt.longitude, -180, 180),
_alt: altitude,
_speed: coordinate(rt.groundSpeed, 0, 1000),
_sats: coordinate(rt.satellites, 0, 1000),
})
const assetRow = assets[key]
if (assetRow) {
const lon = drone._lon != null ? drone._lon : dock?.longitude
const lat = drone._lat != null ? drone._lat : dock?.latitude
const hasCoordinates = lon != null && lat != null
Object.assign(assetRow, {
status: drone.status,
statusClass: drone.statusClass,
location: drone.statusClass === 'mission'
? `${dock?.location || '--'} · 飞行中`
: `停放于${dock?.name || '--'}`,
hasCoordinates,
longitude: hasCoordinates ? lon : null,
latitude: hasCoordinates ? lat : null,
inDock: drone.statusClass !== 'mission',
altitude: drone._alt,
speed: drone._speed,
satellites: drone._sats,
realtime: rt,
alarms,
online,
})
}
return true
}
return { assets, docks, drones, loaded, asset, record, commandType, sendCommand, load, applyDockDetail, applyDroneDetail }
})
import { defineStore } from 'pinia'
import { reactive, ref } from 'vue'
import request from '@/utils/http'
import * as urls from '@/config/urls'
// 后端 status 值 → 展示文案 + 状态样式
const DOCK_STATUS = {
online: { status: '在线', statusClass: 'online' },
offline: { status: '离线', statusClass: 'offline' }
}
function fmtTime(t) {
if (!t) return '--'
const d = new Date(t)
if (Number.isNaN(d.getTime())) return '--'
return d.toLocaleString('zh-CN', { hour12: false })
}
function gpsText(q) {
const key = String(q || '').toUpperCase().replace(/-/g, '_')
switch (key) {
case 'RTK_FIX':
case 'RTK_FIXED': return 'RTK 固定解'
case 'RTK_FLOAT': return 'RTK 浮点解'
case '3D_FIX': return '3D 定位'
case '2D_FIX': return '2D 定位'
default: return q ? String(q) : '无定位'
}
}
function modeText(m) {
const key = String(m || '').toUpperCase()
switch (key) {
case 'STANDBY': return '待命'
case 'AUTO': return 'AUTO'
case 'GUIDED': return 'GUIDED'
case 'LOITER': return '悬停'
case 'RTL': return '返航'
default: return m || '--'
}
}
function controlModeText(m) {
const key = String(m || '').toLowerCase()
if (key === 'auto' || key === 'automatic') return '自动'
if (key === 'manual') return '手动'
return m || '--'
}
function parseJson(value, fallback = null) {
if (!value) return fallback
if (typeof value === 'object') return value
try { return JSON.parse(value) } catch { return fallback }
}
function environmentValues(realtime) {
const nested = parseJson(realtime?.environment, {}) || {}
const value = (key, ...aliases) => {
for (const name of [key, ...aliases]) {
if (nested[name] != null && nested[name] !== '') return nested[name]
if (realtime?.[name] != null && realtime[name] !== '') return realtime[name]
}
return null
}
return {
rain: value('rain'),
rainfall: value('rainfall', 'rainAmount', 'rainFall', 'precipitation'),
windSpeed: value('windSpeed', 'wind_speed'),
outsideTemperature: value('outsideTemperature', 'outside_temperature'),
outsideHumidity: value('outsideHumidity', 'outside_humidity'),
insideTemperature: value('insideTemperature', 'inside_temperature', 'temperature'),
insideHumidity: value('insideHumidity', 'inside_humidity', 'humidity')
}
}
function alarmCodes(realtime) {
const codes = parseJson(realtime?.alarmCodes, [])
return Array.isArray(codes) ? codes : []
}
function displayValue(value) {
return value == null || value === '' ? '--' : String(value)
}
function coordinate(value, min, max) {
const number = Number(value)
return Number.isFinite(number) && number >= min && number <= max ? number : null
}
// 指令标题 → 后端指令 type
const COMMANDS = {
'一键起飞': 'workflow.one_key_takeoff',
'一键降落': 'workflow.one_key_land',
'起飞准备': 'dock.prepare_takeoff',
'起飞准备完成': 'dock.complete_takeoff',
'降落准备': 'dock.prepare_landing',
'降落准备完成': 'dock.complete_landing',
'打开舱门': 'dock.open',
'关闭舱门': 'dock.close',
'开始充电': 'dock.charge_start',
'整机复位': 'dock.reset',
'机巢复位': 'dock.reset',
'无人机开机': 'dock.drone_power_on',
'无人机关机': 'dock.drone_power_off',
'远程重启': 'dock.reboot',
'清除设备告警': 'dock.clear_alarm',
'设备自检': 'dock.self_check',
'起飞': 'drone.takeoff',
'降落': 'drone.land',
'一键返航': 'drone.return',
'悬停': 'drone.hover',
'紧急停止': 'drone.emergency_stop',
'开始任务': 'drone.mission_start',
'暂停任务': 'drone.mission_pause',
'继续任务': 'drone.mission_resume',
'取消任务': 'drone.mission_cancel'
}
// 经纬度 → 地图百分比坐标(按设备经纬范围动态投影,北向上)
function project(lon, lat, bounds) {
const lonSpan = bounds.maxLon - bounds.minLon || 1
const latSpan = bounds.maxLat - bounds.minLat || 1
const x = ((lon - bounds.minLon) / lonSpan) * 80 + 10
const y = ((bounds.maxLat - lat) / latSpan) * 70 + 15
return { x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 }
}
function dockStatusFromDetail(registerStatus, online, rt) {
const pending = registerStatus === 'pending' && !online
if (pending) return { status: '待连接', statusClass: 'pending' }
if (!online) return DOCK_STATUS.offline
if (alarmCodes(rt).length) return { status: '告警', statusClass: 'alarm' }
return DOCK_STATUS.online
}
function droneStatusFromDetail(online, rt) {
const armed = ['1', 'true', 1, true].includes(rt.armed)
const altitude = coordinate(rt.altitude, -1000, 100000)
const flying = armed || (altitude != null && altitude > 0)
if (!online) return { status: '离线', statusClass: 'offline', altitude, flying }
if (flying) return { status: '飞行中', statusClass: 'mission', altitude, flying }
return { status: '在巢', statusClass: 'online', altitude, flying }
}
export const useDeviceStore = defineStore('devices', () => {
const assets = reactive({})
const docks = ref([])
const drones = ref([])
const loaded = ref(false)
function asset(id) {
return assets[id] || null
}
function record(id) {
const key = String(id)
return docks.value.find((d) => d.id === key) || drones.value.find((d) => d.id === key) || null
}
function commandType(title) {
return COMMANDS[title] || ''
}
async function sendCommand(rec, title, params = {}) {
const type = COMMANDS[title]
if (!type) throw new Error('未知指令')
if (rec.kind === 'dock') {
return request.post(urls.DOCK_COMMAND(rec.id), { type, params })
}
const dock = docks.value.find((d) => d.dockId === rec.dockId)
if (!dock) throw new Error('未找到绑定机巢')
return request.post(urls.DOCK_COMMAND(dock.id), { type, params, droneSn: rec.droneSn })
}
async function load(options = {}) {
const silent = Boolean(options.silent)
const reqOpts = silent ? { skipErrorToast: true } : {}
const [dockPage, dronePage] = await Promise.all([
request.get(urls.DOCKS, { params: { pageNum: 1, pageSize: 100 }, ...reqOpts }),
request.get(urls.DRONES, { params: { pageNum: 1, pageSize: 100 }, ...reqOpts })
])
const dockList = dockPage?.records || []
const droneList = dronePage?.records || []
const [dockDetails, droneDetails] = await Promise.all([
Promise.all(dockList.map((d) => request.get(urls.DOCK(d.id), reqOpts).then((r) => r || {}).catch(() => ({})))),
Promise.all(droneList.map((d) => request.get(urls.DRONE(d.id), reqOpts).then((r) => r || {}).catch(() => ({}))))
])
const coords = dockList
.map((d) => ({ longitude: coordinate(d.longitude, -180, 180), latitude: coordinate(d.latitude, -90, 90) }))
.filter((d) => d.longitude != null && d.latitude != null)
const defaultLon = coords[0]?.longitude ?? 0
const defaultLat = coords[0]?.latitude ?? 0
const bounds = {
minLon: Math.min(...coords.map((d) => Number(d.longitude)), defaultLon),
maxLon: Math.max(...coords.map((d) => Number(d.longitude)), defaultLon),
minLat: Math.min(...coords.map((d) => Number(d.latitude)), defaultLat),
maxLat: Math.max(...coords.map((d) => Number(d.latitude)), defaultLat)
}
if (bounds.minLon === bounds.maxLon) { bounds.minLon -= 0.01; bounds.maxLon += 0.01 }
if (bounds.minLat === bounds.maxLat) { bounds.minLat -= 0.01; bounds.maxLat += 0.01 }
const newDocks = dockList.map((d, i) => {
const id = String(d.id)
const detail = dockDetails[i] || {}
const rt = detail.realtime || {}
const online = Boolean(detail.online)
const registerStatus = d.registerStatus || 'registered'
const st = dockStatusFromDetail(registerStatus, online, rt)
const name = d.name || d.dockId
const environment = environmentValues(rt)
return {
id,
kind: 'dock',
dockId: d.dockId,
name,
code: d.code || d.sn || d.dockId,
sn: d.sn || d.dockId || '--',
iccid: '--',
longitude: coordinate(d.longitude, -180, 180),
latitude: coordinate(d.latitude, -90, 90),
location: d.location || '--',
status: st.status,
statusClass: st.statusClass,
binding: '--',
bindingState: 'unbound',
mode: controlModeText(rt.controlMode),
version: d.softwareVer ? `v${d.softwareVer}` : '--',
updated: fmtTime(d.updatedAt),
protocol: d.protocolVer ? `MQTT v${d.protocolVer}` : '--',
registerStatus,
realtime: rt,
environment,
alarms: alarmCodes(rt),
online
}
})
const newDrones = droneList.map((d, i) => {
const id = String(d.id)
const detail = droneDetails[i] || {}
const rt = detail.realtime || {}
const online = Boolean(detail.online)
const st = droneStatusFromDetail(online, rt)
const altitude = st.altitude
const dock = newDocks.find((dk) => dk.dockId === d.dockId)
const battery = rt.batteryPercent ?? rt.batteryPct ?? d.battery
return {
id,
kind: 'drone',
droneSn: d.droneSn,
dockId: d.dockId,
name: d.name || d.droneSn,
code: d.code || d.droneSn,
status: st.status,
statusClass: st.statusClass,
binding: dock?.name || '--',
bindingState: dock ? 'bound' : 'unbound',
battery: battery != null && battery !== '' ? `${battery}%` : '--',
mode: modeText(rt.flightMode),
mission: displayValue(rt.missionName || rt.mission),
updated: fmtTime(d.updatedAt),
sysid: displayValue(rt.currentSysId),
gps: gpsText(rt.gpsQuality),
sn: d.droneSn || d.code || '--',
model: d.model || '--',
firmwareVer: d.firmwareVer || '--',
localRoutes: [],
realtime: rt,
alarms: alarmCodes(rt),
online,
_lat: coordinate(rt.latitude, -90, 90),
_lon: coordinate(rt.longitude, -180, 180),
_alt: altitude,
_speed: coordinate(rt.groundSpeed, 0, 1000),
_sats: coordinate(rt.satellites, 0, 1000)
}
})
for (const dk of newDocks) {
const dr = newDrones.find((d) => d.dockId === dk.dockId)
if (dr) {
dk.binding = dr.name
dk.bindingState = 'bound'
}
}
docks.value = newDocks
drones.value = newDrones
// 重建地图资产
Object.keys(assets).forEach((k) => delete assets[k])
for (const dk of newDocks) {
const hasCoordinates = dk.longitude != null && dk.latitude != null
const p = hasCoordinates ? project(dk.longitude, dk.latitude, bounds) : { x: null, y: null }
assets[dk.id] = {
id: dk.id, type: 'dock', name: dk.name, location: dk.location,
status: dk.status, statusClass: dk.statusClass, x: p.x, y: p.y, hasCoordinates,
longitude: hasCoordinates ? dk.longitude : null,
latitude: hasCoordinates ? dk.latitude : null,
realtime: dk.realtime, environment: dk.environment, alarms: dk.alarms, online: dk.online
}
}
for (const dr of newDrones) {
const dock = newDocks.find((d) => d.dockId === dr.dockId)
const lon = dr._lon != null ? dr._lon : dock?.longitude
const lat = dr._lat != null ? dr._lat : dock?.latitude
const hasCoordinates = lon != null && lat != null
const p = hasCoordinates ? project(lon, lat, bounds) : { x: null, y: null }
assets[dr.id] = {
id: dr.id, type: 'drone', name: dr.name,
location: dr.statusClass === 'mission' ? `${dock?.location || '--'} · 飞行中` : `停放于${dock?.name || '--'}`,
status: dr.status, statusClass: dr.statusClass, x: p.x, y: p.y, hasCoordinates,
longitude: hasCoordinates ? lon : null,
latitude: hasCoordinates ? lat : null,
parent: dock?.id || '', parentName: dock?.name || '--',
inDock: dr.statusClass !== 'mission', altitude: dr._alt, speed: dr._speed, satellites: dr._sats,
realtime: dr.realtime, alarms: dr.alarms, online: dr.online
}
}
loaded.value = true
return { docks: newDocks, drones: newDrones }
}
function applyDockDetail(id, detail) {
const key = String(id)
const dock = docks.value.find((d) => d.id === key)
if (!dock) {
console.warn('[devices] applyDockDetail missing dock', key)
return false
}
const rt = detail?.realtime || {}
const online = Boolean(detail?.online)
const st = dockStatusFromDetail(dock.registerStatus || 'registered', online, rt)
const environment = environmentValues(rt)
const alarms = alarmCodes(rt)
Object.assign(dock, {
status: st.status,
statusClass: st.statusClass,
mode: controlModeText(rt.controlMode),
realtime: rt,
environment,
alarms,
online,
})
const assetRow = assets[key]
if (assetRow) {
Object.assign(assetRow, {
status: dock.status,
statusClass: dock.statusClass,
realtime: rt,
environment,
alarms,
online,
})
}
return true
}
function applyDroneDetail(id, detail) {
const key = String(id)
const drone = drones.value.find((d) => d.id === key)
if (!drone) {
console.warn('[devices] applyDroneDetail missing drone', key)
return false
}
const rt = detail?.realtime || {}
const online = Boolean(detail?.online)
const st = droneStatusFromDetail(online, rt)
const altitude = st.altitude
const battery = rt.batteryPercent ?? rt.batteryPct
const alarms = alarmCodes(rt)
const dock = docks.value.find((d) => d.dockId === drone.dockId)
Object.assign(drone, {
status: st.status,
statusClass: st.statusClass,
battery: battery != null && battery !== '' ? `${battery}%` : drone.battery,
mode: modeText(rt.flightMode),
mission: displayValue(rt.missionName || rt.mission),
sysid: displayValue(rt.currentSysId),
gps: gpsText(rt.gpsQuality),
realtime: rt,
alarms,
online,
_lat: coordinate(rt.latitude, -90, 90),
_lon: coordinate(rt.longitude, -180, 180),
_alt: altitude,
_speed: coordinate(rt.groundSpeed, 0, 1000),
_sats: coordinate(rt.satellites, 0, 1000),
})
const assetRow = assets[key]
if (assetRow) {
const lon = drone._lon != null ? drone._lon : dock?.longitude
const lat = drone._lat != null ? drone._lat : dock?.latitude
const hasCoordinates = lon != null && lat != null
Object.assign(assetRow, {
status: drone.status,
statusClass: drone.statusClass,
location: drone.statusClass === 'mission'
? `${dock?.location || '--'} · 飞行中`
: `停放于${dock?.name || '--'}`,
hasCoordinates,
longitude: hasCoordinates ? lon : null,
latitude: hasCoordinates ? lat : null,
inDock: drone.statusClass !== 'mission',
altitude: drone._alt,
speed: drone._speed,
satellites: drone._sats,
realtime: rt,
alarms,
online,
})
}
return true
}
return { assets, docks, drones, loaded, asset, record, commandType, sendCommand, load, applyDockDetail, applyDroneDetail }
})

2142
src/views/AccountView/AccountView.vue

File diff suppressed because it is too large

1442
src/views/DeviceDetailView/DeviceDetailView.vue

File diff suppressed because it is too large

1658
src/views/DevicesView/DevicesView.vue

File diff suppressed because it is too large

2048
src/views/TrafficOpsView/TrafficOpsView.vue

File diff suppressed because it is too large
Loading…
Cancel
Save