index.vue 35 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240
  1. <template>
  2. <div class="map-list-container">
  3. <!-- 顶部工具栏 -->
  4. <div class="toolbar-section">
  5. <!-- 搜索和筛选区 -->
  6. <div class="toolbar-filters">
  7. <el-input
  8. v-model="searchKeyword"
  9. placeholder="搜索地图名称..."
  10. :prefix-icon="Search"
  11. clearable
  12. class="search-input"
  13. @input="handleSearch"
  14. />
  15. <el-select
  16. v-model="statusFilter"
  17. placeholder="状态筛选"
  18. clearable
  19. class="status-filter"
  20. @change="handleFilterChange"
  21. >
  22. <el-option label="全部状态" value="all" />
  23. <el-option label="正常" value="available" />
  24. <el-option label="不可用" value="unavailable" />
  25. <el-option label="正在建图" value="building" />
  26. <el-option label="正在录制" value="recording" />
  27. <el-option label="实时建图" value="slaming" />
  28. </el-select>
  29. <el-select
  30. v-model="sortField"
  31. placeholder="排序方式"
  32. class="sort-select"
  33. @change="handleSortChange"
  34. >
  35. <el-option label="最近修改" value="updated" />
  36. <el-option label="名称" value="name" />
  37. <el-option label="状态" value="status" />
  38. </el-select>
  39. </div>
  40. <!-- 操作按钮区 -->
  41. <div class="toolbar-actions">
  42. <el-button
  43. type="default"
  44. :icon="Upload"
  45. @click="handleImport"
  46. >
  47. 导入地图
  48. </el-button>
  49. <el-button
  50. type="primary"
  51. :icon="Plus"
  52. @click="handleCreate"
  53. >
  54. 新建地图
  55. </el-button>
  56. <el-button
  57. type="success"
  58. :icon="MapLocation"
  59. @click="handleSlam"
  60. >
  61. 实时建图
  62. </el-button>
  63. <el-button-group class="view-toggle">
  64. <el-button
  65. :type="viewMode === 'card' ? 'primary' : 'default'"
  66. :icon="Grid"
  67. @click="setViewMode('card')"
  68. title="卡片视图"
  69. />
  70. <el-button
  71. :type="viewMode === 'table' ? 'primary' : 'default'"
  72. :icon="Menu"
  73. @click="setViewMode('table')"
  74. title="表格视图"
  75. />
  76. </el-button-group>
  77. </div>
  78. </div>
  79. <!-- 主要内容区域 -->
  80. <div class="main-content">
  81. <!-- 卡片视图 -->
  82. <template v-if="viewMode === 'card'">
  83. <div v-loading="loading" class="card-grid" :class="{ compact: isCompactMode }">
  84. <div v-if="mapList.length === 0 && !loading" class="empty-state">
  85. <el-icon class="empty-icon"><MapLocation/></el-icon>
  86. <h3>暂无地图</h3>
  87. <p>{{ hasSearchOrFilter ? '没有找到符合条件的地图' : '您还没有创建任何地图' }}</p>
  88. <el-button v-if="!hasSearchOrFilter" type="primary" @click="handleCreate">新建地图</el-button>
  89. </div>
  90. <el-card
  91. v-for="item in displayedList"
  92. :key="item.id"
  93. class="map-card"
  94. shadow="hover"
  95. @click.stop="handleCardClick(item)"
  96. >
  97. <div class="card-thumbnail">
  98. <img v-if="item.thumbUrl" :src="item.thumbUrl" alt="缩略图" />
  99. <div v-else class="thumbnail-placeholder">
  100. <el-icon :size="40"><MapLocation/></el-icon>
  101. </div>
  102. </div>
  103. <div class="card-info">
  104. <h4 class="map-name">{{ item.map || item.mapName || item.name }}</h4>
  105. <el-tag :type="getStatusType(item.state)" size="small">{{ getStatusText(item.state) }}</el-tag>
  106. </div>
  107. <div class="card-actions" @click.stop>
  108. <el-button link type="primary" @click.stop="handleNavigation(item)">导航</el-button>
  109. <el-button link type="primary" @click.stop="handleEdit(item)">编辑</el-button>
  110. <el-dropdown trigger="click" @command="cmd => handleCommand(item, cmd)">
  111. <el-button link type="primary">更多<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
  112. <template #dropdown>
  113. <el-dropdown-menu>
  114. <el-dropdown-item command="rename">重命名</el-dropdown-item>
  115. <el-dropdown-item command="download">下载地图</el-dropdown-item>
  116. <el-dropdown-item command="build">构建地图</el-dropdown-item>
  117. <el-dropdown-item command="calibrate">坐标系标定</el-dropdown-item>
  118. <el-dropdown-item command="delete" divided>删除</el-dropdown-item>
  119. </el-dropdown-menu>
  120. </template>
  121. </el-dropdown>
  122. </div>
  123. </el-card>
  124. </div>
  125. </template>
  126. <!-- 表格视图 -->
  127. <template v-else>
  128. <el-table
  129. v-loading="loading"
  130. :data="displayedList"
  131. row-key="id"
  132. class="map-table"
  133. stripe
  134. >
  135. <el-table-column type="index" label="序号" width="80" align="center" />
  136. <el-table-column prop="map" label="地图名称" min-width="200" show-overflow-tooltip />
  137. <el-table-column label="状态" width="120" align="center">
  138. <template #default="{ row }">
  139. <el-tag :type="getStatusType(row.state)" size="small">{{ getStatusText(row.state) }}</el-tag>
  140. </template>
  141. </el-table-column>
  142. <el-table-column label="操作" width="200" align="center">
  143. <template #default="{ row }">
  144. <el-button link type="primary" @click="handleNavigation(row)">导航</el-button>
  145. <el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
  146. <el-button link type="primary" @click="handleCommand(row, 'delete')">删除</el-button>
  147. </template>
  148. </el-table-column>
  149. </el-table>
  150. </template>
  151. </div>
  152. <!-- 分页 -->
  153. <div v-if="totalCount > pageSize" class="pagination-section">
  154. <el-pagination
  155. v-model:current-page="currentPage"
  156. v-model:page-size="pageSize"
  157. :total="totalCount"
  158. :page-sizes="[12, 24, 48, 96]"
  159. layout="total, sizes, prev, pager, next, jumper"
  160. @size-change="handleSizeChange"
  161. @current-change="handlePageChange"
  162. />
  163. </div>
  164. <!-- 新建地图对话框(事后建图模式:先录制再构建) -->
  165. <el-dialog v-model="createDialogVisible" title="新建地图" width="500px" append-to-body>
  166. <el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="100px">
  167. <el-form-item label="地图名称" prop="mapName">
  168. <el-input
  169. v-model="createForm.mapName"
  170. placeholder="请输入地图名称"
  171. />
  172. </el-form-item>
  173. <el-form-item label="建图模式" prop="mode">
  174. <el-radio-group v-model="createMode" size="small">
  175. <el-radio label="record">录制模式(事后建图)</el-radio>
  176. <el-radio label="slam">实时建图模式</el-radio>
  177. </el-radio-group>
  178. </el-form-item>
  179. </el-form>
  180. <el-alert
  181. v-if="createMode === 'record'"
  182. type="info"
  183. :closable="false"
  184. show-icon
  185. style="margin-top: 8px;"
  186. >
  187. <template #title>
  188. 录制模式说明:
  189. <ul style="margin: 4px 0 0 16px; padding: 0;">
  190. <li>点击"开始录制"后,系统将启动传感器数据录制</li>
  191. <li>请控制机器人在场地内移动,采集环境数据</li>
  192. <li>录制完成后点击"停止录制"</li>
  193. <li>然后进行"构建地图"操作生成最终地图</li>
  194. </ul>
  195. </template>
  196. </el-alert>
  197. <template #footer>
  198. <el-button @click="createDialogVisible = false">取消</el-button>
  199. <el-button type="primary" @click="submitCreate">
  200. {{ createMode === 'record' ? '开始录制' : '开始实时建图' }}
  201. </el-button>
  202. </template>
  203. </el-dialog>
  204. <!-- 录制进度对话框 -->
  205. <el-dialog
  206. v-model="recordingDialogVisible"
  207. title="正在录制地图"
  208. width="520px"
  209. append-to-body
  210. :close-on-click-modal="false"
  211. :show-close="false"
  212. >
  213. <div class="dialog-content">
  214. <div class="info-row">
  215. <span class="label">地图名称:</span>
  216. <span class="value"><strong>{{ recordingMapName }}</strong></span>
  217. </div>
  218. <div class="info-row">
  219. <span class="label">录制大小:</span>
  220. <span class="value"><strong>{{ formatFileSize(recordingProgress) }}</strong></span>
  221. </div>
  222. <div class="info-row">
  223. <span class="label">当前状态:</span>
  224. <el-tag type="warning" size="small">正在录制中</el-tag>
  225. </div>
  226. </div>
  227. <el-alert
  228. type="info"
  229. :closable="false"
  230. show-icon
  231. style="margin-top: 16px;"
  232. >
  233. <template #title>
  234. 录制提示:
  235. <ul style="margin: 4px 0 0 16px; padding: 0;">
  236. <li>请控制机器人移动,采集环境数据</li>
  237. <li>录制过程中请保持设备连接稳定</li>
  238. <li>完成后请点击"停止录制"按钮</li>
  239. </ul>
  240. </template>
  241. </el-alert>
  242. <template #footer>
  243. <el-button @click="recordingDialogVisible = false" :disabled="true" size="small">关闭</el-button>
  244. <el-button type="warning" @click="handleStopRecord" icon="VideoPause">停止录制</el-button>
  245. </template>
  246. </el-dialog>
  247. <!-- 构建地图进度对话框 -->
  248. <el-dialog
  249. v-model="buildingDialogVisible"
  250. title="正在构建地图"
  251. width="520px"
  252. append-to-body
  253. :close-on-click-modal="false"
  254. :show-close="false"
  255. >
  256. <div class="dialog-content">
  257. <div class="info-row">
  258. <span class="label">地图名称:</span>
  259. <span class="value"><strong>{{ buildingMapName }}</strong></span>
  260. </div>
  261. <div class="info-row">
  262. <span class="label">构建进度:</span>
  263. <span class="value"><strong>{{ buildingProgress }}%</strong></span>
  264. </div>
  265. <div class="progress-wrapper">
  266. <el-progress :percentage="buildingProgress" :stroke-width="16" status="warning" />
  267. </div>
  268. </div>
  269. <el-alert
  270. type="info"
  271. :closable="false"
  272. show-icon
  273. style="margin-top: 16px;"
  274. >
  275. <template #title>
  276. 构建提示:
  277. <ul style="margin: 4px 0 0 16px; padding: 0;">
  278. <li>正在处理传感器数据并生成地图文件</li>
  279. <li>构建时间取决于数据量大小</li>
  280. <li>请耐心等待,不要关闭此窗口</li>
  281. </ul>
  282. </template>
  283. </el-alert>
  284. <template #footer>
  285. <el-button @click="buildingDialogVisible = false" :disabled="true" size="small">关闭</el-button>
  286. <el-button type="danger" @click="handleStopBuild" icon="Close">取消构建</el-button>
  287. </template>
  288. </el-dialog>
  289. <!-- 实时建图对话框 -->
  290. <el-dialog
  291. v-model="slamDialogVisible"
  292. title="实时建图进行中"
  293. width="520px"
  294. append-to-body
  295. :close-on-click-modal="false"
  296. :show-close="false"
  297. >
  298. <div class="dialog-content">
  299. <div class="info-row">
  300. <span class="label">地图名称:</span>
  301. <span class="value"><strong>{{ slamMapName }}</strong></span>
  302. </div>
  303. <div class="info-row">
  304. <span class="label">当前状态:</span>
  305. <el-tag type="success" size="small">正在实时建图</el-tag>
  306. </div>
  307. </div>
  308. <el-alert
  309. type="success"
  310. :closable="false"
  311. show-icon
  312. style="margin-top: 16px;"
  313. >
  314. <template #title>
  315. 实时建图提示:
  316. <ul style="margin: 4px 0 0 16px; padding: 0;">
  317. <li>请控制机器人在场地内移动</li>
  318. <li>系统正在实时构建三维地图</li>
  319. <li>完成后点击"停止建图"自动生成地图文件</li>
  320. <li>停止后需要较长时间生成最终地图</li>
  321. </ul>
  322. </template>
  323. </el-alert>
  324. <template #footer>
  325. <el-button type="success" @click="handleStopSlam" icon="Check">停止建图</el-button>
  326. </template>
  327. </el-dialog>
  328. <!-- 下载对话框 -->
  329. <el-dialog v-model="downloadDialogVisible" title="下载地图" width="500px" append-to-body>
  330. <p style="margin-bottom: 16px;">请选择要下载的地图组件:</p>
  331. <el-checkbox-group v-model="downLoadTypes">
  332. <el-checkbox v-for="comp in availableComponents" :key="comp.label" :label="comp.label">{{ comp.name || comp.label }}</el-checkbox>
  333. </el-checkbox-group>
  334. <template #footer>
  335. <el-button @click="downloadDialogVisible = false">取消</el-button>
  336. <el-button type="primary" @click="submitDownload">开始下载</el-button>
  337. </template>
  338. </el-dialog>
  339. <!-- 重命名对话框 -->
  340. <el-dialog v-model="renameDialogVisible" title="重命名地图" width="400px" append-to-body>
  341. <el-form label-width="100px">
  342. <el-form-item label="新名称">
  343. <el-input v-model="newMapName" placeholder="请输入新名称" />
  344. </el-form-item>
  345. </el-form>
  346. <template #footer>
  347. <el-button @click="renameDialogVisible = false">取消</el-button>
  348. <el-button type="primary" @click="submitRename">确定</el-button>
  349. </template>
  350. </el-dialog>
  351. <!-- 构建地图配置对话框 -->
  352. <el-dialog v-model="buildConfigDialogVisible" title="构建地图" width="500px" append-to-body>
  353. <el-form label-width="120px">
  354. <el-form-item label="地图名称">
  355. <span>{{ buildConfigMapName }}</span>
  356. </el-form-item>
  357. <el-form-item label="构建步骤">
  358. <el-checkbox-group v-model="selectedBuildSteps">
  359. <el-checkbox label="recon">重建(Recon)</el-checkbox>
  360. <el-checkbox label="kfmix">关键帧融合(KFMix)</el-checkbox>
  361. <el-checkbox label="octomap">八叉树地图(Octomap)</el-checkbox>
  362. <el-checkbox label="tilemap">瓦片地图(TileMap)</el-checkbox>
  363. <el-checkbox label="potree">点云地图(Potree)</el-checkbox>
  364. </el-checkbox-group>
  365. </el-form-item>
  366. <el-form-item>
  367. <el-alert type="info" :closable="false" show-icon>
  368. <template #title>
  369. 默认执行全部构建步骤,如只需构建部分步骤请取消选择对应选项
  370. </template>
  371. </el-alert>
  372. </el-form-item>
  373. </el-form>
  374. <template #footer>
  375. <el-button @click="buildConfigDialogVisible = false">取消</el-button>
  376. <el-button type="primary" @click="handleStartBuild">开始构建</el-button>
  377. </template>
  378. </el-dialog>
  379. </div>
  380. </template>
  381. <script setup>
  382. import { ref, computed, reactive, onMounted, onUnmounted, watch } from 'vue'
  383. import { useRouter, useRoute } from 'vue-router'
  384. import { ElMessage, ElMessageBox } from 'element-plus'
  385. import { Search, Plus, Upload, MapLocation, Menu, Grid, ArrowDown, VideoPause, Close, Check } from '@element-plus/icons-vue'
  386. import { useMapList, useMapControl } from '@/composables/useMap'
  387. import { useWebSocket } from '@/composables/useWebSocket'
  388. import * as mapApi from '@/api/robot/map'
  389. const router = useRouter()
  390. const route = useRoute()
  391. // 地图列表相关
  392. const { loading, mapList, fetchMapList, fetchMapThumbnail, renameMap, removeMap, downloadMap } = useMapList()
  393. // 地图控制相关
  394. const deviceId = ref('ld000001') // TODO: 从配置或选择器获取
  395. // ASM 录制/构建/SLAM 状态管理
  396. const {
  397. isRecording, isBuilding, isSlaming,
  398. recordingMapName, buildingMapName, slamMapName, buildingProgress,
  399. startRecord, stopRecord,
  400. startBuild, stopBuild, completedBuild,
  401. startSlam, stopSlam,
  402. updateBuildingProgress
  403. } = useMapControl(deviceId.value)
  404. // WebSocket 连接和消息处理
  405. const {
  406. initWebSocket,
  407. disconnect: disconnectWs,
  408. subscribeToDevice,
  409. } = useWebSocket()
  410. // 视图状态
  411. const viewMode = ref('card')
  412. const isCompactMode = ref(false)
  413. const searchKeyword = ref('')
  414. const statusFilter = ref('all')
  415. const sortField = ref('updated')
  416. const currentPage = ref(1)
  417. const pageSize = ref(12)
  418. // 对话框状态
  419. const createDialogVisible = ref(false)
  420. const recordingDialogVisible = ref(false)
  421. const buildingDialogVisible = ref(false)
  422. const slamDialogVisible = ref(false)
  423. const downloadDialogVisible = ref(false)
  424. const renameDialogVisible = ref(false)
  425. const buildConfigDialogVisible = ref(false)
  426. // 录制进度
  427. const recordingProgress = ref(0)
  428. // 表单
  429. const createFormRef = ref()
  430. const createForm = reactive({ mapName: '' })
  431. const createMode = ref('record')
  432. const createRules = {
  433. mapName: [{ required: true, message: '请输入地图名称', trigger: 'blur' }]
  434. }
  435. // 下载相关
  436. const downLoadTypes = ref([])
  437. const availableComponents = ref([])
  438. const currentDownloadMap = ref('')
  439. // 重命名相关
  440. const currentRenameMap = ref('')
  441. const newMapName = ref('')
  442. // 构建配置相关
  443. const buildConfigMapName = ref('')
  444. const selectedBuildSteps = ref(['recon', 'kfmix', 'octomap', 'tilemap', 'potree'])
  445. // 等待响应状态
  446. const waitingForResponse = ref(false)
  447. // 计算属性
  448. const hasSearchOrFilter = computed(() => searchKeyword.value.trim() || statusFilter.value !== 'all')
  449. const filteredList = computed(() => {
  450. let list = [...mapList.value]
  451. // 搜索过滤
  452. if (searchKeyword.value.trim()) {
  453. const keyword = searchKeyword.value.toLowerCase()
  454. list = list.filter(item => {
  455. const name = (item.map || item.mapName || item.name || '').toLowerCase()
  456. return name.includes(keyword)
  457. })
  458. }
  459. // 状态筛选
  460. if (statusFilter.value !== 'all') {
  461. list = list.filter(item => (item.state || item.status) === statusFilter.value)
  462. }
  463. return list
  464. })
  465. const totalCount = computed(() => filteredList.value.length)
  466. const displayedList = computed(() => {
  467. const start = (currentPage.value - 1) * pageSize.value
  468. return filteredList.value.slice(start, start + pageSize.value)
  469. })
  470. // 状态配置
  471. const statusConfig = {
  472. available: { type: 'success', text: '正常' },
  473. unavailable: { type: 'danger', text: '不可用' },
  474. building: { type: 'warning', text: '正在建图' },
  475. recording: { type: 'warning', text: '正在录制' },
  476. slaming: { type: 'success', text: '实时建图' }
  477. }
  478. function getStatusType(state) {
  479. return statusConfig[state]?.type || 'info'
  480. }
  481. function getStatusText(state) {
  482. return statusConfig[state]?.text || state || '未知'
  483. }
  484. // 格式化文件大小
  485. function formatFileSize(bytes) {
  486. if (!bytes || bytes === 0) return '0 B'
  487. const units = ['B', 'KB', 'MB', 'GB', 'TB']
  488. const k = 1024
  489. const i = Math.floor(Math.log(bytes) / Math.log(k))
  490. return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + units[i]
  491. }
  492. // 加载数据
  493. async function loadData() {
  494. try {
  495. await fetchMapList(deviceId.value)
  496. // 加载缩略图(仅获取前6个地图的缩略图用于卡片显示)
  497. const limit = Math.min(6, mapList.value.length)
  498. for (let i = 0; i < limit; i++) {
  499. const mapItem = mapList.value[i]
  500. const name = mapItem.map || mapItem.mapName || mapItem.name
  501. if (name && !mapItem.thumbUrl) {
  502. try {
  503. const thumbUrl = await fetchMapThumbnail(name)
  504. if (thumbUrl) {
  505. mapItem.thumbUrl = thumbUrl
  506. }
  507. } catch (e) {
  508. console.warn('获取缩略图失败:', name)
  509. }
  510. }
  511. }
  512. } catch (error) {
  513. console.error('加载数据失败:', error)
  514. }
  515. }
  516. // WebSocket 消息处理
  517. function handleWsMessage(type, message) {
  518. switch (type) {
  519. case 'asm':
  520. handleAsmMessage(message)
  521. break
  522. case 'map':
  523. // 地图列表更新时刷新
  524. loadData()
  525. break
  526. }
  527. }
  528. // 处理 ASM 消息
  529. function handleAsmMessage(message) {
  530. const { type, function: funcName, progress, state, data } = message
  531. if (type === 'asm_progress') {
  532. // 进度反馈
  533. if (funcName === 'SensorRecorder.start') {
  534. // 录制进度
  535. recordingProgress.value = progress || 0
  536. } else if (funcName === 'MapBuilder.start') {
  537. // 构建进度
  538. updateBuildingProgress(progress || 0)
  539. }
  540. } else if (type === 'asm_state') {
  541. // 状态反馈
  542. waitingForResponse.value = false
  543. // 开始录制状态反馈
  544. if (funcName === 'ASM.sensor_record.start') {
  545. if (state === 2) {
  546. ElMessage.success(`地图"${recordingMapName.value}"开始录制`)
  547. } else if (state === 3) {
  548. ElMessage.error('启动录制失败')
  549. resetRecordingState()
  550. }
  551. }
  552. // 停止录制状态反馈
  553. else if (funcName === 'ASM.sensor_record.stop') {
  554. if (state === 2) {
  555. ElMessage.success('录制已停止')
  556. resetRecordingState()
  557. loadData()
  558. } else if (state === 3) {
  559. ElMessage.error('停止录制失败')
  560. }
  561. }
  562. // 开始构建状态反馈
  563. else if (funcName === 'ASM.map_build.start') {
  564. if (state === 2) {
  565. ElMessage.success(`地图"${buildingMapName.value}"开始构建`)
  566. buildingDialogVisible.value = true
  567. } else if (state === 3) {
  568. ElMessage.error('启动构建失败')
  569. resetBuildingState()
  570. }
  571. }
  572. // 停止构建状态反馈
  573. else if (funcName === 'ASM.map_build.stop') {
  574. if (state === 2) {
  575. ElMessage.info('构建已取消')
  576. resetBuildingState()
  577. } else if (state === 3) {
  578. ElMessage.error('取消构建失败')
  579. }
  580. }
  581. // MapBuilder 完成
  582. else if (funcName === 'MapBuilder.start') {
  583. if (state === 2) {
  584. ElMessage.success('地图构建完成')
  585. resetBuildingState()
  586. completedBuild(buildConfigMapName.value,"MapBuilder.start").then((res) => {
  587. loadData()
  588. }).catch(() => {
  589. ElMessage.error('更新地图状态失败')
  590. })
  591. } else if (state === 3) {
  592. ElMessage.error('地图构建失败')
  593. resetBuildingState()
  594. }
  595. }
  596. // 开始实时建图状态反馈
  597. else if (funcName === 'ASM.map_slam.start') {
  598. if (state === 2) {
  599. ElMessage.success(`实时建图"${slamMapName.value}"已开始`)
  600. console.log("slamMapName.value1:", slamMapName.value);
  601. completedBuild(slamMapName.value,"ASM.map_slam.start").then((res) => {
  602. loadData()
  603. }).catch(() => {
  604. ElMessage.error('更新地图状态失败')
  605. })
  606. } else if (state === 3) {
  607. ElMessage.error('启动实时建图失败')
  608. resetSlamState()
  609. }
  610. }
  611. // 停止实时建图状态反馈
  612. else if (funcName === 'ASM.map_slam.stop') {
  613. if (state === 2) {
  614. console.log("slamMapName.value",slamMapName.value);
  615. ElMessage.success('实时建图已停止,正在生成地图...')
  616. completedBuild(slamMapName.value,"ASM.map_slam.stop").then((res) => {
  617. loadData()
  618. resetSlamState()
  619. }).catch(() => {
  620. ElMessage.error('更新地图状态失败')
  621. })
  622. } else if (state === 3) {
  623. ElMessage.error('停止实时建图失败')
  624. }
  625. }
  626. }
  627. }
  628. // 重置录制状态
  629. function resetRecordingState() {
  630. recordingDialogVisible.value = false
  631. recordingProgress.value = 0
  632. recordingMapName.value = ''
  633. }
  634. // 重置构建状态
  635. function resetBuildingState() {
  636. buildingDialogVisible.value = false
  637. buildingProgress.value = 0
  638. buildingMapName.value = ''
  639. }
  640. // 重置实时建图状态
  641. function resetSlamState() {
  642. slamDialogVisible.value = false
  643. slamMapName.value = ''
  644. }
  645. // 搜索/筛选/排序
  646. function handleSearch() {
  647. currentPage.value = 1
  648. }
  649. function handleFilterChange() {
  650. currentPage.value = 1
  651. }
  652. function handleSortChange() {
  653. currentPage.value = 1
  654. }
  655. function handleSizeChange(size) {
  656. pageSize.value = size
  657. currentPage.value = 1
  658. }
  659. function handlePageChange(page) {
  660. currentPage.value = page
  661. }
  662. function setViewMode(mode) {
  663. viewMode.value = mode
  664. localStorage.setItem('map-view-mode', mode)
  665. }
  666. // 新建地图
  667. function handleCreate() {
  668. createForm.mapName = ''
  669. createMode.value = 'record'
  670. createDialogVisible.value = true
  671. }
  672. async function submitCreate() {
  673. if (!createForm.mapName.trim()) {
  674. ElMessage.warning('请输入地图名称')
  675. return
  676. }
  677. createDialogVisible.value = false
  678. if (createMode.value === 'record') {
  679. // 录制模式
  680. recordingMapName.value = createForm.mapName
  681. waitingForResponse.value = true
  682. try {
  683. await startRecord(createForm.mapName)
  684. recordingDialogVisible.value = true
  685. // 30秒超时
  686. setTimeout(() => {
  687. if (waitingForResponse.value && !recordingDialogVisible.value) {
  688. waitingForResponse.value = false
  689. ElMessage.error('启动录制超时,请检查设备连接')
  690. }
  691. }, 30000)
  692. await loadData()
  693. } catch (e) {
  694. waitingForResponse.value = false
  695. resetRecordingState()
  696. }
  697. } else {
  698. // 实时建图模式
  699. slamMapName.value = createForm.mapName
  700. waitingForResponse.value = true
  701. try {
  702. await startSlam(createForm.mapName)
  703. slamDialogVisible.value = true
  704. // 30秒超时
  705. setTimeout(() => {
  706. if (waitingForResponse.value && !slamDialogVisible.value) {
  707. waitingForResponse.value = false
  708. ElMessage.error('启动实时建图超时,请检查设备连接')
  709. }
  710. }, 30000)
  711. await loadData()
  712. } catch (e) {
  713. waitingForResponse.value = false
  714. resetSlamState()
  715. }
  716. }
  717. }
  718. // 实时建图(工具栏按钮)
  719. async function handleSlam() {
  720. try {
  721. const { value: mapName } = await ElMessageBox.prompt('请输入地图名称', '实时建图', {
  722. confirmButtonText: '开始实时建图',
  723. cancelButtonText: '取消',
  724. inputPattern: /\S+/
  725. })
  726. slamMapName.value = mapName
  727. waitingForResponse.value = true
  728. await startSlam(mapName)
  729. slamDialogVisible.value = true
  730. setTimeout(() => {
  731. if (waitingForResponse.value && !slamDialogVisible.value) {
  732. waitingForResponse.value = false
  733. ElMessage.error('启动实时建图超时,请检查设备连接')
  734. }
  735. }, 30000)
  736. await loadData()
  737. } catch (e) {
  738. // 用户取消
  739. }
  740. }
  741. // 停止录制
  742. async function handleStopRecord() {
  743. try {
  744. await ElMessageBox.confirm('确认停止录制?停止后将无法继续录制。', '停止录制', {
  745. type: 'warning',
  746. confirmButtonText: '确认停止',
  747. cancelButtonText: '继续录制'
  748. })
  749. waitingForResponse.value = true
  750. await stopRecord(recordingMapName.value)
  751. setTimeout(() => {
  752. if (waitingForResponse.value) {
  753. waitingForResponse.value = false
  754. ElMessage.error('停止录制超时,请检查设备连接')
  755. }
  756. }, 30000)
  757. } catch (e) {
  758. // 用户取消
  759. }
  760. }
  761. // 停止构建
  762. async function handleStopBuild() {
  763. try {
  764. await ElMessageBox.confirm('确认取消构建?', '取消构建', {
  765. type: 'warning',
  766. confirmButtonText: '确认取消',
  767. cancelButtonText: '继续构建'
  768. })
  769. waitingForResponse.value = true
  770. await stopBuild()
  771. setTimeout(() => {
  772. if (waitingForResponse.value) {
  773. waitingForResponse.value = false
  774. ElMessage.error('取消构建超时,请检查设备连接')
  775. }
  776. }, 30000)
  777. } catch (e) {
  778. // 用户取消
  779. }
  780. }
  781. // 停止实时建图
  782. async function handleStopSlam() {
  783. try {
  784. await ElMessageBox.confirm(
  785. '确认停止实时建图?停止后将自动生成地图文件,这可能需要较长时间。',
  786. '停止实时建图',
  787. {
  788. type: 'warning',
  789. confirmButtonText: '确认停止',
  790. cancelButtonText: '继续建图'
  791. }
  792. )
  793. waitingForResponse.value = true
  794. await stopSlam()
  795. setTimeout(() => {
  796. if (waitingForResponse.value) {
  797. waitingForResponse.value = false
  798. ElMessage.warning('停止实时建图超时,但地图可能仍在后台生成中,请稍后查看地图列表')
  799. }
  800. }, 60000)
  801. } catch (e) {
  802. // 用户取消
  803. }
  804. }
  805. // 卡片点击
  806. function handleCardClick(item) {
  807. // 默认导航到编辑页
  808. handleEdit(item)
  809. }
  810. // 导航
  811. function handleNavigation(item) {
  812. const name = item.map || item.mapName || item.name
  813. console.log("item",name);
  814. router.push(`/map/maplist/navigation/${encodeURIComponent(name)}`)
  815. }
  816. // 编辑
  817. function handleEdit(item) {
  818. const name = item.map || item.mapName || item.name
  819. router.push(`/map/maplist/edit/${encodeURIComponent(name)}`)
  820. }
  821. // 标定
  822. function handleCalibrate(item) {
  823. const name = item.map || item.mapName || item.name
  824. router.push(`/map/maplist/calibration/${encodeURIComponent(name)}`)
  825. }
  826. // 更多操作
  827. async function handleCommand(item, cmd) {
  828. const name = item.map || item.mapName || item.name
  829. switch (cmd) {
  830. case 'rename':
  831. currentRenameMap.value = name
  832. newMapName.value = name
  833. renameDialogVisible.value = true
  834. break
  835. case 'download':
  836. try {
  837. const res = await mapApi.getMapComponents(name)
  838. if (res.code === 200 && res.data) {
  839. availableComponents.value = res.data
  840. downLoadTypes.value = res.data.map(c => c.label)
  841. currentDownloadMap.value = name
  842. downloadDialogVisible.value = true
  843. }
  844. } catch (e) {
  845. ElMessage.error('获取组件列表失败')
  846. }
  847. break
  848. case 'build':
  849. // 检查地图状态
  850. if (item.state === 'recording') {
  851. ElMessage.warning('地图正在录制中,请先停止录制后再构建')
  852. return
  853. }
  854. if (item.state === 'building') {
  855. ElMessage.warning('地图正在构建中')
  856. return
  857. }
  858. if (item.state === 'slaming') {
  859. ElMessage.warning('地图正在实时建图中')
  860. return
  861. }
  862. buildConfigMapName.value = name
  863. selectedBuildSteps.value = ['recon', 'kfmix', 'octomap', 'tilemap', 'potree']
  864. buildConfigDialogVisible.value = true
  865. break
  866. case 'calibrate':
  867. handleCalibrate(item)
  868. break
  869. case 'delete':
  870. try {
  871. await ElMessageBox.confirm(`确定要删除地图"${name}"吗?此操作不可恢复`, '删除确认', {
  872. type: 'warning'
  873. })
  874. await removeMap(name)
  875. } catch (e) {
  876. // 用户取消
  877. }
  878. break
  879. }
  880. }
  881. // 开始构建地图
  882. async function handleStartBuild() {
  883. if (!buildConfigMapName.value) {
  884. ElMessage.warning('请选择要构建的地图')
  885. return
  886. }
  887. buildConfigDialogVisible.value = false
  888. buildingMapName.value = buildConfigMapName.value
  889. waitingForResponse.value = true
  890. try {
  891. await startBuild(buildConfigMapName.value)
  892. buildingDialogVisible.value = true
  893. setTimeout(() => {
  894. if (waitingForResponse.value && !buildingDialogVisible.value) {
  895. waitingForResponse.value = false
  896. ElMessage.error('启动构建超时,请检查设备连接')
  897. }
  898. }, 30000)
  899. await loadData()
  900. } catch (e) {
  901. waitingForResponse.value = false
  902. resetBuildingState()
  903. }
  904. }
  905. // 重命名
  906. async function submitRename() {
  907. if (!newMapName.value.trim()) {
  908. ElMessage.warning('请输入新名称')
  909. return
  910. }
  911. try {
  912. await renameMap(currentRenameMap.value, newMapName.value)
  913. renameDialogVisible.value = false
  914. await loadData()
  915. } catch (e) {
  916. // 错误已在composable中处理
  917. }
  918. }
  919. // 下载
  920. async function submitDownload() {
  921. try {
  922. await downloadMap(currentDownloadMap.value, downLoadTypes.value)
  923. downloadDialogVisible.value = false
  924. } catch (e) {
  925. // 错误已在composable中处理
  926. }
  927. }
  928. // 导入
  929. function handleImport() {
  930. ElMessage.info('导入功能开发中...')
  931. }
  932. // 初始化
  933. onMounted(() => {
  934. // 恢复视图模式
  935. const savedMode = localStorage.getItem('map-view-mode')
  936. if (savedMode) {
  937. viewMode.value = savedMode
  938. }
  939. // 初始化 WebSocket
  940. initWebSocket({
  941. deviceId: deviceId.value,
  942. onMessage: handleWsMessage,
  943. onConnect: () => {
  944. console.log('[MapList] WebSocket已连接')
  945. },
  946. onDisconnect: () => {
  947. console.log('[MapList] WebSocket已断开')
  948. }
  949. })
  950. loadData()
  951. })
  952. // 组件卸载时断开 WebSocket
  953. onUnmounted(() => {
  954. disconnectWs()
  955. })
  956. // 监听录制状态,自动显示/隐藏对话框
  957. watch(isRecording, (newVal) => {
  958. if (newVal && recordingMapName.value) {
  959. recordingDialogVisible.value = true
  960. }
  961. })
  962. watch(isBuilding, (newVal) => {
  963. if (!newVal && buildingMapName.value) {
  964. buildingDialogVisible.value = false
  965. }
  966. })
  967. watch(isSlaming, (newVal) => {
  968. if (!newVal && slamMapName.value) {
  969. slamDialogVisible.value = false
  970. }
  971. })
  972. </script>
  973. <style lang="scss" scoped>
  974. .map-list-container {
  975. padding: 16px;
  976. min-height: calc(100vh - 140px);
  977. }
  978. .toolbar-section {
  979. display: flex;
  980. justify-content: space-between;
  981. align-items: center;
  982. gap: 16px;
  983. padding: 16px;
  984. background: var(--el-bg-color);
  985. border-radius: 8px;
  986. margin-bottom: 16px;
  987. }
  988. .toolbar-filters {
  989. display: flex;
  990. gap: 12px;
  991. .search-input { width: 200px; }
  992. .status-filter, .sort-select { width: 140px; }
  993. }
  994. .toolbar-actions {
  995. display: flex;
  996. gap: 8px;
  997. }
  998. .main-content {
  999. min-height: 400px;
  1000. }
  1001. .card-grid {
  1002. display: grid;
  1003. grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  1004. gap: 16px;
  1005. &.compact {
  1006. gap: 12px;
  1007. grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  1008. }
  1009. }
  1010. .map-card {
  1011. cursor: pointer;
  1012. transition: all 0.2s;
  1013. &:hover {
  1014. transform: translateY(-2px);
  1015. }
  1016. }
  1017. .card-thumbnail {
  1018. height: 160px;
  1019. background: var(--el-fill-color-light);
  1020. border-radius: 4px;
  1021. overflow: hidden;
  1022. margin-bottom: 12px;
  1023. img {
  1024. width: 100%;
  1025. height: 100%;
  1026. object-fit: cover;
  1027. }
  1028. .thumbnail-placeholder {
  1029. width: 100%;
  1030. height: 100%;
  1031. display: flex;
  1032. align-items: center;
  1033. justify-content: center;
  1034. color: var(--el-text-color-placeholder);
  1035. }
  1036. }
  1037. .card-info {
  1038. margin-bottom: 12px;
  1039. .map-name {
  1040. margin: 0 0 8px 0;
  1041. font-size: 16px;
  1042. font-weight: 500;
  1043. overflow: hidden;
  1044. text-overflow: ellipsis;
  1045. white-space: nowrap;
  1046. }
  1047. }
  1048. .card-actions {
  1049. display: flex;
  1050. gap: 8px;
  1051. align-items: center;
  1052. }
  1053. .empty-state {
  1054. grid-column: 1 / -1;
  1055. text-align: center;
  1056. padding: 60px 20px;
  1057. .empty-icon {
  1058. font-size: 60px;
  1059. color: var(--el-text-color-placeholder);
  1060. margin-bottom: 16px;
  1061. }
  1062. h3 {
  1063. margin: 0 0 8px 0;
  1064. color: var(--el-text-color-primary);
  1065. }
  1066. p {
  1067. margin: 0 0 16px 0;
  1068. color: var(--el-text-color-secondary);
  1069. }
  1070. }
  1071. .map-table {
  1072. background: var(--el-bg-color);
  1073. border-radius: 8px;
  1074. }
  1075. .pagination-section {
  1076. display: flex;
  1077. justify-content: flex-end;
  1078. margin-top: 16px;
  1079. }
  1080. .dialog-content {
  1081. padding: 8px 0;
  1082. }
  1083. .info-row {
  1084. display: flex;
  1085. align-items: center;
  1086. margin-bottom: 12px;
  1087. .label {
  1088. width: 100px;
  1089. color: var(--el-text-color-regular);
  1090. }
  1091. .value {
  1092. flex: 1;
  1093. }
  1094. }
  1095. .progress-wrapper {
  1096. margin-top: 8px;
  1097. padding: 0 4px;
  1098. }
  1099. </style>