index.vue 72 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549
  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="el-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-select>
  28. <el-select
  29. v-model="sortField"
  30. placeholder="排序方式"
  31. class="sort-select"
  32. @change="handleSortChange"
  33. >
  34. <el-option label="最近修改" value="updated" />
  35. <el-option label="名称" value="name" />
  36. <el-option label="状态" value="status" />
  37. </el-select>
  38. </div>
  39. <!-- 操作按钮区 -->
  40. <div class="toolbar-actions">
  41. <el-button
  42. type="default"
  43. icon="el-icon-upload2"
  44. size="small"
  45. @click="handleImport"
  46. >
  47. 导入地图
  48. </el-button>
  49. <el-button
  50. type="primary"
  51. icon="el-icon-plus"
  52. size="small"
  53. @click="handleCreate"
  54. >
  55. 新建地图
  56. </el-button>
  57. <el-button
  58. class="xt-btn"
  59. :disabled="selectedMaps.length !== 1"
  60. icon="el-icon-discover"
  61. size="small"
  62. @click="openSelfExplore"
  63. >
  64. 自主探索
  65. </el-button>
  66. <el-button
  67. class="xt-btn"
  68. :disabled="selectedMaps.length !== 1"
  69. icon="el-icon-connection"
  70. size="small"
  71. @click="openRemoteExplore"
  72. >
  73. 遥控探索
  74. </el-button>
  75. <!-- 视图切换 -->
  76. <el-button-group class="view-toggle">
  77. <el-button
  78. :type="viewMode === 'card' ? 'primary' : 'default'"
  79. icon="el-icon-s-grid"
  80. size="small"
  81. @click="setViewMode('card')"
  82. title="卡片视图"
  83. />
  84. <el-button
  85. :type="viewMode === 'table' ? 'primary' : 'default'"
  86. icon="el-icon-menu"
  87. size="small"
  88. @click="setViewMode('table')"
  89. title="表格视图"
  90. />
  91. </el-button-group>
  92. </div>
  93. </div>
  94. <!-- 主要内容区域 -->
  95. <div class="main-content">
  96. <!-- 卡片视图 -->
  97. <template v-if="viewMode === 'card'">
  98. <!-- 加载骨架屏 -->
  99. <div v-if="loading" class="card-grid" :class="{ compact: isCompactMode }">
  100. <div v-for="n in 12" :key="n" class="skeleton-card">
  101. <div class="skeleton-thumb"></div>
  102. <div class="skeleton-content">
  103. <div class="skeleton-title"></div>
  104. <div class="skeleton-remark"></div>
  105. <div class="skeleton-meta"></div>
  106. </div>
  107. </div>
  108. </div>
  109. <!-- 空态 -->
  110. <div v-else-if="mapList.length === 0" class="empty-state">
  111. <div class="empty-illustration">
  112. <i class="el-icon-map-location"></i>
  113. </div>
  114. <h3 class="empty-title">暂无地图</h3>
  115. <p class="empty-description">
  116. {{ hasSearchOrFilter ? '没有找到符合条件的地图' : '您还没有创建任何地图' }}
  117. </p>
  118. <div v-if="!hasSearchOrFilter" class="empty-actions">
  119. <el-button type="primary" icon="el-icon-plus" @click="handleCreate">
  120. 新建地图
  121. </el-button>
  122. <el-button type="default" icon="el-icon-upload2" @click="handleImport">
  123. 导入地图
  124. </el-button>
  125. </div>
  126. </div>
  127. <!-- 地图卡片列表 -->
  128. <div v-else class="card-grid" :class="{ compact: isCompactMode }">
  129. <XtMapCard
  130. v-for="item in displayedList"
  131. :key="item.id"
  132. :item="item"
  133. :selectable="selectedMaps.length > 0"
  134. :selected="selectedMaps.includes(item.id)"
  135. :map-file-base="MAP_FILE_BASE"
  136. @clickNav="handleNavigation"
  137. @clickEdit="handleEdit"
  138. @clickMore="handleCardAction"
  139. @selectChange="handleSelectChange"
  140. @refresh="getList"
  141. />
  142. </div>
  143. </template>
  144. <!-- 表格视图 -->
  145. <template v-else>
  146. <div ref="tableWrap" class="table-wrapper">
  147. <el-table
  148. ref="mapTable"
  149. v-loading="loading"
  150. :data="displayedList"
  151. row-key="id"
  152. @selection-change="handleTableSelectionChange"
  153. class="map-table"
  154. :fit="true"
  155. style="width: 100%"
  156. :border="false"
  157. :height="useFixedHeight ? tableHeight : null"
  158. :header-cell-style="() => ({ padding: '10px 12px', whiteSpace: 'nowrap' })"
  159. :cell-style="() => ({ padding: '10px 12px' })"
  160. empty-text="暂无地图数据"
  161. >
  162. <el-table-column type="selection" width="56" align="center" :reserve-selection="true"></el-table-column>
  163. <el-table-column
  164. label="序号"
  165. type="index"
  166. :index="indexMethod"
  167. width="64"
  168. align="center"
  169. header-align="center"
  170. class-name="col-index"
  171. label-class-name="col-index-header">
  172. </el-table-column>
  173. <el-table-column prop="mapName" label="地图名称" min-width="260" show-overflow-tooltip>
  174. <template slot-scope="scope">
  175. <div class="table-name">
  176. <!-- {{ scope.row.name }} -->
  177. {{ scope.row.map }}
  178. </div>
  179. </template>
  180. </el-table-column>
  181. <el-table-column label="缩略图" width="120" align="center">
  182. <template slot-scope="scope">
  183. <div class="table-thumb">
  184. <img v-if="scope.row.thumbUrl" :src="scope.row.thumbUrl" alt="缩略图" />
  185. <i v-else class="el-icon-map-location"></i>
  186. </div>
  187. </template>
  188. </el-table-column>
  189. <el-table-column label="状态" width="120" align="center">
  190. <template slot-scope="scope">
  191. <el-tag
  192. :type="getStatusConfig(scope.row.state).type"
  193. size="mini"
  194. effect="plain"
  195. >
  196. <i :class="getStatusConfig(scope.row.state).icon"></i>
  197. {{ getStatusConfig(scope.row.state).text }}
  198. </el-tag>
  199. </template>
  200. </el-table-column>
  201. <el-table-column prop="remark" label="备注" min-width="360" show-overflow-tooltip />
  202. <el-table-column prop="updatedAt" label="更新时间" width="180" align="center">
  203. <template slot-scope="{ row }">
  204. {{ formatDateTimeCompat(pickUpdatedAt(row)) }}
  205. </template>
  206. </el-table-column>
  207. <el-table-column label="操作" width="220" header-align="center" align="right">
  208. <template slot-scope="{ row }">
  209. <div class="op-cell">
  210. <router-link :to="buildNavTo(this,row)" class="op-link">
  211. <i class="el-icon-position"></i><span class="op-text">导航</span>
  212. </router-link>
  213. <router-link :to="buildEditTo(this,row)" class="op-link">
  214. <i class="el-icon-edit"></i><span class="op-text">编辑</span>
  215. </router-link>
  216. <el-dropdown @command="cmd=>onMore(row,cmd)">
  217. <span class="el-dropdown-link">
  218. <i class="el-icon-more"></i><span class="op-text">更多</span>
  219. </span>
  220. <el-dropdown-menu slot="dropdown" class="xt-more">
  221. <el-dropdown-item command="rename"><i class="el-icon-edit"></i> 重命名</el-dropdown-item>
  222. <el-dropdown-item command="download"><i class="el-icon-download"></i> 下载地图</el-dropdown-item>
  223. <el-dropdown-item command="build"><i class="el-icon-cpu"></i> 构建地图</el-dropdown-item>
  224. <el-dropdown-item divided command="delete" class="is-danger"><i class="el-icon-delete"></i> 删除地图</el-dropdown-item>
  225. <el-dropdown-item divided command="calibrate"><i class="el-icon-position"></i> 坐标系标定</el-dropdown-item>
  226. </el-dropdown-menu>
  227. </el-dropdown>
  228. </div>
  229. </template>
  230. </el-table-column>
  231. </el-table>
  232. </div>
  233. </template>
  234. </div>
  235. <!-- 分页 -->
  236. <div v-if="!loading && mapList.length > 0" class="pagination-section">
  237. <!-- 密度切换 -->
  238. <div v-if="totalCount > 60 && viewMode === 'card'" class="density-toggle">
  239. <el-button-group size="mini">
  240. <el-button
  241. :type="isCompactMode ? 'default' : 'primary'"
  242. @click="isCompactMode = false"
  243. >
  244. 常规
  245. </el-button>
  246. <el-button
  247. :type="isCompactMode ? 'primary' : 'default'"
  248. @click="isCompactMode = true"
  249. >
  250. 紧凑
  251. </el-button>
  252. </el-button-group>
  253. </div>
  254. <el-pagination
  255. :current-page="currentPage"
  256. :page-sizes="[12, 24, 48, 96]"
  257. :page-size="pageSize"
  258. :total="totalCount"
  259. layout="total, sizes, prev, pager, next, jumper"
  260. @size-change="handleSizeChange"
  261. @current-change="handlePageChange"
  262. />
  263. </div>
  264. <!-- 自主探索配置对话框 -->
  265. <el-dialog :visible.sync="autoExploreOpen" width="560px" append-to-body :close-on-click-modal="false" class="xt-explore-dialog">
  266. <span slot="title" class="xt-title">
  267. 自主探索
  268. <small class="xt-subtitle">为所选地图配置探索规则 · {{ selectedMapName }}</small>
  269. </span>
  270. <el-form ref="form" :model="exploreParams" :rules="rules" label-width="120px" class="xt-form">
  271. <el-form-item label="最大探索时间" prop="maxTime">
  272. <div class="xt-field">
  273. <el-input-number
  274. v-model="exploreParams.maxTime"
  275. :min="1"
  276. :step="1"
  277. controls-position="right"
  278. placeholder="请输入时间"
  279. />
  280. <span class="xt-unit">分钟</span>
  281. <span class="xt-hint">建议 1-10 分钟</span>
  282. </div>
  283. </el-form-item>
  284. <el-form-item label="最远探索距离" prop="maxDistance">
  285. <div class="xt-field">
  286. <el-input-number
  287. v-model="exploreParams.maxDistance"
  288. :min="1"
  289. :step="1"
  290. controls-position="right"
  291. placeholder="请输入距离"
  292. />
  293. <span class="xt-unit">米</span>
  294. <span class="xt-hint">建议不超过 500 米</span>
  295. </div>
  296. </el-form-item>
  297. <el-form-item label="最大探索范围" prop="maxRange">
  298. <div class="xt-field">
  299. <el-input-number
  300. v-model="exploreParams.maxRange"
  301. :min="1"
  302. :step="1"
  303. controls-position="right"
  304. placeholder="请输入范围"
  305. />
  306. <span class="xt-unit">平方米</span>
  307. <span class="xt-hint">建议 100-5000 平方米</span>
  308. </div>
  309. </el-form-item>
  310. </el-form>
  311. <span slot="footer" class="dialog-footer xt-footer">
  312. <el-button plain @click="autoExploreCancel">取 消</el-button>
  313. <el-button type="primary" @click="submitAutoExplore">开 始</el-button>
  314. </span>
  315. </el-dialog>
  316. <!-- 遥控探索确认对话框 -->
  317. <el-dialog :visible.sync="remoteExploreOpen" width="480px" append-to-body :close-on-click-modal="false" class="xt-confirm-dialog">
  318. <span slot="title" class="confirm-title">
  319. <i class="el-icon-warning" style="color: #E6A23C; margin-right: 8px;"></i>
  320. 提示
  321. </span>
  322. <div class="confirm-content">
  323. <p>将对编号为「{{ selectedMapCode }}-{{ selectedMapName }}」的地图执行遥控探索,是否继续?</p>
  324. </div>
  325. <span slot="footer" class="dialog-footer">
  326. <el-button @click="remoteExploreCancel">取 消</el-button>
  327. <el-button type="primary" @click="submitRemoteExplore">确 定</el-button>
  328. </span>
  329. </el-dialog>
  330. <!-- 下载地图dia -->
  331. <el-dialog :title="title" :visible.sync="downloadOpen" width="520px" append-to-body :close-on-click-modal="false">
  332. <div style="margin-bottom: 20px;">
  333. <span style="font-weight: bold;">请选择下载的地图组件:</span>
  334. </div>
  335. <el-checkbox-group v-model="downLoadTypes" style="margin-bottom: 10px;">
  336. <el-checkbox
  337. v-for="component in availableComponents"
  338. :key="component.label"
  339. :label="component.label"
  340. :checked="true"
  341. >
  342. {{ component.name }}
  343. </el-checkbox>
  344. </el-checkbox-group>
  345. <div slot="footer" class="dialog-footer">
  346. <el-button type="primary" @click="submitDownload">开 始</el-button>
  347. <el-button @click="downloadOpen = false">取 消</el-button>
  348. </div>
  349. </el-dialog>
  350. <!-- 构建地图dia -->
  351. <el-dialog :title="title" :visible.sync="constructOpen" width="520px" append-to-body :close-on-click-modal="false">
  352. <span style="margin-right: 10px;">构建模式:</span>
  353. <el-radio-group v-model="constructModle" size="mini">
  354. <el-radio-button label="auto">自动构建</el-radio-button>
  355. <el-radio-button label="hand">手动构建</el-radio-button>
  356. </el-radio-group>
  357. <div v-if="constructModle == 'hand'">
  358. <p style="margin-top: 20px;">请选择地图及数据:</p>
  359. <el-checkbox-group v-model="constructTypes" style="margin-bottom: 10px;">
  360. <el-checkbox label="tree" :checked="true">八叉树</el-checkbox>
  361. <el-checkbox label="las" :checked="true">las数量</el-checkbox>
  362. <el-checkbox label="tile" :checked="true">瓦片地图</el-checkbox>
  363. <el-checkbox label="potree" :checked="true">potree</el-checkbox>
  364. </el-checkbox-group>
  365. </div>
  366. <div slot="footer" class="dialog-footer">
  367. <el-button type="primary" @click="submitConstruct">开 始</el-button>
  368. <el-button @click="constructOpen = false">取 消</el-button>
  369. </div>
  370. </el-dialog>
  371. <!-- <div>
  372. <button @click="publishMsg">发布消息</button>
  373. <button @click="addTopic">动态订阅 topic3</button>
  374. <button @click="removeTopic">动态取消 topic2</button>
  375. </div> -->
  376. <!-- <MqttComp ref="mqtt" :topics="topics" @message-received="onMessage" /> -->
  377. <!-- 导入地图对话框 -->
  378. <el-dialog title="导入地图" :visible.sync="importOpen" width="520px" append-to-body :close-on-click-modal="false">
  379. <div style="margin-bottom: 20px;">
  380. <span style="font-weight: bold;">选择要导入的地图文件(.zip):</span>
  381. </div>
  382. <el-upload
  383. ref="upload"
  384. :action="`/v1/map/import`"
  385. :headers="uploadHeaders"
  386. :on-success="handleUploadSuccess"
  387. :on-error="handleUploadError"
  388. :before-upload="beforeUpload"
  389. :auto-upload="false"
  390. name="filedata"
  391. accept=".zip"
  392. drag
  393. >
  394. <i class="el-icon-upload"></i>
  395. <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
  396. <div class="el-upload__tip" slot="tip">只能上传.zip文件,且不超过100MB</div>
  397. </el-upload>
  398. <div slot="footer" class="dialog-footer">
  399. <el-button type="primary" @click="submitImport">开始导入</el-button>
  400. <el-button @click="importOpen = false">取消</el-button>
  401. </div>
  402. </el-dialog>
  403. </div>
  404. </template>
  405. <script>
  406. import MqttComp from "@/components/Mqtt/mqttComp.vue";
  407. // 导入地图卡片组件
  408. import XtMapCard from '@/components/XtMapCard'
  409. // 导入日期格式化工具
  410. import { formatDateTimeCompat, pickUpdatedAt } from '@/utils/datefmt'
  411. // 导入路由辅助函数
  412. import { buildNavTo, buildEditTo, buildCalibrateTo, buildConstructTo, pickId } from '@/utils/route-helpers'
  413. // 导入文件保存库
  414. import { saveAs } from 'file-saver'
  415. // 安全获取数组函数
  416. function pickArray(...arrs){
  417. for(const a of arrs){
  418. if(Array.isArray(a) && a.length>=0) return a
  419. }
  420. return []
  421. }
  422. // 尝试导入真实 API,失败则使用 Mock
  423. let mapApi
  424. try {
  425. // 尝试导入真实 API(如果存在)
  426. mapApi = require('@/api/map/index.js')
  427. } catch (error) {
  428. // Fallback 到 Mock API
  429. mapApi = require('@/api/mock/maps')
  430. }
  431. // 地图文件服务基础URL(可根据实际情况调整)
  432. const MAP_FILE_BASE = 'http://101.35.49.102:9000' // 示例地址,实际使用时请替换
  433. export default {
  434. name: "MapList",
  435. components: {
  436. XtMapCard,
  437. MqttComp,
  438. },
  439. data() {
  440. return {
  441. // topics: ["/robot4inspection/a477be75f66fe3cb/task/target/action/goto/reply","/robot4inspection/a477be75f66fe3cb/task/target/event/arrive"], // 初始订阅的 topic
  442. topics:[],
  443. // 常量
  444. MAP_FILE_BASE,
  445. // 视图模式
  446. viewMode: 'card', // 'card' | 'table'
  447. // 加载状态
  448. loading: false,
  449. // 数据
  450. mapList: [],
  451. currentPage: 1,
  452. pageSize: 12,
  453. // Mock 数据
  454. /* mockMapList: [
  455. {
  456. id: 1,
  457. code: 'SH001',
  458. name: '上海办公楼一层',
  459. mapName: '上海办公楼一层',
  460. showImg: 'https://placehold.co/320x180/4f46e5/white?text=Office+Floor+1',
  461. thumbUrl: 'https://placehold.co/320x180/4f46e5/white?text=Office+Floor+1',
  462. status: 'ok',
  463. use: true,
  464. top: true,
  465. remark: '主要办公区域地图,包含接待区、办公区、会议室',
  466. updatedAt: '2024-01-15T14:22:00Z'
  467. },
  468. {
  469. id: 2,
  470. code: 'FC001',
  471. name: '工厂车间A区',
  472. mapName: '工厂车间A区',
  473. showImg: 'https://placehold.co/320x180/dc2626/white?text=Factory+A',
  474. thumbUrl: 'https://placehold.co/320x180/dc2626/white?text=Factory+A',
  475. status: 'down',
  476. use: false,
  477. top: false,
  478. remark: '生产车间A区域,设备较多,需要定期更新',
  479. updatedAt: '2024-01-12T09:12:00Z'
  480. },
  481. {
  482. id: 3,
  483. code: 'WH001',
  484. name: '仓库主区域',
  485. mapName: '仓库主区域',
  486. showImg: 'https://placehold.co/320x180/f59e0b/white?text=Warehouse',
  487. thumbUrl: 'https://placehold.co/320x180/f59e0b/white?text=Warehouse',
  488. status: 'scanning',
  489. use: false,
  490. top: true,
  491. remark: '仓库主要存储区域,正在进行地图更新扫描',
  492. updatedAt: '2024-01-14T16:45:00Z'
  493. },
  494. {
  495. id: 4,
  496. code: 'RD002',
  497. name: '研发中心二楼',
  498. mapName: '研发中心二楼',
  499. showImg: 'https://placehold.co/320x180/059669/white?text=R%26D+Floor+2',
  500. thumbUrl: 'https://placehold.co/320x180/059669/white?text=R%26D+Floor+2',
  501. status: 'ok',
  502. use: false,
  503. top: false,
  504. remark: '研发中心实验室区域,包含多个实验室和会议室',
  505. updatedAt: '2024-01-11T11:30:00Z'
  506. },
  507. {
  508. id: 5,
  509. code: 'PK001',
  510. name: '停车场地下一层',
  511. mapName: '停车场地下一层',
  512. showImg: 'https://placehold.co/320x180/7c3aed/white?text=Parking+B1',
  513. thumbUrl: 'https://placehold.co/320x180/7c3aed/white?text=Parking+B1',
  514. status: 'ok',
  515. use: false,
  516. top: false,
  517. remark: '地下停车场区域图,包含车位信息和通道标识',
  518. updatedAt: '2024-01-10T08:15:00Z'
  519. },
  520. {
  521. id: 6,
  522. code: 'CF001',
  523. name: '食堂餐厅区域',
  524. mapName: '食堂餐厅区域',
  525. showImg: 'https://placehold.co/320x180/ea580c/white?text=Cafeteria',
  526. thumbUrl: 'https://placehold.co/320x180/ea580c/white?text=Cafeteria',
  527. status: 'scanning',
  528. use: false,
  529. top: false,
  530. remark: '员工食堂及餐厅区域,包含用餐区和厨房区域',
  531. updatedAt: '2024-01-09T12:40:00Z'
  532. },
  533. {
  534. id: 7,
  535. code: 'OD001',
  536. name: '户外园区',
  537. mapName: '户外园区',
  538. showImg: 'https://placehold.co/320x180/16a34a/white?text=Outdoor+Park',
  539. thumbUrl: 'https://placehold.co/320x180/16a34a/white?text=Outdoor+Park',
  540. status: 'ok',
  541. use: false,
  542. top: false,
  543. remark: '公司户外园区,包含步行道、休息区和绿化带',
  544. updatedAt: '2024-01-08T15:20:00Z'
  545. },
  546. {
  547. id: 8,
  548. code: 'MC001',
  549. name: '会议中心',
  550. mapName: '会议中心',
  551. showImg: 'https://placehold.co/320x180/0891b2/white?text=Conference',
  552. thumbUrl: 'https://placehold.co/320x180/0891b2/white?text=Conference',
  553. status: 'down',
  554. use: false,
  555. top: false,
  556. remark: '多功能会议中心,包含大型会议厅和小型讨论室',
  557. updatedAt: '2024-01-07T10:05:00Z'
  558. }
  559. ], */
  560. // 搜索和筛选
  561. searchKeyword: '',
  562. statusFilter: 'all',
  563. sortField: 'updated',
  564. sortOrder: 'desc',
  565. searchDebounceTimer: null,
  566. // 选择状态
  567. selectedMaps: [],
  568. // UI状态
  569. isCompactMode: false,
  570. useFixedHeight: false,
  571. tableHeight: 520, // 表格高度
  572. // 弹窗状态(保留原有对话框)
  573. title: "",
  574. autoExploreOpen: false, // 自主探索弹框
  575. remoteExploreOpen: false, // 遥控探索确认弹框
  576. downloadOpen: false,
  577. constructOpen: false,
  578. importOpen: false, // 导入地图对话框
  579. uploadHeaders: {}, // 上传请求头
  580. constructModle: 'hand',
  581. exploreParams: {
  582. maxTime: null,
  583. maxDistance: null,
  584. maxRange: null
  585. },
  586. downLoadTypes: [],
  587. availableComponents: [], // 可用的组件列表
  588. constructTypes: [],
  589. currentRow: null, // 当前操作的地图行数据
  590. rules: {
  591. maxTime: [
  592. { required: true, message: "最长时间不能为空", trigger: "blur" }
  593. ],
  594. maxDistance: [
  595. { required: true, message: "最远距离不能为空", trigger: "blur" }
  596. ],
  597. maxRange: [
  598. { required: true, message: "最大范围不能为空", trigger: "blur" }
  599. ],
  600. }
  601. };
  602. },
  603. computed: {
  604. // 原始数据:兼容不同变量名
  605. rawList(){
  606. // 按出现频率降序尝试
  607. return pickArray(this.mapList)
  608. },
  609. // 供表格/卡片使用的数据(支持搜索过滤和分页)
  610. displayedList(){
  611. let filteredData = [...(this.rawList || [])]
  612. // 搜索过滤
  613. if (this.searchKeyword.trim()) {
  614. const keyword = this.searchKeyword.toLowerCase()
  615. filteredData = filteredData.filter(item => {
  616. // 搜索地图名称、备注等字段
  617. const mapName = (item.map || item.mapName || item.name || '').toLowerCase()
  618. const remark = (item.remark || '').toLowerCase()
  619. return mapName.includes(keyword) || remark.includes(keyword)
  620. })
  621. }
  622. // 状态筛选
  623. if (this.statusFilter !== 'all') {
  624. filteredData = filteredData.filter(item => {
  625. const status = item.state || item.status
  626. return status === this.statusFilter
  627. })
  628. }
  629. // 排序
  630. if (this.sortField && this.sortField !== 'updated') {
  631. filteredData.sort((a, b) => {
  632. let aVal, bVal
  633. switch (this.sortField) {
  634. case 'name':
  635. aVal = (a.map || a.mapName || a.name || '').toLowerCase()
  636. bVal = (b.map || b.mapName || b.name || '').toLowerCase()
  637. break
  638. case 'status':
  639. // 状态优先级:available > building > recording > unavailable
  640. const statusOrder = { available: 3, building: 2, recording: 1, unavailable: 0 }
  641. aVal = statusOrder[a.state || a.status] || 0
  642. bVal = statusOrder[b.state || b.status] || 0
  643. break
  644. default:
  645. aVal = a[this.sortField] || ''
  646. bVal = b[this.sortField] || ''
  647. break
  648. }
  649. if (this.sortOrder === 'asc') {
  650. return aVal > bVal ? 1 : aVal < bVal ? -1 : 0
  651. } else {
  652. return aVal < bVal ? 1 : aVal > bVal ? -1 : 0
  653. }
  654. })
  655. }
  656. // 分页
  657. const page = this.pagination?.page || this.pageNum || this.currentPage || 1
  658. const size = this.pagination?.pageSize || this.pageSize || 12
  659. const start = (page - 1) * size
  660. return filteredData.slice(start, start + size)
  661. },
  662. totalCount(){
  663. let filteredData = [...(this.rawList || [])]
  664. // 搜索过滤
  665. if (this.searchKeyword.trim()) {
  666. const keyword = this.searchKeyword.toLowerCase()
  667. filteredData = filteredData.filter(item => {
  668. // 搜索地图名称、备注等字段
  669. const mapName = (item.map || item.mapName || item.name || '').toLowerCase()
  670. const remark = (item.remark || '').toLowerCase()
  671. return mapName.includes(keyword) || remark.includes(keyword)
  672. })
  673. }
  674. // 状态筛选
  675. if (this.statusFilter !== 'all') {
  676. filteredData = filteredData.filter(item => {
  677. const status = item.state || item.status
  678. return status === this.statusFilter
  679. })
  680. }
  681. return filteredData.length
  682. },
  683. // 是否有搜索或筛选条件
  684. hasSearchOrFilter() {
  685. return this.searchKeyword.trim() || this.statusFilter !== 'all'
  686. },
  687. // 获取选中的地图名称
  688. selectedMapName() {
  689. if (this.selectedMaps.length === 1) {
  690. const selectedMap = this.displayedList.find(map => map.id === this.selectedMaps[0]) ||
  691. this.rawList.find(map => map.id === this.selectedMaps[0])
  692. return selectedMap ? selectedMap.name : ''
  693. }
  694. return ''
  695. },
  696. // 获取选中的地图编号
  697. selectedMapCode() {
  698. if (this.selectedMaps.length === 1) {
  699. const selectedMap = this.displayedList.find(map => map.id === this.selectedMaps[0]) ||
  700. this.rawList.find(map => map.id === this.selectedMaps[0])
  701. return selectedMap ? (selectedMap.code || selectedMap.id) : ''
  702. }
  703. return ''
  704. }
  705. },
  706. created() {
  707. this.loadViewMode()
  708. // this.calculateTableHeight()
  709. },
  710. watch: {
  711. displayedList() {
  712. this.$nextTick(() => this.$refs.mapTable && this.$refs.mapTable.doLayout())
  713. }
  714. },
  715. mounted() {
  716. this.getList()
  717. this.calcTableHeight()
  718. window.addEventListener('resize', this.onResize)
  719. // 添加键盘监听
  720. this.addKeyboardListeners()
  721. // 表格渲染后重排一次,防止列宽缓存
  722. this.$nextTick(() => {
  723. this.$refs.mapTable && this.$refs.mapTable.doLayout()
  724. })
  725. },
  726. beforeDestroy() {
  727. window.removeEventListener('resize', this.onResize)
  728. // 移除键盘监听
  729. this.removeKeyboardListeners()
  730. },
  731. methods: {
  732. onMessage({ topic, message }) {
  733. console.log("收到消息:", topic, message);
  734. },
  735. publishMsg() {
  736. this.$refs.mqtt.publish("/robot4inspection/508b02dc5bcdca22/task/target/action/goto", {
  737. "timestamp" : 12345678,
  738. "args": [{"roadmap": "demo","nid": [10]}]
  739. });
  740. },
  741. addTopic() {
  742. if (!this.topics.includes("topic3")) this.topics.push("topic3");
  743. },
  744. removeTopic() {
  745. this.topics = this.topics.filter((t) => t !== "/robot4inspection/508b02dc5bcdca22/localization/pose");
  746. },
  747. // 获取地图列表
  748. async getList() {
  749. this.loading = true
  750. // 模拟加载延迟
  751. // await new Promise(resolve => setTimeout(resolve, 300))
  752. try {
  753. // TODO: 后续接入真实接口时,取消注释以下代码
  754. // const params = {
  755. // page: this.currentPage,
  756. // pageSize: this.pageSize,
  757. // keyword: this.searchKeyword,
  758. // status: this.statusFilter,
  759. // sort: this.sortField,
  760. // order: this.sortOrder
  761. // }
  762. const response = await mapApi.getMapList()
  763. console.log('Fetched map list:', response);
  764. if (response) {
  765. // this.mapList = this.processMapList(response)
  766. const { maps = [], states = [], total = 0 } = response || {}
  767. // 合并成对象数组 [{ map: 'demo', state: 'available' }]
  768. this.mapList = maps.map((map, index) => ({
  769. id: index + 1, // 如果有唯一ID字段,请替换
  770. map,
  771. state: states[index] || null
  772. }))
  773. const test = await mapApi.getMapThumbnail(this.mapList[0].map)
  774. console.log('Processed map list:', test);
  775. const url = URL.createObjectURL(test)
  776. this.$set(this.mapList[0], 'thumbUrl', url)
  777. }
  778. // 保持原始数据不变,过滤、排序、分页交给computed属性处理
  779. // this.mapList 保存完整的原始数据
  780. } catch (error) {
  781. this.$message.error('获取地图列表失败: ' + error.message)
  782. this.mapList = []
  783. } finally {
  784. this.loading = false
  785. this.relayout()
  786. }
  787. },
  788. // 处理地图列表数据
  789. processMapList(list) {
  790. return list.map(item => ({
  791. ...item
  792. }))
  793. },
  794. // 表格相关方法
  795. calcTableHeight() {
  796. this.$nextTick(() => {
  797. if (!this.$refs.tableWrap) return
  798. const top = this.$refs.tableWrap.getBoundingClientRect().top
  799. this.tableHeight = window.innerHeight - top - 120 // 预留分页/外边距
  800. // 当行数 * 行高 + 表头高度 > 可用高度时,才启用固定高度(出现纵向滚动)
  801. const rowH = 48, headerH = 48
  802. const need = (this.mapList.length * rowH + headerH) > this.tableHeight
  803. this.useFixedHeight = need
  804. this.$refs.mapTable && this.$refs.mapTable.doLayout()
  805. })
  806. },
  807. onResize() {
  808. this.calcTableHeight()
  809. },
  810. relayout() {
  811. this.$nextTick(() => this.$refs.mapTable && this.$refs.mapTable.doLayout())
  812. },
  813. // 搜索处理(防抖)
  814. handleSearch() {
  815. clearTimeout(this.searchDebounceTimer)
  816. this.searchDebounceTimer = setTimeout(() => {
  817. // 搜索时重置到第一页
  818. this.currentPage = 1
  819. // 由于使用computed属性进行前端过滤,无需调用接口
  820. // displayedList和totalCount会自动重新计算
  821. }, 300)
  822. },
  823. // 筛选变化处理
  824. handleFilterChange() {
  825. this.currentPage = 1
  826. // 由于使用computed属性进行前端过滤,无需调用接口
  827. },
  828. // 排序变化处理
  829. handleSortChange() {
  830. this.currentPage = 1
  831. // 由于使用computed属性进行前端排序,无需调用接口
  832. },
  833. // 页码变化
  834. handlePageChange(page) {
  835. this.currentPage = page
  836. // 使用前端分页,无需调用接口
  837. // 滚动到顶部
  838. this.$nextTick(() => {
  839. window.scrollTo({ top: 0, behavior: 'smooth' })
  840. })
  841. },
  842. // 页大小变化
  843. handleSizeChange(size) {
  844. this.pageSize = size
  845. this.currentPage = 1
  846. // 使用前端分页,无需调用接口
  847. },
  848. // 视图模式切换
  849. setViewMode(mode) {
  850. this.viewMode = mode
  851. this.saveViewMode()
  852. if (mode === 'table') {
  853. this.$nextTick(() => {
  854. this.calcTableHeight()
  855. this.relayout()
  856. })
  857. }
  858. },
  859. // 卡片选择变化
  860. handleSelectChange(id, checked) {
  861. if (checked) {
  862. if (!this.selectedMaps.includes(id)) {
  863. this.selectedMaps.push(id)
  864. }
  865. } else {
  866. const index = this.selectedMaps.indexOf(id)
  867. if (index > -1) {
  868. this.selectedMaps.splice(index, 1)
  869. }
  870. }
  871. },
  872. // 表格选择变化
  873. handleTableSelectionChange(selection) {
  874. this.selectedMaps = selection.map(item => item.id)
  875. },
  876. // 清除选择
  877. clearSelection() {
  878. this.selectedMaps = []
  879. },
  880. // 导航
  881. handleNavigation(id) {
  882. this.$router.push({
  883. name: 'MapNavigation',
  884. params: { mapId: id }
  885. })
  886. },
  887. // 编辑
  888. handleEdit(id) {
  889. this.$router.push({
  890. name: 'MapEdit',
  891. params: { mapId: id }
  892. })
  893. },
  894. // 表格操作方法
  895. goNav(row) {
  896. this.handleNavigation(row.id)
  897. },
  898. goEdit(row) {
  899. this.handleEdit(row.id)
  900. },
  901. async onMore(row, cmd) {
  902. const id = pickId(row);
  903. switch(cmd) {
  904. case 'rename':
  905. // 优先:已有重命名弹框/方法
  906. if (typeof this.openRenameDialog === 'function') return this.openRenameDialog(row);
  907. if (this.renameDialog) { this.renameDialog.visible = true; this.renameDialog.row = row; return; }
  908. // 兜底:Element prompt
  909. this.$prompt('请输入新的地图名称', '重命名', {
  910. inputValue: row.mapName || row.map || row.name || '',
  911. inputPattern: /\S+/,
  912. inputErrorMessage: '名称不能为空'
  913. }).then(async ({ value }) => {
  914. try {
  915. // 调用重命名API
  916. const response = await mapApi.renameMap({
  917. map: row.map || row.mapName || row.name,
  918. rename: value
  919. });
  920. if (response && response.status) {
  921. this.$message.success('地图重命名成功');
  922. // 刷新列表
  923. this.getList();
  924. } else {
  925. this.$message.error('重命名失败');
  926. }
  927. } catch (error) {
  928. console.error('重命名失败:', error);
  929. this.$message.error('重命名失败: ' + (error.message || '网络错误'));
  930. }
  931. }).catch(()=>{});
  932. break;
  933. case 'download':
  934. // 先获取组件列表,再打开下载对话框
  935. await this.loadMapComponents(row);
  936. break;
  937. case 'build':
  938. // 优先:旧路由/方法
  939. if (typeof this.openBuildDialog === 'function') return this.openBuildDialog(row);
  940. if (typeof this.constructOpen === 'boolean') {
  941. this.constructOpen = true;
  942. this.title = `构建地图 - ${row.name || row.mapName || ''}`;
  943. this.currentRow = row;
  944. return;
  945. }
  946. if (typeof this.toBuildPage === 'function') return this.toBuildPage(row);
  947. return this.$router.push(buildConstructTo(row));
  948. case 'delete':
  949. this.$confirm('确认删除该地图?此操作不可恢复', '删除地图', {type: 'warning'})
  950. .then(async () => {
  951. try {
  952. // 调用删除API
  953. const response = await mapApi.deleteMap({
  954. map: row.map || row.mapName || row.name
  955. });
  956. if (response && response.status) {
  957. this.$message.success('地图删除成功');
  958. // 刷新列表
  959. this.getList();
  960. } else {
  961. this.$message.error('删除失败');
  962. }
  963. } catch (error) {
  964. console.error('删除失败:', error);
  965. this.$message.error('删除失败: ' + (error.message || '网络错误'));
  966. }
  967. })
  968. .catch(() => {});
  969. break;
  970. case 'calibrate':
  971. // 优先:旧路由/方法
  972. if (typeof this.openCalibration === 'function') return this.openCalibration(row);
  973. return this.$router.push(buildCalibrateTo(row));
  974. default:
  975. // 兼容原有的操作
  976. return this.handleCardAction(row, cmd);
  977. }
  978. },
  979. // 序号计算方法
  980. indexMethod(i) {
  981. const p = this.pagination?.page || this.pageNum || this.currentPage || 1
  982. const ps = this.pagination?.pageSize || this.pageSize || 10
  983. return (p - 1) * ps + i + 1
  984. },
  985. // 卡片更多操作
  986. async handleCardAction(row, action) {
  987. console.log('Card action:', row, action);
  988. switch (action) {
  989. case 'publish':
  990. await this.handlePublish(row)
  991. break
  992. case 'copy':
  993. await this.handleCopy(row)
  994. break
  995. case 'download':
  996. // 先获取组件列表,再打开下载对话框
  997. await this.loadMapComponents(row);
  998. break
  999. case 'delete':
  1000. await this.handleDelete([id])
  1001. break
  1002. }
  1003. },
  1004. // 创建地图
  1005. async handleCreate() {
  1006. try {
  1007. const { value } = await this.$prompt('请输入地图名称', '新建地图', {
  1008. confirmButtonText: '确定',
  1009. cancelButtonText: '取消',
  1010. inputPattern: /^[\w\u4e00-\u9fa5_-]+$/,
  1011. inputErrorMessage: '地图名格式不正确'
  1012. })
  1013. const response = await mapApi.createMap({ name: value })
  1014. if (response.code === 200) {
  1015. this.$message.success('地图创建成功')
  1016. this.getList()
  1017. }
  1018. } catch (error) {
  1019. if (error !== 'cancel') {
  1020. this.$message.error('创建失败: ' + error.message)
  1021. }
  1022. }
  1023. },
  1024. // 导入地图
  1025. handleImport() {
  1026. this.importOpen = true
  1027. },
  1028. // 文件上传前的处理
  1029. beforeUpload(file) {
  1030. const isZip = file.type === 'application/zip' || file.name.endsWith('.zip')
  1031. const isLt100M = file.size / 1024 / 1024 < 100
  1032. if (!isZip) {
  1033. this.$message.error('只能上传.zip格式的文件!')
  1034. return false
  1035. }
  1036. if (!isLt100M) {
  1037. this.$message.error('上传文件大小不能超过100MB!')
  1038. return false
  1039. }
  1040. return true
  1041. },
  1042. // 提交导入
  1043. submitImport() {
  1044. const fileList = this.$refs.upload.uploadFiles
  1045. if (fileList.length === 0) {
  1046. this.$message.error('请选择要导入的地图文件')
  1047. return
  1048. }
  1049. this.$refs.upload.submit()
  1050. },
  1051. // 上传成功处理
  1052. handleUploadSuccess(response, file) {
  1053. if (response && response.status) {
  1054. this.$message.success(`地图导入成功: ${response.map || file.name}`)
  1055. this.importOpen = false
  1056. this.$refs.upload.clearFiles()
  1057. // 刷新列表
  1058. this.getList()
  1059. } else {
  1060. this.$message.error('导入失败')
  1061. }
  1062. },
  1063. // 上传失败处理
  1064. handleUploadError(error, file) {
  1065. console.error('上传失败:', error)
  1066. this.$message.error('导入失败: ' + (error.message || '网络错误'))
  1067. },
  1068. // 发布地图
  1069. async handlePublish(ids) {
  1070. try {
  1071. await this.$confirm(`确定要发布选中的 ${ids.length} 个地图吗?`, '发布确认', {
  1072. type: 'warning'
  1073. })
  1074. const response = await mapApi.publishMaps(ids)
  1075. if (response.code === 200) {
  1076. this.$message.success(response.message)
  1077. this.getList()
  1078. this.clearSelection()
  1079. }
  1080. } catch (error) {
  1081. if (error !== 'cancel') {
  1082. this.$message.error('发布失败: ' + error.message)
  1083. }
  1084. }
  1085. },
  1086. // 复制地图
  1087. async handleCopy(id) {
  1088. try {
  1089. const { value } = await this.$prompt('请输入新地图名称', '复制地图', {
  1090. confirmButtonText: '确定',
  1091. cancelButtonText: '取消'
  1092. })
  1093. const response = await mapApi.copyMap(id, value)
  1094. if (response.code === 200) {
  1095. this.$message.success('地图复制成功')
  1096. this.getList()
  1097. }
  1098. } catch (error) {
  1099. if (error !== 'cancel') {
  1100. this.$message.error('复制失败: ' + error.message)
  1101. }
  1102. }
  1103. },
  1104. // 下载地图
  1105. async handleDownload(id) {
  1106. try {
  1107. const response = await mapApi.downloadMap(id, ['tree', 'vector', 'tile'])
  1108. if (response.code === 200) {
  1109. // 创建临时下载链接
  1110. const link = document.createElement('a')
  1111. link.href = response.data.downloadUrl
  1112. link.download = `map_${id}.zip`
  1113. link.click()
  1114. this.$message.success('下载已开始')
  1115. }
  1116. } catch (error) {
  1117. this.$message.error('下载失败: ' + error.message)
  1118. }
  1119. },
  1120. // 删除地图
  1121. async handleDelete(ids) {
  1122. try {
  1123. await this.$confirm(`确定要删除选中的 ${ids.length} 个地图吗?删除后无法恢复。`, '删除确认', {
  1124. type: 'warning',
  1125. confirmButtonText: '确定删除',
  1126. cancelButtonText: '取消'
  1127. })
  1128. const response = await mapApi.deleteMap(ids)
  1129. if (response.code === 200) {
  1130. this.$message.success(response.message)
  1131. this.getList()
  1132. this.clearSelection()
  1133. }
  1134. } catch (error) {
  1135. if (error !== 'cancel') {
  1136. this.$message.error('删除失败: ' + error.message)
  1137. }
  1138. }
  1139. },
  1140. // 批量操作
  1141. async handleBatchPublish() {
  1142. await this.handlePublish(this.selectedMaps)
  1143. },
  1144. async handleBatchDelete() {
  1145. await this.handleDelete(this.selectedMaps)
  1146. },
  1147. // 获取状态配置
  1148. getStatusConfig(status) {
  1149. const configs = {
  1150. available: { type: 'success', icon: 'el-icon-check', text: '正常' },
  1151. unavailable: { type: 'danger', icon: 'el-icon-close', text: '不可用' },
  1152. building : { type: 'warning', icon: 'el-icon-loading', text: '正在建图' },
  1153. recording : { type: 'warning', icon: 'el-icon-loading', text: '正在录制' }
  1154. }
  1155. return configs[status] || configs.ok
  1156. },
  1157. // 格式化时间
  1158. formatDateTimeCompat,
  1159. pickUpdatedAt,
  1160. // 路由辅助函数
  1161. buildNavTo(vm, row){
  1162. return buildNavTo(row);
  1163. },
  1164. buildEditTo(vm, row){
  1165. return buildEditTo(row);
  1166. },
  1167. pickId,
  1168. // 本地存储相关
  1169. loadViewMode() {
  1170. try {
  1171. const stored = localStorage.getItem('xt-map-view-mode')
  1172. if (stored && ['card', 'table'].includes(stored)) {
  1173. this.viewMode = stored
  1174. }
  1175. } catch (error) {
  1176. console.warn('Failed to load view mode:', error)
  1177. }
  1178. },
  1179. saveViewMode() {
  1180. try {
  1181. localStorage.setItem('xt-map-view-mode', this.viewMode)
  1182. } catch (error) {
  1183. console.warn('Failed to save view mode:', error)
  1184. }
  1185. },
  1186. // 打开自主探索弹框
  1187. openSelfExplore() {
  1188. if (this.selectedMaps.length !== 1) {
  1189. this.$message.warning('请选择一个地图进行自主探索')
  1190. return
  1191. }
  1192. this.autoExploreOpen = true
  1193. },
  1194. // 打开遥控探索确认弹框
  1195. openRemoteExplore() {
  1196. if (this.selectedMaps.length !== 1) {
  1197. this.$message.warning('请选择一个地图进行遥控探索')
  1198. return
  1199. }
  1200. this.remoteExploreOpen = true
  1201. },
  1202. // 键盘监听方法
  1203. addKeyboardListeners() {
  1204. this.handleKeyboard = (event) => {
  1205. if (this.autoExploreOpen) {
  1206. if (event.key === 'Enter') {
  1207. event.preventDefault()
  1208. this.submitAutoExplore()
  1209. } else if (event.key === 'Escape') {
  1210. event.preventDefault()
  1211. this.autoExploreCancel()
  1212. }
  1213. } else if (this.remoteExploreOpen) {
  1214. if (event.key === 'Enter') {
  1215. event.preventDefault()
  1216. this.submitRemoteExplore()
  1217. } else if (event.key === 'Escape') {
  1218. event.preventDefault()
  1219. this.remoteExploreCancel()
  1220. }
  1221. }
  1222. }
  1223. document.addEventListener('keydown', this.handleKeyboard)
  1224. },
  1225. removeKeyboardListeners() {
  1226. if (this.handleKeyboard) {
  1227. document.removeEventListener('keydown', this.handleKeyboard)
  1228. }
  1229. },
  1230. // 自主探索相关方法
  1231. autoExploreCancel() {
  1232. this.autoExploreOpen = false
  1233. },
  1234. submitAutoExplore() {
  1235. this.$refs["form"].validate(valid => {
  1236. if (valid) {
  1237. this.$message.success('已开始自主探索...')
  1238. this.autoExploreOpen = false
  1239. }
  1240. })
  1241. },
  1242. // 遥控探索相关方法
  1243. remoteExploreCancel() {
  1244. this.remoteExploreOpen = false
  1245. },
  1246. submitRemoteExplore() {
  1247. this.$message.success('已开始遥控探索...')
  1248. this.remoteExploreOpen = false
  1249. },
  1250. async submitDownload() {
  1251. console.log('submitDownload', this.currentRow);
  1252. if (!this.currentRow) {
  1253. this.$message.error('请选择要下载的地图');
  1254. return;
  1255. }
  1256. if (this.downLoadTypes.length === 0) {
  1257. this.$message.error('请至少选择一个地图组件');
  1258. return;
  1259. }
  1260. const mapName = this.currentRow.map || this.currentRow.mapName || this.currentRow.name;
  1261. // 创建加载提示
  1262. const loading = this.$loading({
  1263. lock: true,
  1264. text: '正在压缩地图文件,请耐心等待...',
  1265. spinner: 'el-icon-loading',
  1266. background: 'rgba(0, 0, 0, 0.7)',
  1267. customClass: 'large-loading-text',
  1268. target: document.body
  1269. });
  1270. // 应用自定义样式
  1271. this.applyLoadingStyle();
  1272. try {
  1273. // 计算需要忽略的组件(所有可用组件减去选中的组件)
  1274. const allComponents = this.availableComponents.map(comp => comp.label);
  1275. const ignoreComponents = allComponents.filter(comp => !this.downLoadTypes.includes(comp));
  1276. // 第一步:压缩地图
  1277. loading.text = '正在压缩地图文件,大文件可能需要较长时间...';
  1278. const compressResponse = await mapApi.compressMapExport({
  1279. map: mapName,
  1280. ignore: ignoreComponents
  1281. });
  1282. if (compressResponse && compressResponse.status) {
  1283. // 第二步:下载压缩包
  1284. loading.text = '正在下载压缩文件...';
  1285. try {
  1286. const downloadResponse = await mapApi.downloadMapExport({
  1287. map: mapName
  1288. });
  1289. // 检查响应是否为有效的blob
  1290. if (downloadResponse instanceof Blob && downloadResponse.size > 0) {
  1291. // 使用file-saver库进行下载
  1292. saveAs(downloadResponse, `${mapName}.zip`);
  1293. this.$message.success(`地图 "${mapName}" 下载完成`);
  1294. this.downloadOpen = false;
  1295. } else {
  1296. // 如果不是有效的blob,可能是错误响应
  1297. console.error('下载响应不是有效的文件:', downloadResponse);
  1298. this.$message.error('下载失败:服务器返回的不是有效文件');
  1299. }
  1300. } catch (downloadError) {
  1301. console.error('下载文件失败:', downloadError);
  1302. if (downloadError.message && downloadError.message.includes('timeout')) {
  1303. this.$confirm('下载超时,可能是文件较大或网络较慢。是否重试?', '下载超时', {
  1304. confirmButtonText: '重试',
  1305. cancelButtonText: '取消',
  1306. type: 'warning'
  1307. }).then(() => {
  1308. // 重试下载
  1309. this.retryDownload(mapName);
  1310. }).catch(() => {
  1311. this.downloadOpen = false;
  1312. });
  1313. } else if (downloadError.message && downloadError.message.includes('404')) {
  1314. this.$message.error('下载文件不存在,可能压缩失败,请重新尝试');
  1315. } else {
  1316. this.$message.error('下载文件失败: ' + (downloadError.message || '网络错误'));
  1317. }
  1318. }
  1319. } else {
  1320. this.$message.error('地图压缩失败,请检查地图文件是否完整');
  1321. }
  1322. } catch (error) {
  1323. console.error('下载失败:', error);
  1324. if (error.message && error.message.includes('timeout')) {
  1325. this.$message.error('压缩超时,请稍后重试或联系管理员');
  1326. } else {
  1327. this.$message.error('下载失败: ' + (error.message || '网络错误'));
  1328. }
  1329. } finally {
  1330. loading.close();
  1331. }
  1332. },
  1333. // 重试下载方法
  1334. async retryDownload(mapName) {
  1335. const loading = this.$loading({
  1336. lock: true,
  1337. text: '正在重试下载...',
  1338. spinner: 'el-icon-loading',
  1339. background: 'rgba(0, 0, 0, 0.7)',
  1340. customClass: 'large-loading-text',
  1341. target: document.body
  1342. });
  1343. // 应用自定义样式
  1344. this.applyLoadingStyle();
  1345. try {
  1346. const downloadResponse = await mapApi.downloadMapExport({
  1347. map: mapName
  1348. });
  1349. if (downloadResponse instanceof Blob && downloadResponse.size > 0) {
  1350. saveAs(downloadResponse, `${mapName}.zip`);
  1351. this.$message.success(`地图 "${mapName}" 下载完成`);
  1352. this.downloadOpen = false;
  1353. } else {
  1354. this.$message.error('重试失败:服务器返回的不是有效文件');
  1355. }
  1356. } catch (error) {
  1357. console.error('重试下载失败:', error);
  1358. this.$message.error('重试失败: ' + (error.message || '网络错误'));
  1359. } finally {
  1360. loading.close();
  1361. }
  1362. },
  1363. // 应用自定义loading样式的辅助方法
  1364. applyLoadingStyle() {
  1365. this.$nextTick(() => {
  1366. const loadingMask = document.querySelector('.el-loading-mask.large-loading-text');
  1367. if (loadingMask) {
  1368. const textEl = loadingMask.querySelector('.el-loading-text');
  1369. const spinnerEl = loadingMask.querySelector('.el-loading-spinner');
  1370. const iconEl = loadingMask.querySelector('.el-icon-loading');
  1371. if (textEl) {
  1372. textEl.style.fontSize = '18px';
  1373. textEl.style.fontWeight = '500';
  1374. textEl.style.color = '#ffffff';
  1375. textEl.style.lineHeight = '1.5';
  1376. textEl.style.marginTop = '10px';
  1377. }
  1378. if (spinnerEl) {
  1379. spinnerEl.style.fontSize = '32px';
  1380. }
  1381. if (iconEl) {
  1382. iconEl.style.fontSize = '32px';
  1383. iconEl.style.color = '#ffffff';
  1384. }
  1385. }
  1386. });
  1387. },
  1388. submitConstruct() {
  1389. console.log('Construct mode:', this.constructModle)
  1390. this.constructOpen = false
  1391. this.$message.success('构建已开始')
  1392. },
  1393. // 加载地图组件列表
  1394. async loadMapComponents(row) {
  1395. const mapName = row.map || row.mapName || row.name;
  1396. if (!mapName) {
  1397. this.$message.error('无法获取地图名称');
  1398. return;
  1399. }
  1400. try {
  1401. // 调用组件列表接口
  1402. const response = await mapApi.getMapComponents({ map: mapName });
  1403. if (response && response.status && response.components) {
  1404. // 保存可用组件列表
  1405. this.availableComponents = response.components;
  1406. // 默认选中所有可用的组件
  1407. this.downLoadTypes = response.components.map(component => component.label);
  1408. // 设置当前操作的行数据和标题
  1409. this.currentRow = row;
  1410. this.title = `下载地图 - ${row.name || row.mapName || row.map || ''}`;
  1411. // 打开下载对话框
  1412. this.downloadOpen = true;
  1413. } else {
  1414. this.$message.error('获取地图组件列表失败');
  1415. }
  1416. } catch (error) {
  1417. console.error('获取地图组件列表失败:', error);
  1418. this.$message.error('获取地图组件列表失败: ' + (error.message || '网络错误'));
  1419. }
  1420. }
  1421. }
  1422. };
  1423. </script>
  1424. <style lang="scss" scoped>
  1425. .map-list-container {
  1426. min-height: 100vh;
  1427. background: var(--color-bg-secondary);
  1428. padding: var(--spacing-6);
  1429. .toolbar-section {
  1430. background: var(--color-bg-card);
  1431. border-radius: 12px;
  1432. padding: var(--spacing-5);
  1433. margin-bottom: var(--spacing-6);
  1434. box-shadow: 0 6px 18px rgba(2, 6, 23, 0.05);
  1435. display: flex;
  1436. align-items: center;
  1437. justify-content: space-between;
  1438. gap: var(--spacing-4);
  1439. .toolbar-filters {
  1440. display: flex;
  1441. align-items: center;
  1442. gap: var(--spacing-3);
  1443. .search-input {
  1444. width: 260px;
  1445. }
  1446. .status-filter,
  1447. .sort-select {
  1448. width: 140px;
  1449. }
  1450. .el-input,
  1451. .el-select {
  1452. ::v-deep .el-input__inner {
  1453. border-radius: var(--radius-lg);
  1454. transition: all var(--duration-200) var(--ease-out);
  1455. &:focus {
  1456. border-color: var(--color-primary);
  1457. box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.15);
  1458. }
  1459. }
  1460. }
  1461. }
  1462. .toolbar-actions {
  1463. display: flex;
  1464. align-items: center;
  1465. gap: var(--spacing-3);
  1466. .el-button {
  1467. border-radius: var(--radius-base);
  1468. font-weight: var(--font-weight-medium);
  1469. }
  1470. .view-toggle {
  1471. margin-left: var(--spacing-2);
  1472. .el-button {
  1473. padding: 8px 12px;
  1474. border-radius: var(--radius-base);
  1475. &:first-child {
  1476. border-top-right-radius: 0;
  1477. border-bottom-right-radius: 0;
  1478. }
  1479. &:last-child {
  1480. border-top-left-radius: 0;
  1481. border-bottom-left-radius: 0;
  1482. }
  1483. }
  1484. }
  1485. }
  1486. }
  1487. .main-content {
  1488. margin-bottom: var(--spacing-6);
  1489. // 卡片网格
  1490. .card-grid {
  1491. display: grid;
  1492. grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
  1493. gap: var(--spacing-6);
  1494. &.compact {
  1495. gap: var(--spacing-4);
  1496. grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  1497. }
  1498. @media (min-width: 1440px) {
  1499. grid-template-columns: repeat(3, 1fr);
  1500. &.compact {
  1501. grid-template-columns: repeat(4, 1fr);
  1502. }
  1503. }
  1504. }
  1505. // 骨架屏
  1506. .skeleton-card {
  1507. background: var(--color-bg-card);
  1508. border-radius: 12px;
  1509. overflow: hidden;
  1510. box-shadow: 0 6px 18px rgba(2, 6, 23, 0.05);
  1511. .skeleton-thumb {
  1512. height: 160px;
  1513. background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-bg-tertiary) 50%, var(--color-bg-secondary) 75%);
  1514. background-size: 200% 100%;
  1515. animation: skeleton-loading 1.5s infinite;
  1516. }
  1517. .skeleton-content {
  1518. padding: 16px;
  1519. .skeleton-title,
  1520. .skeleton-remark,
  1521. .skeleton-meta {
  1522. background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-bg-tertiary) 50%, var(--color-bg-secondary) 75%);
  1523. background-size: 200% 100%;
  1524. animation: skeleton-loading 1.5s infinite;
  1525. border-radius: var(--radius-base);
  1526. }
  1527. .skeleton-title {
  1528. height: 20px;
  1529. margin-bottom: var(--spacing-3);
  1530. width: 80%;
  1531. }
  1532. .skeleton-remark {
  1533. height: 14px;
  1534. margin-bottom: var(--spacing-2);
  1535. width: 100%;
  1536. }
  1537. .skeleton-meta {
  1538. height: 12px;
  1539. width: 60%;
  1540. }
  1541. }
  1542. }
  1543. // 表格容器
  1544. .table-wrapper {
  1545. background: var(--color-bg-card);
  1546. border-radius: 12px;
  1547. overflow: hidden;
  1548. box-shadow: 0 6px 18px rgba(2, 6, 23, 0.05);
  1549. }
  1550. // 表格
  1551. .map-table {
  1552. ::v-deep .el-table__header {
  1553. background: var(--color-bg-secondary);
  1554. }
  1555. ::v-deep .el-table__body {
  1556. .el-table__row {
  1557. &:hover {
  1558. background: var(--color-bg-secondary);
  1559. }
  1560. }
  1561. }
  1562. ::v-deep .el-table__header th,
  1563. ::v-deep .el-table__body td {
  1564. padding: 10px 12px;
  1565. }
  1566. ::v-deep .el-table-column--selection .cell {
  1567. display: flex;
  1568. justify-content: center;
  1569. align-items: center;
  1570. padding: 10px 8px;
  1571. }
  1572. /* 表头"序号"不换行、居中 */
  1573. ::v-deep .col-index-header .cell {
  1574. white-space: nowrap;
  1575. text-align: center;
  1576. padding: 10px 8px;
  1577. }
  1578. /* 序号数字等宽、居中、颜色稍弱一点 */
  1579. ::v-deep .col-index .cell {
  1580. display: flex;
  1581. justify-content: center;
  1582. align-items: center;
  1583. padding: 10px 8px;
  1584. color: var(--color-text-secondary, #6b7280);
  1585. font-variant-numeric: tabular-nums; /* 等宽数字 */
  1586. }
  1587. ::v-deep .cell {
  1588. line-height: 20px;
  1589. white-space: nowrap;
  1590. }
  1591. /* 避免某些主题对固定列的默认阴影/间距影响布局(现在已不固定,这里只是兜底) */
  1592. ::v-deep .el-table__fixed-right {
  1593. right: 0;
  1594. box-shadow: none;
  1595. }
  1596. .table-name {
  1597. display: flex;
  1598. align-items: center;
  1599. gap: var(--spacing-2);
  1600. }
  1601. .table-thumb {
  1602. width: 40px;
  1603. height: 24px;
  1604. border-radius: var(--radius-base);
  1605. overflow: hidden;
  1606. background: var(--color-bg-secondary);
  1607. display: flex;
  1608. align-items: center;
  1609. justify-content: center;
  1610. margin: 0 auto;
  1611. img {
  1612. width: 100%;
  1613. height: 100%;
  1614. object-fit: cover;
  1615. }
  1616. i {
  1617. color: var(--color-text-quaternary);
  1618. font-size: var(--font-size-sm);
  1619. }
  1620. }
  1621. .op-cell {
  1622. display: flex;
  1623. justify-content: flex-end;
  1624. align-items: center;
  1625. gap: 8px;
  1626. }
  1627. .op-link {
  1628. display: inline-flex;
  1629. align-items: center;
  1630. color: var(--color-primary, #1d4ed8);
  1631. text-decoration: none;
  1632. font-size: var(--font-size-sm);
  1633. transition: color 0.2s ease;
  1634. &:hover {
  1635. color: var(--color-primary-light);
  1636. text-decoration: none;
  1637. }
  1638. }
  1639. .el-dropdown-link {
  1640. color: var(--color-primary);
  1641. cursor: pointer;
  1642. display: inline-flex;
  1643. align-items: center;
  1644. font-size: var(--font-size-sm);
  1645. &:hover {
  1646. color: var(--color-primary-light);
  1647. }
  1648. }
  1649. .op-text {
  1650. margin-left: 4px;
  1651. }
  1652. .xt-more .is-danger {
  1653. color: #e11d48;
  1654. }
  1655. .xt-more .is-danger i {
  1656. color: #e11d48;
  1657. }
  1658. }
  1659. // 空态
  1660. .empty-state {
  1661. text-align: center;
  1662. padding: var(--spacing-12) var(--spacing-6);
  1663. background: var(--color-bg-card);
  1664. border-radius: 12px;
  1665. box-shadow: 0 6px 18px rgba(2, 6, 23, 0.05);
  1666. .empty-illustration {
  1667. margin-bottom: var(--spacing-6);
  1668. i {
  1669. font-size: 80px;
  1670. color: var(--color-text-quaternary);
  1671. opacity: 0.6;
  1672. }
  1673. }
  1674. .empty-title {
  1675. color: var(--color-text-primary);
  1676. font-size: var(--font-size-xl);
  1677. font-weight: var(--font-weight-semibold);
  1678. margin: 0 0 var(--spacing-3) 0;
  1679. }
  1680. .empty-description {
  1681. color: var(--color-text-secondary);
  1682. font-size: var(--font-size-base);
  1683. margin: 0 0 var(--spacing-6) 0;
  1684. line-height: var(--line-height-relaxed);
  1685. }
  1686. .empty-actions {
  1687. display: flex;
  1688. justify-content: center;
  1689. gap: var(--spacing-3);
  1690. .el-button {
  1691. border-radius: var(--radius-base);
  1692. font-weight: var(--font-weight-medium);
  1693. }
  1694. }
  1695. }
  1696. }
  1697. .pagination-section {
  1698. background: var(--color-bg-card);
  1699. border-radius: 12px;
  1700. padding: var(--spacing-4) var(--spacing-5);
  1701. box-shadow: 0 6px 18px rgba(2, 6, 23, 0.05);
  1702. display: flex;
  1703. align-items: center;
  1704. justify-content: space-between;
  1705. .density-toggle {
  1706. .el-button-group {
  1707. .el-button {
  1708. padding: 4px 12px;
  1709. font-size: var(--font-size-xs);
  1710. border-radius: var(--radius-base);
  1711. &:first-child {
  1712. border-top-right-radius: 0;
  1713. border-bottom-right-radius: 0;
  1714. }
  1715. &:last-child {
  1716. border-top-left-radius: 0;
  1717. border-bottom-left-radius: 0;
  1718. }
  1719. }
  1720. }
  1721. }
  1722. .el-pagination {
  1723. ::v-deep .el-pager {
  1724. .number {
  1725. border-radius: var(--radius-base);
  1726. margin: 0 2px;
  1727. }
  1728. }
  1729. ::v-deep .btn-prev,
  1730. ::v-deep .btn-next {
  1731. border-radius: var(--radius-base);
  1732. }
  1733. }
  1734. }
  1735. // 探索按钮样式
  1736. .xt-btn {
  1737. border-radius: var(--radius-base);
  1738. font-weight: var(--font-weight-medium);
  1739. transition: all var(--duration-200) var(--ease-out);
  1740. &:disabled {
  1741. opacity: 0.6;
  1742. cursor: not-allowed;
  1743. }
  1744. &:hover:not(:disabled) {
  1745. transform: translateY(-1px);
  1746. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  1747. }
  1748. }
  1749. // 探索弹框样式
  1750. .xt-explore-dialog ::v-deep .el-dialog {
  1751. border-radius: 12px;
  1752. }
  1753. .xt-explore-dialog ::v-deep .el-dialog__body {
  1754. padding: 18px 20px 6px;
  1755. }
  1756. /* 字体与间距 */
  1757. .xt-title {
  1758. display: flex;
  1759. flex-direction: column;
  1760. font-weight: 600;
  1761. font-size: 16px;
  1762. }
  1763. .xt-subtitle {
  1764. font-size: 12px;
  1765. color: var(--text-secondary, #6b7280);
  1766. font-weight: 400;
  1767. margin-top: 4px;
  1768. }
  1769. .xt-form {
  1770. background: white;
  1771. border-radius: 12px;
  1772. padding: 24px;
  1773. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
  1774. .el-form-item {
  1775. margin-bottom: 24px;
  1776. &:last-child {
  1777. margin-bottom: 0;
  1778. }
  1779. .el-form-item__label {
  1780. color: #333;
  1781. font-size: 14px;
  1782. font-weight: 500;
  1783. line-height: 1.4;
  1784. }
  1785. }
  1786. }
  1787. .xt-field {
  1788. display: flex;
  1789. align-items: center;
  1790. gap: 12px;
  1791. }
  1792. .xt-unit {
  1793. color: #666;
  1794. font-size: 14px;
  1795. font-weight: 500;
  1796. white-space: nowrap;
  1797. }
  1798. .xt-hint {
  1799. color: #888;
  1800. font-size: 12px;
  1801. margin-left: auto;
  1802. white-space: nowrap;
  1803. }
  1804. .xt-footer {
  1805. display: flex;
  1806. justify-content: flex-end;
  1807. gap: 12px;
  1808. padding: 16px 0;
  1809. .el-button {
  1810. padding: 10px 24px;
  1811. border-radius: 8px;
  1812. font-weight: 500;
  1813. transition: all 0.2s ease;
  1814. border: none;
  1815. &.el-button--default {
  1816. background: #f1f5f9;
  1817. color: #64748b;
  1818. &:hover {
  1819. background: #e2e8f0;
  1820. color: #475569;
  1821. transform: translateY(-1px);
  1822. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  1823. }
  1824. }
  1825. &.el-button--primary {
  1826. background: linear-gradient(135deg, #10b981, #059669);
  1827. color: white;
  1828. &:hover {
  1829. background: linear-gradient(135deg, #059669, #047857);
  1830. transform: translateY(-1px);
  1831. box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
  1832. }
  1833. }
  1834. }
  1835. }
  1836. /* 输入框外层容器样式 */
  1837. .xt-explore-dialog ::v-deep .el-input-number {
  1838. width: 160px;
  1839. border-radius: 24px;
  1840. overflow: hidden;
  1841. border: 1.5px solid #d1d5db;
  1842. transition: all 0.2s ease;
  1843. background: white;
  1844. &:hover {
  1845. border-color: #9ca3af;
  1846. }
  1847. &:focus-within,
  1848. &.is-focus {
  1849. border-color: #08C28E;
  1850. box-shadow: 0 0 0 2px rgba(8, 194, 142, 0.2);
  1851. }
  1852. .el-input__inner {
  1853. height: 40px;
  1854. line-height: 40px;
  1855. border: none;
  1856. border-radius: 0;
  1857. box-shadow: none;
  1858. padding-right: 40px;
  1859. font-size: 14px;
  1860. background: transparent;
  1861. &:focus {
  1862. outline: none;
  1863. border: none;
  1864. box-shadow: none;
  1865. }
  1866. &::placeholder {
  1867. color: #9ca3af;
  1868. font-size: 13px;
  1869. }
  1870. }
  1871. }
  1872. /* 右侧加减按钮 */
  1873. .xt-explore-dialog ::v-deep .el-input-number.is-controls-right .el-input-number__increase,
  1874. .xt-explore-dialog ::v-deep .el-input-number.is-controls-right .el-input-number__decrease {
  1875. right: 0;
  1876. border: none;
  1877. border-left: 1.5px solid #d1d5db;
  1878. width: 35px;
  1879. height: 20px;
  1880. border-radius: 0;
  1881. background: #f9fafb;
  1882. color: #6b7280;
  1883. transition: all 0.2s ease;
  1884. &:hover {
  1885. background: #f3f4f6;
  1886. color: #374151;
  1887. }
  1888. }
  1889. // 遥控探索确认弹框样式
  1890. .xt-confirm-dialog {
  1891. ::v-deep .el-dialog {
  1892. border-radius: 12px;
  1893. }
  1894. .confirm-title {
  1895. display: flex;
  1896. align-items: center;
  1897. font-size: 16px;
  1898. font-weight: 600;
  1899. color: #333;
  1900. }
  1901. .confirm-content {
  1902. padding: 20px 0;
  1903. p {
  1904. margin: 0;
  1905. font-size: 14px;
  1906. line-height: 1.6;
  1907. color: #666;
  1908. }
  1909. }
  1910. ::v-deep .el-dialog__footer {
  1911. padding: 16px 24px 24px;
  1912. .el-button {
  1913. padding: 8px 20px;
  1914. border-radius: 6px;
  1915. font-weight: 500;
  1916. &.el-button--primary {
  1917. background: linear-gradient(135deg, #10b981, #059669);
  1918. border-color: #10b981;
  1919. &:hover {
  1920. background: linear-gradient(135deg, #059669, #047857);
  1921. border-color: #059669;
  1922. }
  1923. }
  1924. }
  1925. }
  1926. }
  1927. // 保留的对话框样式
  1928. ::v-deep .el-dialog__body {
  1929. padding: var(--spacing-5);
  1930. }
  1931. }
  1932. // 骨架屏动画
  1933. @keyframes skeleton-loading {
  1934. 0% {
  1935. background-position: -200% 0;
  1936. }
  1937. 100% {
  1938. background-position: 200% 0;
  1939. }
  1940. }
  1941. // 暗色主题适配
  1942. html.dark {
  1943. .map-list-container {
  1944. .toolbar-section {
  1945. background: var(--color-bg-tertiary);
  1946. box-shadow: 0 6px 18px rgba(0, 0, 0, 0.15);
  1947. }
  1948. .skeleton-card {
  1949. background: var(--color-bg-tertiary);
  1950. box-shadow: 0 6px 18px rgba(0, 0, 0, 0.15);
  1951. }
  1952. .table-wrapper {
  1953. background: var(--color-bg-tertiary);
  1954. box-shadow: 0 6px 18px rgba(0, 0, 0, 0.15);
  1955. }
  1956. .map-table {
  1957. ::v-deep .el-table__header {
  1958. background: var(--color-bg-quaternary);
  1959. }
  1960. ::v-deep .el-table__body {
  1961. .el-table__row {
  1962. &:hover {
  1963. background: var(--color-bg-quaternary);
  1964. }
  1965. }
  1966. }
  1967. }
  1968. .empty-state,
  1969. .pagination-section {
  1970. background: var(--color-bg-tertiary);
  1971. box-shadow: 0 6px 18px rgba(0, 0, 0, 0.15);
  1972. }
  1973. // 暗色主题下的探索弹框样式
  1974. .xt-explore-dialog {
  1975. ::v-deep .el-dialog {
  1976. background: #1e293b;
  1977. box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
  1978. }
  1979. ::v-deep .el-dialog__header {
  1980. background: linear-gradient(135deg, #334155, #475569);
  1981. border-bottom: 1px solid #475569;
  1982. }
  1983. ::v-deep .el-dialog__body,
  1984. ::v-deep .el-dialog__footer {
  1985. background: #1e293b;
  1986. }
  1987. ::v-deep .el-dialog__footer {
  1988. border-top: 1px solid #475569;
  1989. }
  1990. .xt-title {
  1991. color: #f1f5f9;
  1992. }
  1993. .xt-subtitle {
  1994. color: #94a3b8;
  1995. }
  1996. .xt-form {
  1997. background: #0f172a;
  1998. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
  1999. .el-form-item__label {
  2000. color: #e2e8f0 !important;
  2001. }
  2002. }
  2003. .xt-unit {
  2004. color: #cbd5e1;
  2005. }
  2006. .xt-hint {
  2007. color: #94a3b8;
  2008. }
  2009. ::v-deep .el-input-number {
  2010. background: #0f172a;
  2011. border-color: #475569;
  2012. &:hover {
  2013. border-color: #64748b;
  2014. }
  2015. &:focus-within,
  2016. &.is-focus {
  2017. border-color: #08C28E;
  2018. box-shadow: 0 0 0 2px rgba(8, 194, 142, 0.3);
  2019. }
  2020. .el-input__inner {
  2021. background: transparent;
  2022. color: #e2e8f0;
  2023. &::placeholder {
  2024. color: #64748b;
  2025. }
  2026. }
  2027. }
  2028. ::v-deep .el-input-number__increase,
  2029. ::v-deep .el-input-number__decrease {
  2030. background: #334155;
  2031. border-color: #475569;
  2032. color: #94a3b8;
  2033. &:hover {
  2034. background: #475569;
  2035. color: #e2e8f0;
  2036. }
  2037. }
  2038. .xt-footer {
  2039. .el-button {
  2040. &.el-button--default {
  2041. background: #334155;
  2042. color: #94a3b8;
  2043. &:hover {
  2044. background: #475569;
  2045. color: #e2e8f0;
  2046. }
  2047. }
  2048. &.el-button--primary {
  2049. background: linear-gradient(135deg, #10b981, #059669);
  2050. color: white;
  2051. &:hover {
  2052. background: linear-gradient(135deg, #059669, #047857);
  2053. }
  2054. }
  2055. }
  2056. }
  2057. }
  2058. // 暗色主题下的确认弹框样式
  2059. .xt-confirm-dialog {
  2060. ::v-deep .el-dialog {
  2061. background: #1e293b;
  2062. }
  2063. .confirm-title {
  2064. color: #f1f5f9;
  2065. }
  2066. .confirm-content p {
  2067. color: #cbd5e1;
  2068. }
  2069. ::v-deep .el-dialog__footer {
  2070. background: #1e293b;
  2071. .el-button {
  2072. &:not(.el-button--primary) {
  2073. background: #334155;
  2074. color: #94a3b8;
  2075. border-color: #475569;
  2076. &:hover {
  2077. background: #475569;
  2078. color: #e2e8f0;
  2079. }
  2080. }
  2081. }
  2082. }
  2083. }
  2084. }
  2085. }
  2086. // 响应式设计
  2087. @media (max-width: 1439px) {
  2088. .map-list-container {
  2089. .main-content {
  2090. .card-grid {
  2091. grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  2092. &.compact {
  2093. grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
  2094. }
  2095. }
  2096. }
  2097. }
  2098. }
  2099. @media (max-width: 768px) {
  2100. .map-list-container {
  2101. padding: var(--spacing-4);
  2102. .toolbar-section {
  2103. flex-direction: column;
  2104. align-items: stretch;
  2105. gap: var(--spacing-4);
  2106. .toolbar-filters {
  2107. flex-direction: column;
  2108. align-items: stretch;
  2109. .search-input,
  2110. .status-filter,
  2111. .sort-select {
  2112. width: 100%;
  2113. }
  2114. }
  2115. .toolbar-actions {
  2116. justify-content: space-between;
  2117. flex-wrap: wrap;
  2118. .view-toggle {
  2119. margin-left: 0;
  2120. order: -1;
  2121. }
  2122. }
  2123. }
  2124. .main-content {
  2125. .card-grid {
  2126. grid-template-columns: 1fr;
  2127. gap: var(--spacing-4);
  2128. &.compact {
  2129. gap: var(--spacing-3);
  2130. }
  2131. }
  2132. }
  2133. .pagination-section {
  2134. flex-direction: column;
  2135. gap: var(--spacing-3);
  2136. .density-toggle {
  2137. order: 1;
  2138. }
  2139. }
  2140. }
  2141. }
  2142. /* 自定义加载蒙版样式 - 更大的字体 */
  2143. ::v-deep .large-loading-text .el-loading-text {
  2144. font-size: 18px !important;
  2145. font-weight: 500 !important;
  2146. color: #ffffff !important;
  2147. line-height: 1.5 !important;
  2148. margin-top: 10px !important;
  2149. }
  2150. ::v-deep .large-loading-text .el-loading-spinner {
  2151. font-size: 32px !important;
  2152. }
  2153. ::v-deep .large-loading-text .el-loading-spinner .el-icon-loading {
  2154. font-size: 32px !important;
  2155. color: #ffffff !important;
  2156. }
  2157. /* 全局样式备用方案 */
  2158. .el-loading-mask.large-loading-text .el-loading-text {
  2159. font-size: 18px !important;
  2160. font-weight: 500 !important;
  2161. color: #ffffff !important;
  2162. line-height: 1.5 !important;
  2163. margin-top: 10px !important;
  2164. }
  2165. .el-loading-mask.large-loading-text .el-loading-spinner {
  2166. font-size: 32px !important;
  2167. }
  2168. .el-loading-mask.large-loading-text .el-loading-spinner .el-icon-loading {
  2169. font-size: 32px !important;
  2170. color: #ffffff !important;
  2171. }
  2172. </style>
  2173. <style>
  2174. /* 全局加载蒙版字体样式 - 不使用scoped */
  2175. .el-loading-mask.large-loading-text .el-loading-text {
  2176. font-size: 18px !important;
  2177. font-weight: 500 !important;
  2178. color: #ffffff !important;
  2179. line-height: 1.5 !important;
  2180. margin-top: 10px !important;
  2181. }
  2182. .el-loading-mask.large-loading-text .el-loading-spinner {
  2183. font-size: 32px !important;
  2184. }
  2185. .el-loading-mask.large-loading-text .el-loading-spinner .el-icon-loading {
  2186. font-size: 32px !important;
  2187. color: #ffffff !important;
  2188. }
  2189. .el-loading-mask.large-loading-text .el-loading-spinner .circular {
  2190. width: 32px !important;
  2191. height: 32px !important;
  2192. }
  2193. /* 更强力的样式覆盖 */
  2194. body .el-loading-mask.large-loading-text .el-loading-text {
  2195. font-size: 18px !important;
  2196. font-weight: 500 !important;
  2197. color: #ffffff !important;
  2198. line-height: 1.5 !important;
  2199. margin-top: 10px !important;
  2200. }
  2201. body .el-loading-mask.large-loading-text .el-loading-spinner {
  2202. font-size: 32px !important;
  2203. }
  2204. body .el-loading-mask.large-loading-text .el-loading-spinner .el-icon-loading {
  2205. font-size: 32px !important;
  2206. color: #ffffff !important;
  2207. }
  2208. /* 通用loading文字样式覆盖 */
  2209. .large-loading-text .el-loading-text {
  2210. font-size: 18px !important;
  2211. font-weight: 500 !important;
  2212. color: #ffffff !important;
  2213. line-height: 1.5 !important;
  2214. margin-top: 10px !important;
  2215. }
  2216. .large-loading-text .el-loading-spinner {
  2217. font-size: 32px !important;
  2218. }
  2219. .large-loading-text .el-loading-spinner i {
  2220. font-size: 32px !important;
  2221. color: #ffffff !important;
  2222. }
  2223. </style>