index.vue 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731
  1. <template>
  2. <div class="dispatch-container">
  3. <!-- Top Filter Bar -->
  4. <div class="top-filter-bar glass-panel-sm">
  5. <div class="filter-left">
  6. <el-radio-group v-model="filters.orderType" size="default" fill="#2d8cf0">
  7. <el-radio-button label="all">全部</el-radio-button>
  8. <el-radio-button v-for="item in serviceOnOrderList" :key="item.id" :label="item.id">{{ item.name
  9. }}</el-radio-button>
  10. </el-radio-group>
  11. </div>
  12. <div class="filter-right">
  13. <el-cascader v-model="regionValue" :options="areaOptions" placeholder="省市区域" clearable
  14. style="width: 170px; margin-right: 10px" @change="handleAreaChange" />
  15. <el-select v-model="filters.station" placeholder="站点" clearable style="width: 170px">
  16. <el-option v-for="item in siteOptions" :key="item.value" :label="item.label" :value="item.value" />
  17. </el-select>
  18. </div>
  19. </div>
  20. <div class="main-content">
  21. <!-- Left: Real Gaode Map Area -->
  22. <div class="map-wrapper">
  23. <template v-if="checkPermi(['order:dispatch:map'])">
  24. <div id="amap-container" class="map-view"></div>
  25. <!-- Bottom Left: Map Controls & Stats -->
  26. <div class="map-controls-panel">
  27. <div class="control-group">
  28. <div class="c-btn" :class="{ active: activeMapFilter === 'all' }" @click="setMapFilter('all')">全部</div>
  29. <div class="c-btn red" :class="{ active: activeMapFilter === 'merchants' }"
  30. @click="setMapFilter('merchants')">商家({{ merchantList.length }})</div>
  31. <div class="c-btn green" :class="{ active: activeMapFilter === 'fulfillers' }"
  32. @click="setMapFilter('fulfillers')">履约者({{ ridersList.length }})</div>
  33. <div class="c-btn blue" :class="{ active: activeMapFilter === 'orders' }" @click="setMapFilter('orders')">
  34. 订单({{ ordersList.length }})</div>
  35. <div class="c-btn gray">灰色表示离线</div>
  36. </div>
  37. </div>
  38. </template>
  39. <div v-else class="no-auth-map">
  40. <div class="no-auth-content">
  41. <div class="lock-icon">
  42. <!-- Inline SVG for Lock Icon -->
  43. <svg viewBox="0 0 1024 1024" width="64" height="64">
  44. <path d="M512 64a256 256 0 0 0-256 256v128h-64a64 64 0 0 0-64 64v384a64 64 0 0 0 64 64h640a64 64 0 0 0 64-64V512a64 64 0 0 0-64-64h-64V320a256 256 0 0 0-256-256z m160 384H352V320a160 160 0 1 1 320 0v128z" fill="#E4E7ED"/>
  45. </svg>
  46. </div>
  47. <h3 class="no-auth-title">暂无地图权限</h3>
  48. <p class="no-auth-desc">当前角色尚未分配地图查看权限,无法展示实时调度数据</p>
  49. </div>
  50. </div>
  51. </div>
  52. <!-- Right: Dispatch Control Panel -->
  53. <div class="right-panel">
  54. <OrderListPanel v-model="currentOrderTab" :orders="filteredOrders" :stats="orderStats" @focus="focusMapPoint"
  55. @dispatch="openDispatchDialog" />
  56. <RiderListPanel v-model="currentRiderTab" :riders="filteredRiders" :stats="riderStats" @focus="focusMapPoint"
  57. @view-orders="handleViewRiderOrders" />
  58. </div>
  59. </div>
  60. <RiderOrdersDialog v-model="riderOrdersVisible" :riderInfo="currentRiderInfo" :orders="currentRiderOrders" />
  61. <DispatchDialog v-model:visible="dispatchDialogVisible" :order="currentDispatchOrder"
  62. :service-list="serviceOnOrderList" @submit="handleDispatchSubmit" />
  63. </div>
  64. </template>
  65. <script setup lang="ts">
  66. import { ref, computed, reactive, onMounted, watch, getCurrentInstance, ComponentInternalInstance } from 'vue';
  67. import { checkPermi } from "@/utils/permission";
  68. import { ElMessage } from 'element-plus';
  69. import { listAllService } from '@/api/service/list/index'
  70. import { listAreaStation } from '@/api/system/areaStation'
  71. import { listStoreOnDispatch } from '@/api/system/store/index'
  72. import { listSubOrderOnDispatch, dispatchSubOrder } from '@/api/order/subOrder/index';
  73. import { listFulfillerOnDispatch } from '@/api/fulfiller/pool/index'
  74. import { getFulfillerGps } from '@/api/fulfiller/fulfiller/index'
  75. import OrderListPanel from './components/OrderListPanel.vue';
  76. import RiderListPanel from './components/RiderListPanel.vue';
  77. import RiderOrdersDialog from './components/RiderOrdersDialog.vue';
  78. import DispatchDialog from './components/DispatchDialog.vue';
  79. // Mock Data
  80. import dispatchMockData from '@/mock/dispatch.json';
  81. import riderOrdersMockData from '@/mock/RiderOrdersDialog.json';
  82. const { proxy } = getCurrentInstance() as ComponentInternalInstance;
  83. // --- Data & State ---
  84. const filters = reactive({
  85. orderType: 'all',
  86. station: undefined
  87. });
  88. const currentOrderTab = ref('PendingDispatch');
  89. const currentRiderTab = ref('All');
  90. const activeMapFilter = ref('all');
  91. const ordersList = ref([]);
  92. const ridersList = ref([]);
  93. const merchantList = ref([]);
  94. const orderStats = computed(() => ({
  95. pendingDispatch: ordersList.value.filter(o => o.status === 0).length,
  96. pendingAccept: ordersList.value.filter(o => o.status === 1).length,
  97. processing: ordersList.value.filter(o => [2, 3].includes(o.status)).length
  98. }));
  99. const riderStats = computed(() => ({
  100. all: ridersList.value.length,
  101. working: ridersList.value.filter(r => r.uiStatus === 'busy').length,
  102. resting: ridersList.value.filter(r => r.uiStatus === 'offline').length,
  103. disabled: ridersList.value.filter(r => r.uiStatus === 'disabled').length
  104. }));
  105. const serviceOnOrderList = ref([])
  106. const getServiceList = () => {
  107. listAllService().then(res => {
  108. serviceOnOrderList.value = res?.data || []
  109. }).catch(() => {
  110. serviceOnOrderList.value = []
  111. })
  112. }
  113. const getOrdersList = () => {
  114. if (!filters.station) return;
  115. listSubOrderOnDispatch({
  116. service: filters.orderType !== 'all' ? filters.orderType : undefined,
  117. site: filters.station
  118. }).then(res => {
  119. const list = (res?.data || []).map(item => {
  120. const typeName = getServiceName(item.service);
  121. const isTransport = item.mode === 1 || item.mode === '1';
  122. const typeCode = isTransport ? 'transport' : typeName?.includes('喂') || typeName?.includes('遛') ? 'feeding' : 'washing';
  123. // 模拟经纬度
  124. return {
  125. ...item,
  126. typeCode,
  127. lng: item.lng || (116.4 + Math.random() * 0.1),
  128. lat: item.lat || (39.9 + Math.random() * 0.05)
  129. };
  130. });
  131. ordersList.value = list;
  132. })
  133. }
  134. const getServiceName = (serviceId) => {
  135. const item = serviceOnOrderList.value.find((i) => i.id === serviceId);
  136. return item ? item.name : '未知服务';
  137. };
  138. const getRidersList = async () => {
  139. if (!filters.station) return;
  140. try {
  141. const res = await listFulfillerOnDispatch({
  142. service: filters.orderType !== 'all' ? filters.orderType : undefined,
  143. site: filters.station
  144. });
  145. const baseList = (res.data || []).map(r => ({
  146. ...r,
  147. uiStatus: r.status === 'busy' ? 'busy' : r.status === 'resting' ? 'offline' : 'disabled',
  148. maskPhone: r.phone ? r.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : '',
  149. categories: (r.serviceTypes || '').split(',').map(id => {
  150. const s = serviceOnOrderList.value.find(item => String(item.id) === String(id));
  151. return s ? s.name.substring(0, 2) : '服务';
  152. }),
  153. nextOrderTime: r.nextOrderTime || '14:30',
  154. // GPS 坐标初始为 null,等待并发请求后覆盖
  155. lng: null,
  156. lat: null
  157. }));
  158. // 并发请求所有骑手的实时 GPS 坐标
  159. const gpsResults = await Promise.allSettled(
  160. baseList.map(r => getFulfillerGps(r.id))
  161. );
  162. ridersList.value = baseList.map((r, idx) => {
  163. const result = gpsResults[idx];
  164. // 只有当 data 不为空且含有经纬度数据时才赋値
  165. if (result.status === 'fulfilled' && result.value?.data) {
  166. const gps = result.value.data;
  167. if (gps.longitude && gps.latitude) {
  168. return { ...r, lng: gps.longitude, lat: gps.latitude };
  169. }
  170. }
  171. return r; // 无数据保持 lng/lat 为 null,不在地图上显示
  172. });
  173. refreshMarkers();
  174. } catch {
  175. ridersList.value = [];
  176. refreshMarkers();
  177. }
  178. }
  179. const getMerchantList = () => {
  180. if (!filters.station) {
  181. merchantList.value = [];
  182. refreshMarkers();
  183. return;
  184. }
  185. listStoreOnDispatch({ site: filters.station }).then(res => {
  186. // 保留全量门店数据(用于统计数字),有坐标的赋 lng/lat,无坐标则不在地图打点
  187. merchantList.value = (res?.data || []).map(item => ({
  188. ...item,
  189. lng: item.longitude || null,
  190. lat: item.latitude || null
  191. }));
  192. refreshMarkers();
  193. }).catch(() => {
  194. merchantList.value = [];
  195. refreshMarkers();
  196. });
  197. }
  198. watch([() => filters.orderType, () => filters.station], () => {
  199. if (!filters.station) {
  200. ordersList.value = [];
  201. ridersList.value = [];
  202. merchantList.value = [];
  203. refreshMarkers();
  204. return;
  205. }
  206. getOrdersList();
  207. getRidersList();
  208. getMerchantList();
  209. });
  210. const areaStationList = ref([])
  211. const areaOptions = ref([])
  212. const siteOptions = ref([])
  213. const regionValue = ref([])
  214. const buildTree = (data, parentId) => {
  215. return (data || [])
  216. .filter(item => String(item.parentId) === String(parentId))
  217. .map(item => ({
  218. value: item.id,
  219. label: item.name,
  220. children: buildTree(data, item.id)
  221. }))
  222. }
  223. const handleAreaChange = (value) => {
  224. filters.station = undefined
  225. if (value && value.length > 0) {
  226. const areaId = value[value.length - 1]
  227. siteOptions.value = areaStationList.value
  228. .filter(item => Number(item.type) === 2 && String(item.parentId) === String(areaId))
  229. .map(item => ({ value: item.id, label: item.name }))
  230. } else {
  231. siteOptions.value = []
  232. }
  233. }
  234. const getAreaStationList = async () => {
  235. try {
  236. const res = await listAreaStation()
  237. const data = res?.data || res
  238. areaStationList.value = Array.isArray(data) ? data : []
  239. const areaData = areaStationList.value.filter(item => Number(item.type) === 0 || Number(item.type) === 1)
  240. areaOptions.value = buildTree(areaData, 0)
  241. siteOptions.value = []
  242. const allStations = areaStationList.value.filter(item => Number(item.type) === 2)
  243. if (allStations.length > 0) {
  244. const randomStation = allStations[Math.floor(Math.random() * allStations.length)]
  245. const areaId = randomStation.parentId
  246. const path = []
  247. let currentId = areaId
  248. while (currentId && String(currentId) !== '0') {
  249. path.unshift(currentId)
  250. const currentArea = areaStationList.value.find(item => String(item.id) === String(currentId))
  251. if (currentArea) {
  252. currentId = currentArea.parentId
  253. } else {
  254. break
  255. }
  256. }
  257. regionValue.value = path
  258. siteOptions.value = areaStationList.value
  259. .filter(item => Number(item.type) === 2 && String(item.parentId) === String(areaId))
  260. .map(item => ({ value: item.id, label: item.name }))
  261. filters.station = randomStation.id
  262. }
  263. } catch {
  264. areaStationList.value = []
  265. areaOptions.value = []
  266. siteOptions.value = []
  267. }
  268. }
  269. // Rider Orders State
  270. const riderOrdersVisible = ref(false);
  271. const currentRiderInfo = ref(null);
  272. const currentRiderOrders = ref([]);
  273. const handleViewRiderOrders = (rider) => {
  274. currentRiderInfo.value = {
  275. name: rider.name,
  276. maskPhone: rider.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'),
  277. station: rider.station + ' - 东城片区',
  278. workType: '全职专送',
  279. categories: ['宠物接送', '上门喂遛', '洗护套餐']
  280. };
  281. currentRiderOrders.value = riderOrdersMockData.orders;
  282. riderOrdersVisible.value = true;
  283. };
  284. // --- Map Logic ---
  285. let map = null;
  286. const amapKey = 'a30e76f457c14b6570925522be37565d';
  287. const securityJsCode = '531ae14ec1dff87e552e1ea51e848582';
  288. const loadAMapScript = () => {
  289. // 设置安全密钥
  290. window._AMapSecurityConfig = {
  291. securityJsCode: securityJsCode,
  292. };
  293. return new Promise((resolve, reject) => {
  294. if (window.AMap) {
  295. resolve(window.AMap);
  296. return;
  297. }
  298. const script = document.createElement('script');
  299. script.src = `https://webapi.amap.com/maps?v=2.0&key=${amapKey}`;
  300. script.onload = () => resolve(window.AMap);
  301. script.onerror = reject;
  302. document.head.appendChild(script);
  303. });
  304. };
  305. const initMap = () => {
  306. if (!window.AMap) return;
  307. map = new AMap.Map('amap-container', {
  308. zoom: 14,
  309. center: [116.4551, 39.9255], // Chaoyang center
  310. mapStyle: 'amap://styles/normal' // 可以更换其他样式
  311. });
  312. refreshMarkers();
  313. };
  314. const refreshMarkers = () => {
  315. if (!map) return;
  316. map.clearMap(); // 清除所有标记
  317. const filter = activeMapFilter.value;
  318. // 1. Merchants (商家) - 仅对有经纬度的门店打点
  319. if (filter === 'all' || filter === 'merchants') {
  320. merchantList.value.filter(m => m.lng && m.lat).forEach((m) => {
  321. const iconImg = m.icon || 'https://cube.elemecdn.com/e/fd/0fc7d20532fdaf769a25683617711png.png';
  322. const content = `
  323. <div style="position:relative; width: 220px; filter: drop-shadow(0 4px 10px rgba(0,0,0,0.2));">
  324. <div style="background:#fff; border-radius:8px; padding: 12px; display: flex; align-items: center; gap: 10px; position: relative;">
  325. <!-- Icon Ring -->
  326. <div style="width: 48px; height: 48px; border-radius: 50%; border: 4px solid #F56C6C; padding: 2px; flex-shrink: 0; box-sizing: border-box; display: flex; align-items: center; justify-content: center;">
  327. <img src="${iconImg}" style="width: 100%; height: 100%; border-radius: 50%; object-fit: cover;">
  328. </div>
  329. <!-- Info -->
  330. <div style="flex: 1; overflow: hidden;">
  331. <div style="font-weight: bold; font-size: 14px; color: #333; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${m.name}</div>
  332. <div style="font-size: 12px; color: #666;">今日 <span style="color: #F56C6C; font-weight: bold; font-size: 14px;">0</span> 单</div>
  333. </div>
  334. </div>
  335. <!-- Triangle -->
  336. <div style="width: 0; height: 0; border-left: 8px solid transparent; border-right: 8px solid transparent; border-top: 10px solid #fff; margin: 0 auto; margin-top: -1px;"></div>
  337. </div>
  338. `;
  339. const marker = new AMap.Marker({
  340. position: [m.lng, m.lat],
  341. content: content,
  342. offset: new AMap.Pixel(-110, -85)
  343. });
  344. map.add(marker);
  345. });
  346. }
  347. // 2. Fulfiller (履约者) - 仅对有真实 GPS 坐标的骑手打点
  348. if (filter === 'all' || filter === 'fulfillers') {
  349. ridersList.value.filter(r => r.lng && r.lat).forEach((r) => {
  350. const borderColor = r.uiStatus === 'busy' ? '#67C23A' : r.uiStatus === 'offline' ? '#909399' : '#F56C6C';
  351. const avatar = r.avatar || 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png';
  352. const content = `
  353. <div style="position:relative; width: 200px; filter: drop-shadow(0 4px 10px rgba(0,0,0,0.2));">
  354. <div style="background:#fff; border-radius:8px; padding: 10px; display: flex; align-items: center; gap: 10px; position: relative;">
  355. <!-- Avatar Ring -->
  356. <div style="width: 44px; height: 44px; border-radius: 50%; border: 3px solid ${borderColor}; padding: 1px; flex-shrink: 0; box-sizing: border-box;">
  357. <img src="${avatar}" style="width: 100%; height: 100%; border-radius: 50%; object-fit: cover;">
  358. </div>
  359. <!-- Info -->
  360. <div style="flex: 1; overflow: hidden;">
  361. <div style="font-weight: bold; font-size: 13px; color: #333; margin-bottom: 2px;">[履约者]${r.name}</div>
  362. <div style="font-size: 12px; color: #999;">${r.maskPhone}</div>
  363. </div>
  364. </div>
  365. <!-- Triangle -->
  366. <div style="width: 0; height: 0; border-left: 6px solid transparent; border-right: 6px solid transparent; border-top: 8px solid #fff; margin: 0 auto; margin-top: -1px;"></div>
  367. </div>
  368. `;
  369. const marker = new AMap.Marker({
  370. position: [r.lng, r.lat],
  371. content: content,
  372. offset: new AMap.Pixel(-100, -75)
  373. });
  374. map.add(marker);
  375. });
  376. }
  377. // 3. Orders (订单)
  378. if (filter === 'all' || filter === 'orders') {
  379. filteredOrders.value.forEach((o) => {
  380. // 默认点标记 + 文本标注
  381. const labelContent = `<div style="border:1px solid #409EFF;background:#fff;color:#409EFF;padding:2px 6px;border-radius:4px;font-size:12px;">${getServiceName(o.service)}</div>`;
  382. const marker = new AMap.Marker({
  383. position: [o.lng, o.lat],
  384. label: {
  385. content: labelContent,
  386. direction: 'top',
  387. offset: new AMap.Pixel(0, -5)
  388. }
  389. });
  390. map.add(marker);
  391. });
  392. }
  393. };
  394. const setMapFilter = (val) => {
  395. activeMapFilter.value = val;
  396. };
  397. const focusMapPoint = (lng, lat) => {
  398. if (!map) return;
  399. map.setCenter([lng, lat]);
  400. map.setZoom(16);
  401. };
  402. watch(activeMapFilter, () => {
  403. refreshMarkers();
  404. });
  405. watch([currentOrderTab, currentRiderTab], () => {
  406. refreshMarkers();
  407. });
  408. onMounted(async () => {
  409. getServiceList()
  410. await getAreaStationList()
  411. if (checkPermi(['order:dispatch:map'])) {
  412. loadAMapScript()
  413. .then(() => {
  414. initMap();
  415. })
  416. .catch((err) => {
  417. console.error('Map loading failed', err);
  418. ElMessage.error('地图加载失败,请检查网络');
  419. });
  420. }
  421. });
  422. // Dispatch Dialog State
  423. const dispatchDialogVisible = ref(false);
  424. const currentDispatchOrder = ref(null);
  425. const currentRider = ref(null);
  426. const openDispatchDialog = (order) => {
  427. const typeName = getServiceName(order?.service);
  428. const isTransport = order?.mode === 1 || order?.mode === '1';
  429. const typeCode = isTransport ? 'transport' : typeName?.includes('喂') || typeName?.includes('遛') ? 'feeding' : 'washing';
  430. const t = order?.subOrderType ?? order?.type;
  431. const transportType =
  432. t === 0 || t === '0' || t === 1 || t === '1'
  433. ? 'round'
  434. : t === 2 || t === '2'
  435. ? 'pick'
  436. : t === 3 || t === '3'
  437. ? 'drop'
  438. : order?.splitType || order?.transportType;
  439. const toAddress = order?.toAddress || '';
  440. const pickAddr = isTransport ? toAddress : '';
  441. const dropAddr = isTransport ? toAddress : '';
  442. const address = isTransport ? '' : toAddress;
  443. const orderObj = {
  444. id: order.id,
  445. typeCode,
  446. transportType,
  447. time: order.serviceTime || order.appointTime || order.createTime,
  448. status: order.status,
  449. address,
  450. pickAddr,
  451. dropAddr,
  452. service: order.service,
  453. riderId: order.riderId || order.fulfiller || null,
  454. riderGender: order.fulfillerGender
  455. };
  456. currentDispatchOrder.value = orderObj;
  457. dispatchDialogVisible.value = true;
  458. };
  459. const handleDispatchSubmit = async (data) => {
  460. if (!currentDispatchOrder.value) return;
  461. try {
  462. const priceFen = Math.round(Number(data.fee || 0) * 100);
  463. await dispatchSubOrder({
  464. orderId: currentDispatchOrder.value.id,
  465. fulfiller: data.riderId,
  466. price: priceFen
  467. });
  468. ElMessage.success('派单成功');
  469. dispatchDialogVisible.value = false;
  470. getOrdersList(); // 重新加载订单列表
  471. } catch (error) {
  472. // 错误由请求拦截器处理
  473. }
  474. };
  475. const filteredOrders = computed(() => {
  476. let result = ordersList.value;
  477. const statusMap = {
  478. 'PendingDispatch': [0],
  479. 'PendingAccept': [1],
  480. 'Processing': [2, 3]
  481. };
  482. const targetStatus = statusMap[currentOrderTab.value];
  483. if (targetStatus) {
  484. result = result.filter((o) => targetStatus.includes(o.status));
  485. }
  486. return result;
  487. });
  488. const filteredRiders = computed(() => {
  489. if (currentRiderTab.value === 'All') return ridersList.value;
  490. const map = {
  491. 'Working': ['busy'],
  492. 'Resting': ['resting'],
  493. 'Disabled': ['disabled']
  494. };
  495. const allowed = map[currentRiderTab.value] || [];
  496. return ridersList.value.filter((r) => allowed.includes(r.status));
  497. });
  498. </script>
  499. <style scoped>
  500. .dispatch-container {
  501. height: calc(100vh - 84px);
  502. display: flex;
  503. flex-direction: column;
  504. background-color: #f0f2f5;
  505. overflow: hidden;
  506. }
  507. /* 1. Top Bar */
  508. .top-filter-bar {
  509. height: 56px;
  510. background: #fff;
  511. border-bottom: 1px solid #e4e7ed;
  512. display: flex;
  513. align-items: center;
  514. justify-content: space-between;
  515. padding: 0 24px;
  516. box-shadow: 0 2px 6px rgba(0, 0, 0, 0.02);
  517. z-index: 20;
  518. }
  519. /* 2. Main Content */
  520. .main-content {
  521. flex: 1;
  522. display: flex;
  523. position: relative;
  524. overflow: hidden;
  525. }
  526. /* Left Map Area */
  527. .map-wrapper {
  528. flex: 1;
  529. position: relative;
  530. background: #fcf9f2;
  531. overflow: hidden;
  532. }
  533. .map-view {
  534. width: 100%;
  535. height: 100%;
  536. }
  537. /* Map Controls */
  538. .map-controls-panel {
  539. position: absolute;
  540. bottom: 24px;
  541. left: 24px;
  542. display: flex;
  543. align-items: center;
  544. gap: 16px;
  545. z-index: 10;
  546. }
  547. .control-group {
  548. background: #fff;
  549. border-radius: 8px;
  550. padding: 6px;
  551. display: flex;
  552. gap: 8px;
  553. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  554. }
  555. .c-btn {
  556. padding: 6px 16px;
  557. border-radius: 6px;
  558. font-size: 13px;
  559. cursor: pointer;
  560. background: #f5f7fa;
  561. color: #606266;
  562. font-weight: 500;
  563. transition: all 0.2s;
  564. }
  565. .c-btn.active {
  566. background: #2d8cf0;
  567. color: #fff;
  568. }
  569. .c-btn.red {
  570. background: #fef0f0;
  571. color: #f56c6c;
  572. border: 1px solid #fde2e2;
  573. }
  574. .c-btn.green {
  575. background: #f0f9eb;
  576. color: #67c23a;
  577. border: 1px solid #e1f3d8;
  578. }
  579. .c-btn.gray {
  580. background: #f0f2f5;
  581. color: #909399;
  582. border: 1px solid #dcdfe6;
  583. cursor: default;
  584. }
  585. .c-btn.blue {
  586. background: #ecf5ff;
  587. color: #409eff;
  588. border: 1px solid #d9ecff;
  589. }
  590. .no-auth-map {
  591. width: 100%;
  592. height: 100%;
  593. display: flex;
  594. align-items: center;
  595. justify-content: center;
  596. background-color: #fcfcfd;
  597. background-image: radial-gradient(#e5e7eb 1px, transparent 1px);
  598. background-size: 20px 20px;
  599. }
  600. .no-auth-content {
  601. text-align: center;
  602. padding: 40px;
  603. background: rgba(255, 255, 255, 0.8);
  604. backdrop-filter: blur(8px);
  605. border-radius: 16px;
  606. border: 1px solid rgba(234, 236, 239, 0.8);
  607. box-shadow: 0 10px 25px rgba(0, 0, 0, 0.03);
  608. max-width: 320px;
  609. }
  610. .lock-icon {
  611. margin-bottom: 24px;
  612. display: flex;
  613. justify-content: center;
  614. opacity: 0.8;
  615. }
  616. .no-auth-title {
  617. margin: 0 0 12px 0;
  618. color: #303133;
  619. font-size: 20px;
  620. font-weight: 600;
  621. }
  622. .no-auth-desc {
  623. margin: 0 0 20px 0;
  624. color: #909399;
  625. font-size: 14px;
  626. line-height: 1.6;
  627. }
  628. .no-auth-tip {
  629. display: inline-block;
  630. padding: 4px 12px;
  631. background: #f4f4f5;
  632. color: #a8abb2;
  633. font-size: 12px;
  634. border-radius: 4px;
  635. font-family: monospace;
  636. }
  637. /* Right Panel */
  638. .right-panel {
  639. width: 440px;
  640. background: #fff;
  641. border-left: 1px solid #e4e7ed;
  642. display: flex;
  643. flex-direction: column;
  644. box-shadow: -4px 0 16px rgba(0, 0, 0, 0.05);
  645. z-index: 30;
  646. }
  647. </style>