Browse Source

feat: polish shell cover map, centralize urls, real Replay trajectory

- MainContainer view layer over MapLayer; non-monitor pages cover map
- Extract all API paths into config/urls.js and replace call sites
- Replay draws trajectory on shared maptalks map via VectorLayer
- Add .env.production skeleton for Tianditu/map center
main
xiaosi 1 month ago
parent
commit
f50069637a
  1. 6
      .env.production
  2. 3
      src/components/DockEditModal.vue
  3. 60
      src/config/urls.js
  4. 14
      src/layout/MainContainer.vue
  5. 13
      src/stores/modules/devicesStore.js
  6. 26
      src/styles/prototype.css
  7. 17
      src/views/AccountView/AccountView.vue
  8. 5
      src/views/AlarmsView/AlarmsView.vue
  9. 5
      src/views/DeviceDetailView/DeviceDetailView.vue
  10. 7
      src/views/DevicesView/DevicesView.vue
  11. 9
      src/views/FirmwaresView/FirmwaresView.vue
  12. 11
      src/views/LoginView/LoginView.vue
  13. 7
      src/views/MediaView/MediaView.vue
  14. 3
      src/views/OperationLogsView/OperationLogsView.vue
  15. 135
      src/views/ReplayView/ReplayView.vue
  16. 3
      src/views/SystemView/SystemView.vue
  17. 11
      src/views/TasksView/TasksView.vue
  18. 3
      src/views/UsersView/UsersView.vue

6
.env.production

@ -0,0 +1,6 @@
# 生产构建环境变量(可按部署环境覆盖)
APP_TIANDITU_TOKEN=1e3adb7649d896631c574461b15cb9d4
APP_MAP_CENTER_LNG=106.62341584179686
APP_MAP_CENTER_LAT=26.657826154258505
APP_MAP_ZOOM=5
# APP_PRODUCTION=true

3
src/components/DockEditModal.vue

@ -27,6 +27,7 @@
<script setup> <script setup>
import { reactive, ref, watch } from 'vue' import { reactive, ref, watch } from 'vue'
import request from '@/utils/http' import request from '@/utils/http'
import * as urls from '@/config/urls'
const props = defineProps({ const props = defineProps({
visible: Boolean, visible: Boolean,
@ -75,7 +76,7 @@ async function submit() {
saving.value = true saving.value = true
try { try {
await request.put(`/v1/docks/${props.dock.id}`, {
await request.put(urls.DOCK(props.dock.id), {
name, name,
longitude, longitude,
latitude, latitude,

60
src/config/urls.js

@ -1,2 +1,60 @@
/** API 路径集中配置。axios baseURL 已是 /api,此处用相对路径。 */
export const BASE_URL = '/api' export const BASE_URL = '/api'
// 后续从调用点抽全;本轮允许仅占位
// Auth
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'
// 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'
// 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'
// Live / Videos
export const LIVE = '/v1/live'
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'
// Account
export const TRAFFIC_BALANCE = '/v1/account/traffic/balance'
export const TRAFFIC_ORDERS = '/v1/account/traffic/orders'
export const TRAFFIC_USAGE = '/v1/account/traffic/usage'
export const SIM_CARDS = '/v1/account/sim-cards'
export const SIM_RECHARGE_LOGS = '/v1/account/sim-recharge-logs'
export const SIM_CARD_RECHARGE = (id) => `/v1/account/sim-cards/${id}/recharge`
// 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'

14
src/layout/MainContainer.vue

@ -36,7 +36,9 @@ function closeSidebar() {
</t-header> </t-header>
<t-content :class="s.content"> <t-content :class="s.content">
<MapLayer /> <MapLayer />
<router-view />
<div :class="s.view">
<router-view />
</div>
</t-content> </t-content>
</t-layout> </t-layout>
</t-layout> </t-layout>
@ -94,6 +96,16 @@ function closeSidebar() {
background: transparent; background: transparent;
} }
.view {
position: absolute;
inset: 0;
z-index: 1;
height: 100%;
min-height: 0;
overflow: hidden;
pointer-events: none;
}
.mask { .mask {
display: none; display: none;
} }

13
src/stores/modules/devicesStore.js

@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { reactive, ref } from 'vue' import { reactive, ref } from 'vue'
import request from '@/utils/http' import request from '@/utils/http'
import * as urls from '@/config/urls'
// 后端 status 值 → 展示文案 + 状态样式 // 后端 status 值 → 展示文案 + 状态样式
const DOCK_STATUS = { const DOCK_STATUS = {
@ -146,24 +147,24 @@ export const useDeviceStore = defineStore('devices', () => {
const type = COMMANDS[title] const type = COMMANDS[title]
if (!type) throw new Error('未知指令') if (!type) throw new Error('未知指令')
if (rec.kind === 'dock') { if (rec.kind === 'dock') {
return request.post(`/v1/docks/${rec.id}/command`, { type, params })
return request.post(urls.DOCK_COMMAND(rec.id), { type, params })
} }
const dock = docks.value.find((d) => d.dockId === rec.dockId) const dock = docks.value.find((d) => d.dockId === rec.dockId)
if (!dock) throw new Error('未找到绑定机巢') if (!dock) throw new Error('未找到绑定机巢')
return request.post(`/v1/docks/${dock.id}/command`, { type, params, droneSn: rec.droneSn })
return request.post(urls.DOCK_COMMAND(dock.id), { type, params, droneSn: rec.droneSn })
} }
async function load() { async function load() {
const [dockPage, dronePage] = await Promise.all([ const [dockPage, dronePage] = await Promise.all([
request.get('/v1/docks', { params: { pageNum: 1, pageSize: 100 } }),
request.get('/v1/drones', { params: { pageNum: 1, pageSize: 100 } })
request.get(urls.DOCKS, { params: { pageNum: 1, pageSize: 100 } }),
request.get(urls.DRONES, { params: { pageNum: 1, pageSize: 100 } })
]) ])
const dockList = dockPage?.records || [] const dockList = dockPage?.records || []
const droneList = dronePage?.records || [] const droneList = dronePage?.records || []
const [dockDetails, droneDetails] = await Promise.all([ const [dockDetails, droneDetails] = await Promise.all([
Promise.all(dockList.map((d) => request.get(`/v1/docks/${d.id}`).then((r) => r || {}).catch(() => ({})))),
Promise.all(droneList.map((d) => request.get(`/v1/drones/${d.id}`).then((r) => r || {}).catch(() => ({}))))
Promise.all(dockList.map((d) => request.get(urls.DOCK(d.id)).then((r) => r || {}).catch(() => ({})))),
Promise.all(droneList.map((d) => request.get(urls.DRONE(d.id)).then((r) => r || {}).catch(() => ({}))))
]) ])
const coords = dockList const coords = dockList
.map((d) => ({ longitude: coordinate(d.longitude, -180, 180), latitude: coordinate(d.latitude, -90, 90) })) .map((d) => ({ longitude: coordinate(d.longitude, -180, 180), latitude: coordinate(d.latitude, -90, 90) }))

26
src/styles/prototype.css

@ -86,7 +86,7 @@ svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width:
.sidebar-bottom .sidebar-profile > svg { transform: rotate(-90deg); color: #8fa0ad; } .sidebar-bottom .sidebar-profile > svg { transform: rotate(-90deg); color: #8fa0ad; }
.mobile-menu { display: none; } .mobile-menu { display: none; }
.monitor-layout { position: relative; flex: 1; min-height: 0; display: grid; grid-template-columns: 0 minmax(0, 1fr) 0; overflow: hidden; background: transparent; pointer-events: none; transition: grid-template-columns .38s cubic-bezier(.22,.8,.25,1); }
.monitor-layout { position: relative; height: 100%; flex: 1; min-height: 0; display: grid; grid-template-columns: 0 minmax(0, 1fr) 0; overflow: hidden; background: transparent; pointer-events: none; transition: grid-template-columns .38s cubic-bezier(.22,.8,.25,1); }
.monitor-layout.focused { grid-template-columns: 276px minmax(360px, 1fr) 348px; } .monitor-layout.focused { grid-template-columns: 276px minmax(360px, 1fr) 348px; }
.monitor-layout.focused.control-expanded { grid-template-columns: 276px minmax(360px, 1fr) 560px; } .monitor-layout.focused.control-expanded { grid-template-columns: 276px minmax(360px, 1fr) 560px; }
.device-panel, .detail-panel { background: #fff; min-width: 0; min-height: 0; overflow: auto; opacity: 0; visibility: hidden; z-index: 8; pointer-events: none; transition: transform .38s cubic-bezier(.22,.8,.25,1), opacity .2s ease, visibility 0s linear .38s; } .device-panel, .detail-panel { background: #fff; min-width: 0; min-height: 0; overflow: auto; opacity: 0; visibility: hidden; z-index: 8; pointer-events: none; transition: transform .38s cubic-bezier(.22,.8,.25,1), opacity .2s ease, visibility 0s linear .38s; }
@ -250,7 +250,7 @@ svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width:
.page-view[hidden] { display: none !important; } .page-view[hidden] { display: none !important; }
.monitor-layout[hidden] { display: none !important; } .monitor-layout[hidden] { display: none !important; }
.device-page { position: relative; flex: 1; min-height: 0; padding: 22px 24px 24px; display: flex; flex-direction: column; gap: 16px; overflow: auto; background: #f2f5f7; }
.device-page { position: relative; height: 100%; flex: 1; min-height: 0; padding: 22px 24px 24px; display: flex; flex-direction: column; gap: 16px; overflow: auto; background: #f2f5f7; pointer-events: auto; }
.device-page-header { min-height: 42px; display: flex; align-items: center; justify-content: space-between; gap: 16px; } .device-page-header { min-height: 42px; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.device-page-header > div:first-child { display: flex; align-items: baseline; gap: 12px; }.device-page-header h1 { margin: 0; font-size: 24px; }.device-page-header span { color: var(--muted); font-size: 12px; } .device-page-header > div:first-child { display: flex; align-items: baseline; gap: 12px; }.device-page-header h1 { margin: 0; font-size: 24px; }.device-page-header span { color: var(--muted); font-size: 12px; }
.page-actions { display: flex; align-items: center; gap: 8px; }.icon-command, .primary-command, .plain-command { height: 34px; border-radius: 5px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; font-size: 13px; } .page-actions { display: flex; align-items: center; gap: 8px; }.icon-command, .primary-command, .plain-command { height: 34px; border-radius: 5px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; font-size: 13px; }
@ -274,7 +274,7 @@ svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width:
.device-drawer > footer { position: sticky; bottom: 0; min-height: 62px; margin-top: auto; padding: 12px 18px; display: flex; align-items: center; justify-content: flex-end; gap: 8px; border-top: 1px solid var(--line); background: #fff; } .device-drawer > footer { position: sticky; bottom: 0; min-height: 62px; margin-top: auto; padding: 12px 18px; display: flex; align-items: center; justify-content: flex-end; gap: 8px; border-top: 1px solid var(--line); background: #fff; }
.firmware-current { color: var(--muted); font-size: 11px; }.firmware-upgrade { display: flex; align-items: center; gap: 8px; }.firmware-upgrade select { flex: 1; min-width: 0; height: 34px; padding: 0 26px 0 10px; border: 1px solid var(--line); border-radius: 5px; outline: 0; color: #536270; background: #fff; font-size: 12px; }.firmware-upgrade .primary-command { height: 34px; padding: 0 16px; white-space: nowrap; }.firmware-upgrade .primary-command:disabled { opacity: .5; cursor: not-allowed; } .firmware-current { color: var(--muted); font-size: 11px; }.firmware-upgrade { display: flex; align-items: center; gap: 8px; }.firmware-upgrade select { flex: 1; min-width: 0; height: 34px; padding: 0 26px 0 10px; border: 1px solid var(--line); border-radius: 5px; outline: 0; color: #536270; background: #fff; font-size: 12px; }.firmware-upgrade .primary-command { height: 34px; padding: 0 16px; white-space: nowrap; }.firmware-upgrade .primary-command:disabled { opacity: .5; cursor: not-allowed; }
.operation-page { flex: 1; min-height: 0; padding: 22px 24px 24px; display: flex; flex-direction: column; gap: 16px; overflow: auto; background: #f2f5f7; }
.operation-page { height: 100%; flex: 1; min-height: 0; padding: 22px 24px 24px; display: flex; flex-direction: column; gap: 16px; overflow: auto; background: #f2f5f7; pointer-events: auto; }
.operation-page-header { min-height: 45px; display: flex; align-items: center; justify-content: space-between; gap: 18px; } .operation-page-header { min-height: 45px; display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.operation-page-header > div { min-width: 0; } .operation-page-header > div { min-width: 0; }
.operation-page-header h1 { margin: 0 0 5px; font-size: 23px; } .operation-page-header h1 { margin: 0 0 5px; font-size: 23px; }
@ -373,13 +373,13 @@ svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width:
.route-map-legend { right: 12px; bottom: 12px; height: 31px; padding: 0 10px; gap: 11px; }.route-map-legend span { display: flex; align-items: center; gap: 4px; }.route-map-legend i { width: 7px; height: 7px; border: 2px solid #fff; border-radius: 50%; background: var(--blue); box-shadow: 0 0 0 1px #7e8c97; }.route-map-legend i.start { background: var(--green); }.route-map-legend i.end { background: #c56c28; } .route-map-legend { right: 12px; bottom: 12px; height: 31px; padding: 0 10px; gap: 11px; }.route-map-legend span { display: flex; align-items: center; gap: 4px; }.route-map-legend i { width: 7px; height: 7px; border: 2px solid #fff; border-radius: 50%; background: var(--blue); box-shadow: 0 0 0 1px #7e8c97; }.route-map-legend i.start { background: var(--green); }.route-map-legend i.end { background: #c56c28; }
.waypoint-dialog { width: min(540px, 100%); }.waypoint-id-row { min-height: 42px; margin-bottom: 14px; padding: 0 12px; display: flex; align-items: center; justify-content: space-between; border-radius: 4px; background: #f4f6f8; }.waypoint-id-row span { color: var(--muted); font-size: 11px; }.waypoint-id-row strong { font-size: 14px; }.waypoint-dialog .number-input input { padding-right: 38px; }.waypoint-turn-segment { height: 38px; grid-template-columns: repeat(2, minmax(0, 1fr)); } .waypoint-dialog { width: min(540px, 100%); }.waypoint-id-row { min-height: 42px; margin-bottom: 14px; padding: 0 12px; display: flex; align-items: center; justify-content: space-between; border-radius: 4px; background: #f4f6f8; }.waypoint-id-row span { color: var(--muted); font-size: 11px; }.waypoint-id-row strong { font-size: 14px; }.waypoint-dialog .number-input input { padding-right: 38px; }.waypoint-turn-segment { height: 38px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.task-replay-page { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; background: #eef2f4; }
.task-replay-page { position: relative; height: 100%; flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; background: transparent; pointer-events: none; }
.replay-header { min-height: 88px; padding: 10px 20px 13px; display: grid; grid-template-columns: 155px minmax(260px, 1fr) auto; align-items: end; gap: 8px 15px; border-bottom: 1px solid var(--line); background: #fff; } .replay-header { min-height: 88px; padding: 10px 20px 13px; display: grid; grid-template-columns: 155px minmax(260px, 1fr) auto; align-items: end; gap: 8px 15px; border-bottom: 1px solid var(--line); background: #fff; }
.replay-header .back-command { grid-column: 1 / 4; } .replay-header .back-command { grid-column: 1 / 4; }
.replay-header > div:nth-child(2) { grid-column: 1 / 3; min-width: 0; }.replay-header small { color: var(--muted); font-size: 10px; }.replay-header h1 { margin: 3px 0; overflow: hidden; font-size: 19px; text-overflow: ellipsis; white-space: nowrap; }.replay-header > div:nth-child(2) > span { color: var(--muted); font-size: 10px; }.replay-header > div:nth-child(2) b { color: #5c6a75; font-weight: 500; } .replay-header > div:nth-child(2) { grid-column: 1 / 3; min-width: 0; }.replay-header small { color: var(--muted); font-size: 10px; }.replay-header h1 { margin: 3px 0; overflow: hidden; font-size: 19px; text-overflow: ellipsis; white-space: nowrap; }.replay-header > div:nth-child(2) > span { color: var(--muted); font-size: 10px; }.replay-header > div:nth-child(2) b { color: #5c6a75; font-weight: 500; }
.replay-header-summary { display: flex; align-items: center; }.replay-header-summary > span { min-width: 86px; padding: 0 14px; display: flex; flex-direction: column; gap: 4px; border-left: 1px solid var(--line); color: var(--muted); font-size: 10px; }.replay-header-summary strong { color: #344550; font-size: 12px; } .replay-header-summary { display: flex; align-items: center; }.replay-header-summary > span { min-width: 86px; padding: 0 14px; display: flex; flex-direction: column; gap: 4px; border-left: 1px solid var(--line); color: var(--muted); font-size: 10px; }.replay-header-summary strong { color: #344550; font-size: 12px; }
.replay-map { position: relative; flex: 1; min-height: 430px; overflow: hidden; isolation: isolate; background: #718276; }
.replay-map-base { position: absolute; inset: 0; background-image: linear-gradient(rgba(17,39,37,.07), rgba(17,39,37,.19)), url("/assets/map-base-clean.png"); background-position: center; background-size: cover; filter: saturate(.55) contrast(.94); transform: scale(1.01); }
.replay-map { position: relative; flex: 1; min-height: 0; overflow: hidden; isolation: isolate; background: transparent; pointer-events: none; }
.replay-empty { position: absolute; inset: 0; display: grid; place-items: center; color: var(--muted); font-size: 12px; pointer-events: none; background: rgba(238,242,244,.72); }
.replay-route-layer { position: absolute; z-index: 2; inset: 0; width: 100%; height: 100%; overflow: visible; } .replay-route-layer { position: absolute; z-index: 2; inset: 0; width: 100%; height: 100%; overflow: visible; }
.replay-route-shadow, .replay-route-line { fill: none; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; }.replay-route-shadow { stroke: rgba(255,255,255,.9); stroke-width: 7; }.replay-route-line { stroke: #2b79c8; stroke-width: 3; stroke-dasharray: 7 5; } .replay-route-shadow, .replay-route-line { fill: none; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; }.replay-route-shadow { stroke: rgba(255,255,255,.9); stroke-width: 7; }.replay-route-line { stroke: #2b79c8; stroke-width: 3; stroke-dasharray: 7 5; }
.replay-waypoints circle { fill: #fff; stroke: #2b79c8; stroke-width: 4; vector-effect: non-scaling-stroke; }.replay-waypoints circle:first-child { stroke: var(--green); }.replay-waypoints circle:last-child { stroke: #c56c28; } .replay-waypoints circle { fill: #fff; stroke: #2b79c8; stroke-width: 4; vector-effect: non-scaling-stroke; }.replay-waypoints circle:first-child { stroke: var(--green); }.replay-waypoints circle:last-child { stroke: #c56c28; }
@ -432,7 +432,7 @@ svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width:
.raw-download-quota { margin-top: 14px; display: grid; grid-template-columns: repeat(3, 1fr); border: 1px solid var(--line); border-radius: 4px; }.raw-download-quota > div { min-height: 55px; padding: 9px 11px; display: flex; flex-direction: column; justify-content: center; gap: 5px; border-right: 1px solid var(--line); }.raw-download-quota > div:last-child { border-right: 0; }.raw-download-quota span { color: var(--muted); font-size: 10px; }.raw-download-quota strong { font-size: 12px; }.raw-download-content p { margin: 12px 0 0; color: #9a5d25; font-size: 10px; line-height: 1.6; } .raw-download-quota { margin-top: 14px; display: grid; grid-template-columns: repeat(3, 1fr); border: 1px solid var(--line); border-radius: 4px; }.raw-download-quota > div { min-height: 55px; padding: 9px 11px; display: flex; flex-direction: column; justify-content: center; gap: 5px; border-right: 1px solid var(--line); }.raw-download-quota > div:last-child { border-right: 0; }.raw-download-quota span { color: var(--muted); font-size: 10px; }.raw-download-quota strong { font-size: 12px; }.raw-download-content p { margin: 12px 0 0; color: #9a5d25; font-size: 10px; line-height: 1.6; }
.raw-download-dialog > footer { height: 62px; margin-top: 14px; padding: 0 20px; display: flex; align-items: center; justify-content: flex-end; gap: 8px; border-top: 1px solid var(--line); }.raw-download-dialog > footer button { min-width: 88px; }.raw-download-dialog > footer .primary-command { display: flex; align-items: center; justify-content: center; gap: 5px; }.raw-download-dialog > footer svg { width: 13px; } .raw-download-dialog > footer { height: 62px; margin-top: 14px; padding: 0 20px; display: flex; align-items: center; justify-content: flex-end; gap: 8px; border-top: 1px solid var(--line); }.raw-download-dialog > footer button { min-width: 88px; }.raw-download-dialog > footer .primary-command { display: flex; align-items: center; justify-content: center; gap: 5px; }.raw-download-dialog > footer svg { width: 13px; }
.device-detail-page { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; background: #f1f4f6; }
.device-detail-page { height: 100%; flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; background: #f1f4f6; pointer-events: auto; }
.full-detail-header { min-height: 108px; padding: 12px 24px 16px; display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 7px 16px; background: #fff; border-bottom: 1px solid var(--line); }.back-command { grid-column: 1 / 3; width: fit-content; height: 24px; padding: 0; display: flex; align-items: center; gap: 4px; color: var(--muted); background: transparent; font-size: 11px; }.back-command:hover { color: var(--blue); }.back-command svg { width: 12px; transform: rotate(180deg); }.full-detail-identity { min-width: 0; display: flex; align-items: center; gap: 11px; }.full-detail-identity > span { width: 40px; height: 40px; min-width: 40px; display: grid; place-items: center; border-radius: 5px; color: #226e55; background: #e5f3ed; }.full-detail-identity > span.drone { color: #246aa9; background: #e6eff9; }.full-detail-identity > span svg { width: 19px; }.full-detail-identity > div { min-width: 0; }.full-detail-identity small { color: var(--muted); font-size: 10px; }.full-detail-identity h1 { margin: 2px 0; overflow: hidden; font-size: 20px; text-overflow: ellipsis; white-space: nowrap; }.full-detail-identity p { margin: 0; color: var(--muted); font-size: 11px; }.full-detail-identity > i { margin-left: 4px; }.full-detail-actions { display: flex; align-items: center; gap: 8px; }.full-detail-actions svg { width: 14px; } .full-detail-header { min-height: 108px; padding: 12px 24px 16px; display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 7px 16px; background: #fff; border-bottom: 1px solid var(--line); }.back-command { grid-column: 1 / 3; width: fit-content; height: 24px; padding: 0; display: flex; align-items: center; gap: 4px; color: var(--muted); background: transparent; font-size: 11px; }.back-command:hover { color: var(--blue); }.back-command svg { width: 12px; transform: rotate(180deg); }.full-detail-identity { min-width: 0; display: flex; align-items: center; gap: 11px; }.full-detail-identity > span { width: 40px; height: 40px; min-width: 40px; display: grid; place-items: center; border-radius: 5px; color: #226e55; background: #e5f3ed; }.full-detail-identity > span.drone { color: #246aa9; background: #e6eff9; }.full-detail-identity > span svg { width: 19px; }.full-detail-identity > div { min-width: 0; }.full-detail-identity small { color: var(--muted); font-size: 10px; }.full-detail-identity h1 { margin: 2px 0; overflow: hidden; font-size: 20px; text-overflow: ellipsis; white-space: nowrap; }.full-detail-identity p { margin: 0; color: var(--muted); font-size: 11px; }.full-detail-identity > i { margin-left: 4px; }.full-detail-actions { display: flex; align-items: center; gap: 8px; }.full-detail-actions svg { width: 14px; }
.full-detail-actions [hidden] { display: none; } .full-detail-actions [hidden] { display: none; }
.full-detail-tabs { height: 48px; min-height: 48px; padding: 0 24px; display: flex; align-items: end; gap: 28px; background: #fff; border-bottom: 1px solid var(--line); }.full-detail-tabs button { position: relative; height: 47px; padding: 0 2px; color: var(--muted); background: transparent; font-size: 13px; }.full-detail-tabs button b { margin-left: 3px; color: var(--red); font-size: 10px; font-weight: 500; }.full-detail-tabs button.active { color: var(--blue); font-weight: 600; }.full-detail-tabs button.active::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; background: var(--blue); } .full-detail-tabs { height: 48px; min-height: 48px; padding: 0 24px; display: flex; align-items: end; gap: 28px; background: #fff; border-bottom: 1px solid var(--line); }.full-detail-tabs button { position: relative; height: 47px; padding: 0 2px; color: var(--muted); background: transparent; font-size: 13px; }.full-detail-tabs button b { margin-left: 3px; color: var(--red); font-size: 10px; font-weight: 500; }.full-detail-tabs button.active { color: var(--blue); font-weight: 600; }.full-detail-tabs button.active::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; background: var(--blue); }
@ -627,7 +627,7 @@ svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width:
margin-bottom: 17px; margin-bottom: 17px;
} }
.control-page { min-height: 100vh; overflow: auto; background: #f3f6f8; }
.control-page { height: 100%; min-height: 0; overflow: auto; background: #f3f6f8; pointer-events: auto; }
.control-page-header { padding: 18px 24px; background: #fff; border-bottom: 1px solid var(--line); } .control-page-header { padding: 18px 24px; background: #fff; border-bottom: 1px solid var(--line); }
.control-page-header h1 { margin: 0 0 5px; font-size: 22px; } .control-page-header h1 { margin: 0 0 5px; font-size: 22px; }
.control-workspace { min-height: calc(100vh - 76px); display: grid; grid-template-columns: 300px minmax(0, 1fr); gap: 14px; padding: 14px; } .control-workspace { min-height: calc(100vh - 76px); display: grid; grid-template-columns: 300px minmax(0, 1fr); gap: 14px; padding: 14px; }
@ -890,3 +890,13 @@ svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width:
.recharge-modal-footer .confirm:disabled { opacity: .6; cursor: not-allowed; } .recharge-modal-footer .confirm:disabled { opacity: .6; cursor: not-allowed; }
@media (max-width: 900px) { .account-identity-strip { grid-template-columns: 1fr 1fr; }.account-resource-grid { grid-template-columns: 1fr; }.cloud-media-stats { grid-template-columns: repeat(2, 1fr); }.cloud-media-stats > div:nth-child(2) { border-right: 0; }.cloud-media-stats > div:nth-child(-n+2) { border-bottom: 1px solid var(--line); } } @media (max-width: 900px) { .account-identity-strip { grid-template-columns: 1fr 1fr; }.account-resource-grid { grid-template-columns: 1fr; }.cloud-media-stats { grid-template-columns: repeat(2, 1fr); }.cloud-media-stats > div:nth-child(2) { border-right: 0; }.cloud-media-stats > div:nth-child(-n+2) { border-bottom: 1px solid var(--line); } }
@media (max-width: 620px) { .account-identity-strip { grid-template-columns: 1fr; }.communication-status-grid { grid-template-columns: repeat(3, 1fr); }.carrier-package-grid { grid-template-columns: repeat(2, 1fr); } } @media (max-width: 620px) { .account-identity-strip { grid-template-columns: 1fr; }.communication-status-grid { grid-template-columns: repeat(3, 1fr); }.carrier-package-grid { grid-template-columns: repeat(2, 1fr); } }
/* 非 monitor 页盖住全局地图;可点击子页根节点 */
.replay-header,
.replay-map-back,
.replay-map-legend,
.replay-telemetry,
.replay-controller {
pointer-events: auto;
}

17
src/views/AccountView/AccountView.vue

@ -206,6 +206,7 @@ import { useUserStore } from '@/stores/modules/userStore'
import { useUiStore } from '@/stores/modules/uiStore' import { useUiStore } from '@/stores/modules/uiStore'
import request from '@/utils/http' import request from '@/utils/http'
import { useDeviceStore } from '@/stores/modules/devicesStore' import { useDeviceStore } from '@/stores/modules/devicesStore'
import * as urls from '@/config/urls'
const userStore = useUserStore() const userStore = useUserStore()
const ui = useUiStore() const ui = useUiStore()
@ -275,11 +276,11 @@ async function load() {
const simRechargeParams = { pageNum: simRechargePageNum.value, pageSize: 10 } const simRechargeParams = { pageNum: simRechargePageNum.value, pageSize: 10 }
if (simRechargeCardFilter.value) simRechargeParams.simCardId = simRechargeCardFilter.value if (simRechargeCardFilter.value) simRechargeParams.simCardId = simRechargeCardFilter.value
const [bal, orderPage, usagePage, simPage, simRechargePage] = await Promise.all([ const [bal, orderPage, usagePage, simPage, simRechargePage] = await Promise.all([
request.get('/v1/account/traffic/balance'),
request.get('/v1/account/traffic/orders', { params: { pageNum: 1, pageSize: 100 } }),
request.get('/v1/account/traffic/usage', { params: { pageNum: 1, pageSize: 100 } }),
request.get('/v1/account/sim-cards', { params: { pageNum: 1, pageSize: 100 } }),
request.get('/v1/account/sim-recharge-logs', { params: simRechargeParams })
request.get(urls.TRAFFIC_BALANCE),
request.get(urls.TRAFFIC_ORDERS, { params: { pageNum: 1, pageSize: 100 } }),
request.get(urls.TRAFFIC_USAGE, { params: { pageNum: 1, pageSize: 100 } }),
request.get(urls.SIM_CARDS, { params: { pageNum: 1, pageSize: 100 } }),
request.get(urls.SIM_RECHARGE_LOGS, { params: simRechargeParams })
]) ])
if (bal?.balanceGb != null) balance.value = Number(bal.balanceGb).toFixed(1) if (bal?.balanceGb != null) balance.value = Number(bal.balanceGb).toFixed(1)
else balance.value = Number(userStore.user?.trafficBalance ?? 0).toFixed(1) else balance.value = Number(userStore.user?.trafficBalance ?? 0).toFixed(1)
@ -358,7 +359,7 @@ async function loadSimRechargeLogs() {
try { try {
const params = { pageNum: simRechargePageNum.value, pageSize: 10 } const params = { pageNum: simRechargePageNum.value, pageSize: 10 }
if (simRechargeCardFilter.value) params.simCardId = simRechargeCardFilter.value if (simRechargeCardFilter.value) params.simCardId = simRechargeCardFilter.value
const page = await request.get('/v1/account/sim-recharge-logs', { params })
const page = await request.get(urls.SIM_RECHARGE_LOGS, { params })
simRechargeTotal.value = page?.total || 0 simRechargeTotal.value = page?.total || 0
simRechargePages.value = page?.pages || 1 simRechargePages.value = page?.pages || 1
simRechargeLogs.value = page?.records || [] simRechargeLogs.value = page?.records || []
@ -381,7 +382,7 @@ function maskPhone(p) {
async function createOrder() { async function createOrder() {
try { try {
await request.post('/v1/account/traffic/orders', { amountGb: Number(trafficAmount.value) || 1 })
await request.post(urls.TRAFFIC_ORDERS, { amountGb: Number(trafficAmount.value) || 1 })
ui.toast('订单已创建,请联系管理员完成支付') ui.toast('订单已创建,请联系管理员完成支付')
await load() await load()
} catch (e) { } catch (e) {
@ -399,7 +400,7 @@ async function submitRecharge() {
if (!rechargeCard.value) return if (!rechargeCard.value) return
recharging.value = true recharging.value = true
try { try {
await request.post(`/v1/account/sim-cards/${rechargeCard.value.id}/recharge`, { amountGb: Number(rechargeAmount.value) || 1 })
await request.post(urls.SIM_CARD_RECHARGE(rechargeCard.value.id), { amountGb: Number(rechargeAmount.value) || 1 })
ui.toast('充值成功') ui.toast('充值成功')
rechargeOpen.value = false rechargeOpen.value = false
await load() await load()

5
src/views/AlarmsView/AlarmsView.vue

@ -54,6 +54,7 @@
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useUiStore } from '@/stores/modules/uiStore' import { useUiStore } from '@/stores/modules/uiStore'
import request from '@/utils/http' import request from '@/utils/http'
import * as urls from '@/config/urls'
const ui = useUiStore() const ui = useUiStore()
@ -80,7 +81,7 @@ function fmtTime(t) {
async function load() { async function load() {
try { try {
const page = await request.get('/v1/alarms', { params: { pageNum: 1, pageSize: 100 } })
const page = await request.get(urls.ALARMS, { params: { pageNum: 1, pageSize: 100 } })
alarms.value = (page?.records || []).map((a) => { alarms.value = (page?.records || []).map((a) => {
const lv = LEVEL[a.level] || LEVEL.warning const lv = LEVEL[a.level] || LEVEL.warning
const st = STATUS[a.status] || STATUS.active const st = STATUS[a.status] || STATUS.active
@ -126,7 +127,7 @@ async function handle(a) {
const ok = await ui.confirm('处理告警:' + a.title, '确认后将标记该告警为已处理并通知相关人员。', ['确认告警原因', '设备状态正常', '记录处理结果']) const ok = await ui.confirm('处理告警:' + a.title, '确认后将标记该告警为已处理并通知相关人员。', ['确认告警原因', '设备状态正常', '记录处理结果'])
if (!ok) return if (!ok) return
try { try {
await request.put(`/v1/alarms/${a.id}/resolve`)
await request.put(urls.ALARM_RESOLVE(a.id))
ui.toast('告警已处理') ui.toast('告警已处理')
await load() await load()
} catch (e) { } catch (e) {

5
src/views/DeviceDetailView/DeviceDetailView.vue

@ -324,6 +324,7 @@ import { useUiStore } from '@/stores/modules/uiStore'
import { useUserStore } from '@/stores/modules/userStore' import { useUserStore } from '@/stores/modules/userStore'
import request from '@/utils/http' import request from '@/utils/http'
import DockEditModal from '@/components/DockEditModal.vue' import DockEditModal from '@/components/DockEditModal.vue'
import * as urls from '@/config/urls'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -520,7 +521,7 @@ async function loadLogs() {
const current = record.value const current = record.value
if (!current) return if (!current) return
try { try {
const page = await request.get('/v1/commands', { params: { pageNum: 1, pageSize: 100, dockId: current.dockId } })
const page = await request.get(urls.COMMANDS, { params: { pageNum: 1, pageSize: 100, dockId: current.dockId } })
logs.value = (page?.records || []).filter((log) => current.kind !== 'drone' || log.droneSn === current.droneSn).map((log) => { logs.value = (page?.records || []).filter((log) => current.kind !== 'drone' || log.droneSn === current.droneSn).map((log) => {
const status = STATUS_NAMES[log.status] || { name: log.status || '--', cls: 'offline', result: log.status || '--' } const status = STATUS_NAMES[log.status] || { name: log.status || '--', cls: 'offline', result: log.status || '--' }
const type = log.commandType || '' const type = log.commandType || ''
@ -544,7 +545,7 @@ async function loadAlarms() {
const current = record.value const current = record.value
if (!current) return if (!current) return
try { try {
const page = await request.get('/v1/alarms', {
const page = await request.get(urls.ALARMS, {
params: { params: {
pageNum: 1, pageNum: 1,
pageSize: 100, pageSize: 100,

7
src/views/DevicesView/DevicesView.vue

@ -261,6 +261,7 @@ import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/modules/userStore' import { useUserStore } from '@/stores/modules/userStore'
import request from '@/utils/http' import request from '@/utils/http'
import DockEditModal from '@/components/DockEditModal.vue' import DockEditModal from '@/components/DockEditModal.vue'
import * as urls from '@/config/urls'
const devices = useDeviceStore() const devices = useDeviceStore()
const ui = useUiStore() const ui = useUiStore()
@ -445,7 +446,7 @@ async function submitAddDock() {
} }
try { try {
await request.post('/v1/docks', {
await request.post(urls.DOCKS, {
dockId: sn, dockId: sn,
name, name,
code: sn, code: sn,
@ -465,7 +466,7 @@ async function submitAddDock() {
async function loadFirmwares() { async function loadFirmwares() {
try { try {
const list = await request.get('/v1/firmwares/released')
const list = await request.get(urls.FIRMWARES_RELEASED)
const records = Array.isArray(list) ? list : list?.records || [] const records = Array.isArray(list) ? list : list?.records || []
firmwareList.value = records.map((f) => ({ firmwareList.value = records.map((f) => ({
id: String(f.id), id: String(f.id),
@ -499,7 +500,7 @@ async function doUpgrade() {
if (!ok) return if (!ok) return
upgrading.value = true upgrading.value = true
try { try {
await request.post(`/v1/docks/${rec.id}/firmware/upgrade`, { firmwareId: Number(upgradeFirmwareId.value) })
await request.post(urls.DOCK_FIRMWARE_UPGRADE(rec.id), { firmwareId: Number(upgradeFirmwareId.value) })
firmwareVisible.value = false firmwareVisible.value = false
upgradeFirmwareId.value = null upgradeFirmwareId.value = null
ui.toast('升级指令已下发') ui.toast('升级指令已下发')

9
src/views/FirmwaresView/FirmwaresView.vue

@ -118,6 +118,7 @@
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { useUiStore } from '@/stores/modules/uiStore' import { useUiStore } from '@/stores/modules/uiStore'
import request from '@/utils/http' import request from '@/utils/http'
import * as urls from '@/config/urls'
const ui = useUiStore() const ui = useUiStore()
const list = ref([]) const list = ref([])
@ -160,7 +161,7 @@ async function load() {
const params = { pageNum: pageNum.value, pageSize: pageSize.value } const params = { pageNum: pageNum.value, pageSize: pageSize.value }
if (keyword.value) params.keyword = keyword.value if (keyword.value) params.keyword = keyword.value
if (statusFilter.value) params.status = statusFilter.value if (statusFilter.value) params.status = statusFilter.value
const page = await request.get('/v1/firmwares', { params })
const page = await request.get(urls.FIRMWARES, { params })
total.value = page?.total || 0 total.value = page?.total || 0
pages.value = page?.pages || 1 pages.value = page?.pages || 1
list.value = (page?.records || []).map((f) => ({ list.value = (page?.records || []).map((f) => ({
@ -237,10 +238,10 @@ async function submit() {
try { try {
if (editingId.value) { if (editingId.value) {
payload.status = form.status payload.status = form.status
await request.put(`/v1/firmwares/${editingId.value}`, payload)
await request.put(urls.FIRMWARE(editingId.value), payload)
ui.toast('固件已更新') ui.toast('固件已更新')
} else { } else {
await request.post('/v1/firmwares', payload)
await request.post(urls.FIRMWARES, payload)
ui.toast(form.status === 'released' ? '固件已新增并发布' : '固件已新增(草稿状态)') ui.toast(form.status === 'released' ? '固件已新增并发布' : '固件已新增(草稿状态)')
} }
modalVisible.value = false modalVisible.value = false
@ -254,7 +255,7 @@ async function remove(f) {
const ok = await ui.confirm(`确认删除固件版本 ${f.version}`, '删除后不可恢复,已绑定该版本的设备不受影响。') const ok = await ui.confirm(`确认删除固件版本 ${f.version}`, '删除后不可恢复,已绑定该版本的设备不受影响。')
if (!ok) return if (!ok) return
try { try {
await request.delete(`/v1/firmwares/${f.id}`)
await request.delete(urls.FIRMWARE(f.id))
ui.toast('固件已删除') ui.toast('固件已删除')
await load() await load()
} catch (e) { } catch (e) {

11
src/views/LoginView/LoginView.vue

@ -2,6 +2,7 @@
import { reactive, ref } from 'vue' import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import request from '@/utils/http' import request from '@/utils/http'
import * as urls from '@/config/urls'
import { useUserStore } from '@/stores/modules/userStore' import { useUserStore } from '@/stores/modules/userStore'
import { useUiStore } from '@/stores/modules/uiStore' import { useUiStore } from '@/stores/modules/uiStore'
@ -64,7 +65,7 @@ async function submitLogin() {
} }
loading.value = true loading.value = true
try { try {
const data = await request.post('/v1/auth/login', {
const data = await request.post(urls.AUTH_LOGIN, {
phone: account.value, phone: account.value,
password: password.value, password: password.value,
}) })
@ -94,7 +95,7 @@ async function submitRegister() {
} }
loading.value = true loading.value = true
try { try {
const data = await request.post('/v1/auth/register', {
const data = await request.post(urls.AUTH_REGISTER, {
name: reg.company, name: reg.company,
phone: reg.phone, phone: reg.phone,
email: reg.email, email: reg.email,
@ -128,7 +129,7 @@ function sendLoginCode() {
loginCodeTimer.value -= 1 loginCodeTimer.value -= 1
if (loginCodeTimer.value <= 0) clearInterval(timer) if (loginCodeTimer.value <= 0) clearInterval(timer)
}, 1000) }, 1000)
request.post('/v1/auth/sms-code', { phone: smsPhone.value }).catch(() => {})
request.post(urls.AUTH_SMS_CODE, { phone: smsPhone.value }).catch(() => {})
ui.toast('短信验证码已发送') ui.toast('短信验证码已发送')
} }
@ -137,7 +138,7 @@ function sendForgotCode() {
forgotError.value = '请先输入手机号。' forgotError.value = '请先输入手机号。'
return return
} }
request.post('/v1/auth/sms-code', { phone: forgot.phone }).catch(() => {})
request.post(urls.AUTH_SMS_CODE, { phone: forgot.phone }).catch(() => {})
ui.toast('短信验证码已发送') ui.toast('短信验证码已发送')
} }
@ -152,7 +153,7 @@ async function submitForgot() {
return false return false
} }
try { try {
await request.post('/v1/auth/reset-password', {
await request.post(urls.AUTH_RESET_PASSWORD, {
phone: forgot.phone, phone: forgot.phone,
smsCode: forgot.code, smsCode: forgot.code,
newPassword: forgot.password, newPassword: forgot.password,

7
src/views/MediaView/MediaView.vue

@ -69,6 +69,7 @@ import { onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useUiStore } from '@/stores/modules/uiStore' import { useUiStore } from '@/stores/modules/uiStore'
import request from '@/utils/http' import request from '@/utils/http'
import * as urls from '@/config/urls'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -105,8 +106,8 @@ function fmtSize(bytes) {
async function load() { async function load() {
try { try {
const [livePage, videoPage] = await Promise.all([ const [livePage, videoPage] = await Promise.all([
request.get('/v1/live', { params: { pageNum: 1, pageSize: 100 } }),
request.get('/v1/videos', { params: { pageNum: 1, pageSize: 100 } })
request.get(urls.LIVE, { params: { pageNum: 1, pageSize: 100 } }),
request.get(urls.VIDEOS, { params: { pageNum: 1, pageSize: 100 } })
]) ])
liveStreams.value = (livePage?.records || []).map((s) => ({ liveStreams.value = (livePage?.records || []).map((s) => ({
id: s.id, id: s.id,
@ -131,7 +132,7 @@ onMounted(load)
async function download(v) { async function download(v) {
try { try {
const res = await request.post(`/v1/videos/${v.id}/download`)
const res = await request.post(urls.VIDEO_DOWNLOAD(v.id))
if (res?.downloadUrl) { if (res?.downloadUrl) {
window.open(res.downloadUrl, '_blank') window.open(res.downloadUrl, '_blank')
ui.toast('开始下载:' + v.name) ui.toast('开始下载:' + v.name)

3
src/views/OperationLogsView/OperationLogsView.vue

@ -64,6 +64,7 @@
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useUiStore } from '@/stores/modules/uiStore' import { useUiStore } from '@/stores/modules/uiStore'
import request from '@/utils/http' import request from '@/utils/http'
import * as urls from '@/config/urls'
const ui = useUiStore() const ui = useUiStore()
const list = ref([]) const list = ref([])
@ -122,7 +123,7 @@ async function load() {
const params = { pageNum: pageNum.value, pageSize: pageSize.value } const params = { pageNum: pageNum.value, pageSize: pageSize.value }
if (keyword.value) params.keyword = keyword.value if (keyword.value) params.keyword = keyword.value
if (statusFilter.value) params.status = statusFilter.value if (statusFilter.value) params.status = statusFilter.value
const page = await request.get('/v1/commands', { params })
const page = await request.get(urls.COMMANDS, { params })
total.value = page?.total || 0 total.value = page?.total || 0
pages.value = page?.pages || 1 pages.value = page?.pages || 1
list.value = (page?.records || []).map((c) => { list.value = (page?.records || []).map((c) => {

135
src/views/ReplayView/ReplayView.vue

@ -16,17 +16,7 @@
</header> </header>
<div class="replay-map"> <div class="replay-map">
<div class="replay-map-base"></div>
<template v-if="points.length">
<svg class="replay-route-layer" viewBox="0 0 100 100" preserveAspectRatio="none">
<polyline class="replay-route-shadow" :points="polyline" />
<polyline class="replay-route-line" :points="polyline" />
</svg>
<svg class="replay-route-layer replay-waypoints" viewBox="0 0 100 100" preserveAspectRatio="none">
<circle v-for="(p, i) in waypointDots" :key="i" :cx="p.x" :cy="p.y" :r="i === 0 || i === waypointDots.length - 1 ? 1.4 : 1.1" />
</svg>
</template>
<div v-else class="table-empty replay-empty">该执行记录暂无轨迹数据</div>
<div v-if="loaded && !points.length" class="table-empty replay-empty">该执行记录暂无轨迹数据</div>
<button class="replay-map-back" @click="router.push({ name: 'tasks', query: { view: 'records' } })"><svg><use href="#i-chevron" /></svg>返回</button> <button class="replay-map-back" @click="router.push({ name: 'tasks', query: { view: 'records' } })"><svg><use href="#i-chevron" /></svg>返回</button>
<div class="replay-map-legend"> <div class="replay-map-legend">
@ -52,10 +42,16 @@
</template> </template>
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import * as maptalks from 'maptalks'
import { useUiStore } from '@/stores/modules/uiStore' import { useUiStore } from '@/stores/modules/uiStore'
import request from '@/utils/http' import request from '@/utils/http'
import * as urls from '@/config/urls'
import commonRefs from '@/utils/commonRefs'
import mapHelper from '@/core/mapHelper'
const LAYER_ID = 'replay-trajectory'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -63,6 +59,10 @@ const ui = useUiStore()
const execution = ref(null) const execution = ref(null)
const points = ref([]) const points = ref([])
const loaded = ref(false)
let mapInstance = null
let trajLayer = null
const recordName = computed(() => execution.value?.taskId || '执行记录') const recordName = computed(() => execution.value?.taskId || '执行记录')
const deviceName = computed(() => execution.value?.droneSn || execution.value?.dockId || '--') const deviceName = computed(() => execution.value?.droneSn || execution.value?.dockId || '--')
@ -81,24 +81,6 @@ const durationLabel = computed(() => {
return `${min} min` return `${min} min`
}) })
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) * 88 + 6
const y = ((bounds.maxLat - lat) / latSpan) * 88 + 6
return { x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 }
}
const waypointDots = computed(() => {
if (!points.value.length) return []
const lons = points.value.map((p) => p.longitude)
const lats = points.value.map((p) => p.latitude)
const bounds = { minLon: Math.min(...lons), maxLon: Math.max(...lons), minLat: Math.min(...lats), maxLat: Math.max(...lats) }
return points.value.map((p) => project(p.longitude, p.latitude, bounds))
})
const polyline = computed(() => waypointDots.value.map((p) => `${p.x},${p.y}`).join(' '))
const avgAltitude = computed(() => { const avgAltitude = computed(() => {
if (!points.value.length) return '--' if (!points.value.length) return '--'
return (points.value.reduce((s, p) => s + (p.altitude || 0), 0) / points.value.length).toFixed(1) return (points.value.reduce((s, p) => s + (p.altitude || 0), 0) / points.value.length).toFixed(1)
@ -112,16 +94,105 @@ const lastBattery = computed(() => {
return points.value[points.value.length - 1].batteryPct ?? '--' return points.value[points.value.length - 1].batteryPct ?? '--'
}) })
function toCoords(list) {
return list
.map((p) => [Number(p.longitude), Number(p.latitude)])
.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat))
}
function drawTrajectory(map, list) {
const existing = map.getLayer(LAYER_ID)
if (existing) existing.remove()
const coords = toCoords(list)
trajLayer = new maptalks.VectorLayer(LAYER_ID, [], { zIndex: 130, forceRenderOnMoving: true })
trajLayer.addTo(map)
if (!coords.length) return
if (coords.length >= 2) {
new maptalks.LineString(coords, {
symbol: {
lineColor: '#2b79c8',
lineWidth: 3,
lineDasharray: [8, 6],
lineOpacity: 0.95,
},
}).addTo(trajLayer)
}
coords.forEach((c, i) => {
const isStart = i === 0
const isEnd = i === coords.length - 1
const color = isStart ? '#21a06a' : isEnd ? '#c56c28' : '#2b79c8'
const markerType = isStart || isEnd ? 'ellipse' : 'ellipse'
const size = isStart || isEnd ? 12 : 7
new maptalks.Marker(c, {
symbol: {
markerType,
markerFill: color,
markerLineColor: '#fff',
markerLineWidth: 2,
markerWidth: size,
markerHeight: size,
},
}).addTo(trajLayer)
})
if (coords.length === 1) {
map.animateTo({ center: coords[0], zoom: Math.max(map.getZoom(), 16) }, { duration: 400 })
return
}
const extent = new maptalks.Extent(
Math.min(...coords.map((c) => c[0])),
Math.min(...coords.map((c) => c[1])),
Math.max(...coords.map((c) => c[0])),
Math.max(...coords.map((c) => c[1])),
)
map.fitExtent(extent, 0, { animation: true, duration: 400, padding: [80, 80, 80, 80] })
}
async function setupMap() {
const map = await commonRefs.getRef('map')
if (!map) {
ui.toast('地图未初始化(缺少天地图 token)')
return
}
mapInstance = map
mapHelper.toggleMapMode({ is2D: true })
drawTrajectory(map, points.value)
}
function teardownMap() {
if (mapInstance) {
const layer = mapInstance.getLayer(LAYER_ID)
if (layer) layer.remove()
}
trajLayer = null
mapInstance = null
commonRefs.clearPendingList('map')
}
onMounted(async () => { onMounted(async () => {
try { try {
const key = route.params.key
const [ex, traj] = await Promise.all([ const [ex, traj] = await Promise.all([
request.get(`/v1/executions/${route.params.key}`),
request.get(`/v1/executions/${route.params.key}/trajectory`)
request.get(urls.EXECUTION(key)),
request.get(urls.EXECUTION_TRAJECTORY(key)),
]) ])
execution.value = ex execution.value = ex
points.value = traj?.points || [] points.value = traj?.points || []
loaded.value = true
await setupMap()
} catch (e) { } catch (e) {
loaded.value = true
ui.toast(e.message || '加载回放数据失败') ui.toast(e.message || '加载回放数据失败')
} }
}) })
onUnmounted(() => {
teardownMap()
})
</script> </script>

3
src/views/SystemView/SystemView.vue

@ -62,6 +62,7 @@ import { computed, onMounted, ref } from 'vue'
import { useUiStore } from '@/stores/modules/uiStore' import { useUiStore } from '@/stores/modules/uiStore'
import { useUserStore } from '@/stores/modules/userStore' import { useUserStore } from '@/stores/modules/userStore'
import request from '@/utils/http' import request from '@/utils/http'
import * as urls from '@/config/urls'
const ui = useUiStore() const ui = useUiStore()
const userStore = useUserStore() const userStore = useUserStore()
@ -77,7 +78,7 @@ const adminInitial = computed(() => adminName.value.charAt(0))
async function load() { async function load() {
try { try {
await request.get('/health')
await request.get(urls.HEALTH)
backendUp.value = true backendUp.value = true
} catch (e) { } catch (e) {
backendUp.value = false backendUp.value = false

11
src/views/TasksView/TasksView.vue

@ -220,6 +220,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useUiStore } from '@/stores/modules/uiStore' import { useUiStore } from '@/stores/modules/uiStore'
import request from '@/utils/http' import request from '@/utils/http'
import { useDeviceStore } from '@/stores/modules/devicesStore' import { useDeviceStore } from '@/stores/modules/devicesStore'
import * as urls from '@/config/urls'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -298,9 +299,9 @@ async function load() {
try { try {
await deviceStore.load() await deviceStore.load()
const [taskPage, execPage, routePage] = await Promise.all([ const [taskPage, execPage, routePage] = await Promise.all([
request.get('/v1/tasks', { params: { pageNum: 1, pageSize: 100 } }),
request.get('/v1/executions', { params: { pageNum: 1, pageSize: 100 } }),
request.get('/v1/routes', { params: { pageNum: 1, pageSize: 100 } })
request.get(urls.TASKS, { params: { pageNum: 1, pageSize: 100 } }),
request.get(urls.EXECUTIONS, { params: { pageNum: 1, pageSize: 100 } }),
request.get(urls.ROUTES, { params: { pageNum: 1, pageSize: 100 } })
]) ])
plans.value = (taskPage?.records || []).map((t) => { plans.value = (taskPage?.records || []).map((t) => {
@ -509,7 +510,7 @@ async function submitRoute() {
} }
routeSaving.value = true routeSaving.value = true
try { try {
await request.post('/v1/routes', {
await request.post(urls.ROUTES, {
name, name,
waypoints: routePoints.value.map((point) => ({ longitude: point.longitude, latitude: point.latitude, altitude: point.altitude, speed: point.speed, yaw: 0, holdSec: point.turnMode === 'stop' ? 1 : 0 })) waypoints: routePoints.value.map((point) => ({ longitude: point.longitude, latitude: point.latitude, altitude: point.altitude, speed: point.speed, yaw: 0, holdSec: point.turnMode === 'stop' ? 1 : 0 }))
}) })
@ -560,7 +561,7 @@ async function submitCreateTask() {
creatingTask.value = true creatingTask.value = true
try { try {
await request.post('/v1/tasks', {
await request.post(urls.TASKS, {
name, name,
dockId: taskForm.dockId, dockId: taskForm.dockId,
routeId: Number(taskForm.routeId) || 0, routeId: Number(taskForm.routeId) || 0,

3
src/views/UsersView/UsersView.vue

@ -67,6 +67,7 @@
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useUiStore } from '@/stores/modules/uiStore' import { useUiStore } from '@/stores/modules/uiStore'
import request from '@/utils/http' import request from '@/utils/http'
import * as urls from '@/config/urls'
const ui = useUiStore() const ui = useUiStore()
const users = ref([]) const users = ref([])
@ -91,7 +92,7 @@ async function load() {
const params = { pageNum: pageNum.value, pageSize: pageSize.value } const params = { pageNum: pageNum.value, pageSize: pageSize.value }
if (keyword.value) params.keyword = keyword.value if (keyword.value) params.keyword = keyword.value
if (roleFilter.value) params.role = roleFilter.value if (roleFilter.value) params.role = roleFilter.value
const page = await request.get('/v1/users', { params })
const page = await request.get(urls.USERS, { params })
total.value = page?.total || 0 total.value = page?.total || 0
pages.value = page?.pages || 1 pages.value = page?.pages || 1
users.value = (page?.records || []).map((u) => ({ users.value = (page?.records || []).map((u) => ({

Loading…
Cancel
Save