index.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  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 Baidu Map Area -->
  22. <div class="map-wrapper">
  23. <div id="baidu-map" class="map-view"></div>
  24. <!-- Bottom Left: Map Controls & Stats -->
  25. <div class="map-controls-panel">
  26. <div class="control-group">
  27. <div class="c-btn" :class="{ active: activeMapFilter === 'all' }" @click="setMapFilter('all')">全部</div>
  28. <div class="c-btn red" :class="{ active: activeMapFilter === 'merchants' }"
  29. @click="setMapFilter('merchants')">商家(16)</div>
  30. <div class="c-btn green" :class="{ active: activeMapFilter === 'fulfillers' }"
  31. @click="setMapFilter('fulfillers')">履约者(12)</div>
  32. <div class="c-btn blue" :class="{ active: activeMapFilter === 'orders' }" @click="setMapFilter('orders')">
  33. 订单(5)</div>
  34. <div class="c-btn gray">灰色表示离线</div>
  35. </div>
  36. </div>
  37. </div>
  38. <!-- Right: Dispatch Control Panel -->
  39. <div class="right-panel">
  40. <OrderListPanel v-model="currentOrderTab" :orders="filteredOrders" :stats="orderStats" @focus="focusMapPoint"
  41. @dispatch="openDispatchDialog" />
  42. <RiderListPanel v-model="currentRiderTab" :riders="filteredRiders" @focus="focusMapPoint"
  43. @view-orders="handleViewRiderOrders" />
  44. </div>
  45. </div>
  46. <RiderOrdersDialog v-model="riderOrdersVisible" :riderInfo="currentRiderInfo" :orders="currentRiderOrders" />
  47. <DispatchDialog v-model="dispatchDialogVisible" :order="currentDispatchOrder" :currentRider="currentRider"
  48. :ridersList="ridersList" @submit="handleDispatchSubmit" />
  49. </div>
  50. </template>
  51. <script setup>
  52. import { ref, computed, reactive, onMounted, watch } from 'vue';
  53. import { ElMessage } from 'element-plus';
  54. import { listServiceOnOrder } from '@/api/service/list/index'
  55. import { listOnStore as listAreaStationOnStore } from '@/api/system/areaStation'
  56. import OrderListPanel from './components/OrderListPanel.vue';
  57. import RiderListPanel from './components/RiderListPanel.vue';
  58. import RiderOrdersDialog from './components/RiderOrdersDialog.vue';
  59. import DispatchDialog from './components/DispatchDialog.vue';
  60. // Mock Data
  61. import dispatchMockData from '@/mock/dispatch.json';
  62. import riderOrdersMockData from '@/mock/RiderOrdersDialog.json';
  63. // --- Data & State ---
  64. const filters = reactive({
  65. orderType: 'all',
  66. station: undefined
  67. });
  68. const currentOrderTab = ref('PendingDispatch');
  69. const currentRiderTab = ref('All');
  70. const activeMapFilter = ref('all');
  71. const ordersList = ref(dispatchMockData.ordersList);
  72. const ridersList = ref(dispatchMockData.ridersList);
  73. const merchantList = ref(dispatchMockData.merchantList);
  74. const orderStats = reactive(dispatchMockData.stats);
  75. const serviceOnOrderList = ref([])
  76. const getServiceList = () => {
  77. listServiceOnOrder().then(res => {
  78. serviceOnOrderList.value = res?.data?.data || res?.data || []
  79. }).catch(() => {
  80. serviceOnOrderList.value = []
  81. })
  82. }
  83. const areaStationList = ref([])
  84. const areaOptions = ref([])
  85. const siteOptions = ref([])
  86. const regionValue = ref([])
  87. const buildTree = (data, parentId) => {
  88. return (data || [])
  89. .filter(item => String(item.parentId) === String(parentId))
  90. .map(item => ({
  91. value: item.id,
  92. label: item.name,
  93. children: buildTree(data, item.id)
  94. }))
  95. }
  96. const handleAreaChange = (value) => {
  97. filters.station = undefined
  98. if (value && value.length > 0) {
  99. const areaId = value[value.length - 1]
  100. siteOptions.value = areaStationList.value
  101. .filter(item => Number(item.type) === 2 && String(item.parentId) === String(areaId))
  102. .map(item => ({ value: item.id, label: item.name }))
  103. } else {
  104. siteOptions.value = []
  105. }
  106. }
  107. const getAreaStationList = async () => {
  108. try {
  109. const res = await listAreaStationOnStore()
  110. const data = res?.data || res
  111. areaStationList.value = Array.isArray(data) ? data : []
  112. const areaData = areaStationList.value.filter(item => Number(item.type) === 0 || Number(item.type) === 1)
  113. areaOptions.value = buildTree(areaData, 0)
  114. siteOptions.value = []
  115. const allStations = areaStationList.value.filter(item => Number(item.type) === 2)
  116. if (allStations.length > 0) {
  117. const randomStation = allStations[Math.floor(Math.random() * allStations.length)]
  118. const areaId = randomStation.parentId
  119. const path = []
  120. let currentId = areaId
  121. while (currentId && String(currentId) !== '0') {
  122. path.unshift(currentId)
  123. const currentArea = areaStationList.value.find(item => String(item.id) === String(currentId))
  124. if (currentArea) {
  125. currentId = currentArea.parentId
  126. } else {
  127. break
  128. }
  129. }
  130. regionValue.value = path
  131. siteOptions.value = areaStationList.value
  132. .filter(item => Number(item.type) === 2 && String(item.parentId) === String(areaId))
  133. .map(item => ({ value: item.id, label: item.name }))
  134. filters.station = randomStation.id
  135. }
  136. } catch {
  137. areaStationList.value = []
  138. areaOptions.value = []
  139. siteOptions.value = []
  140. }
  141. }
  142. // Rider Orders State
  143. const riderOrdersVisible = ref(false);
  144. const currentRiderInfo = ref(null);
  145. const currentRiderOrders = ref([]);
  146. const handleViewRiderOrders = (rider) => {
  147. currentRiderInfo.value = {
  148. name: rider.name,
  149. maskPhone: rider.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'),
  150. station: rider.station + ' - 东城片区',
  151. workType: '全职专送',
  152. categories: ['宠物接送', '上门喂遛', '洗护套餐']
  153. };
  154. currentRiderOrders.value = riderOrdersMockData.orders;
  155. riderOrdersVisible.value = true;
  156. };
  157. // Map Logic
  158. let map = null;
  159. const ak = 'E4805d16520de693a3fe707cdc962045'; // Public Key
  160. const loadBMapScript = () => {
  161. return new Promise((resolve, reject) => {
  162. if (window.BMap) {
  163. resolve(window.BMap);
  164. return;
  165. }
  166. window.initBMapCallback = () => resolve(window.BMap);
  167. const script = document.createElement('script');
  168. script.src = `https://api.map.baidu.com/api?v=3.0&ak=${ak}&callback=initBMapCallback`;
  169. script.onerror = reject;
  170. document.head.appendChild(script);
  171. });
  172. };
  173. const initMap = () => {
  174. if (!window.BMap) return;
  175. map = new BMap.Map('baidu-map');
  176. const point = new BMap.Point(116.4551, 39.9255); // Chaoyang center
  177. map.centerAndZoom(point, 14);
  178. map.enableScrollWheelZoom(true);
  179. map.setMapStyleV2({
  180. styleId: '3d71dc5a4ce6228d3e9680188e982438'
  181. });
  182. refreshMarkers();
  183. };
  184. const refreshMarkers = () => {
  185. if (!map) return;
  186. map.clearOverlays();
  187. const filter = activeMapFilter.value;
  188. // 1. Merchants
  189. if (filter === 'all' || filter === 'merchants') {
  190. merchantList.value.forEach((m) => {
  191. const pt = new BMap.Point(m.lng, m.lat);
  192. const iconImg = m.icon || 'https://cube.elemecdn.com/e/fd/0fc7d20532fdaf769a25683617711png.png';
  193. const html = `
  194. <div style="position:absolute; transform:translate(-50%, -100%); width: 220px; filter: drop-shadow(0 4px 10px rgba(0,0,0,0.2));">
  195. <div style="background:#fff; border-radius:8px; padding: 12px; display: flex; align-items: center; gap: 10px; position: relative;">
  196. <!-- Close Icon -->
  197. <div style="position: absolute; top: 4px; right: 8px; color: #999; font-size: 16px; cursor: pointer;">×</div>
  198. <!-- Icon Ring -->
  199. <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;">
  200. <img src="${iconImg}" style="width: 100%; height: 100%; border-radius: 50%; object-fit: cover;">
  201. </div>
  202. <!-- Info -->
  203. <div style="flex: 1; overflow: hidden;">
  204. <div style="font-weight: bold; font-size: 14px; color: #333; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${m.name}</div>
  205. <div style="font-size: 12px; color: #666;">今日 <span style="color: #F56C6C; font-weight: bold; font-size: 14px;">${m.orders}</span> 单</div>
  206. </div>
  207. </div>
  208. <!-- Triangle -->
  209. <div style="width: 0; height: 0; border-left: 8px solid transparent; border-right: 8px solid transparent; border-top: 10px solid #fff; margin: 0 auto;"></div>
  210. </div>
  211. `;
  212. const label = new BMap.Label(html, { position: pt, offset: new BMap.Size(0, 0) });
  213. label.setStyle({ border: 'none', background: 'transparent' });
  214. map.addOverlay(label);
  215. });
  216. }
  217. // 2. Fulfiller
  218. if (filter === 'all' || filter === 'fulfillers') {
  219. ridersList.value.forEach((r) => {
  220. const pt = new BMap.Point(r.lng, r.lat);
  221. const borderColor = r.status === 'online' ? '#67C23A' : r.status === 'busy' ? '#409EFF' : '#DCDFE6';
  222. const pendingText =
  223. r.pendingCount > 0 ? `<span style="color:#67C23A;font-weight:bold;">挂${r.pendingCount}单</span>` : `<span style="color:#999;">挂0单</span>`;
  224. const avatar = r.avatar || 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png';
  225. const html = `
  226. <div style="position:absolute; transform:translate(-50%, -100%); width: 200px; filter: drop-shadow(0 4px 10px rgba(0,0,0,0.2));">
  227. <div style="background:#fff; border-radius:8px; padding: 10px; display: flex; align-items: center; gap: 10px; position: relative;">
  228. <!-- Close Icon -->
  229. <div style="position: absolute; top: 2px; right: 6px; color: #999; font-size: 14px; cursor: pointer;">×</div>
  230. <!-- Avatar Ring -->
  231. <div style="width: 44px; height: 44px; border-radius: 50%; border: 3px solid ${borderColor}; padding: 1px; flex-shrink: 0; box-sizing: border-box;">
  232. <img src="${avatar}" style="width: 100%; height: 100%; border-radius: 50%; object-fit: cover;">
  233. </div>
  234. <!-- Info -->
  235. <div style="flex: 1; overflow: hidden;">
  236. <div style="font-weight: bold; font-size: 13px; color: #333; margin-bottom: 2px;">[履约者]${r.name}</div>
  237. <div style="font-size: 12px; color: #999;">${pendingText}</div>
  238. </div>
  239. </div>
  240. <!-- Triangle -->
  241. <div style="width: 0; height: 0; border-left: 6px solid transparent; border-right: 6px solid transparent; border-top: 8px solid #fff; margin: 0 auto;"></div>
  242. </div>
  243. `;
  244. const label = new BMap.Label(html, { position: pt, offset: new BMap.Size(0, 0) });
  245. label.setStyle({ border: 'none', background: 'transparent' });
  246. map.addOverlay(label);
  247. });
  248. }
  249. // 3. Orders (Blue/Purple)
  250. if (filter === 'all' || filter === 'orders') {
  251. filteredOrders.value.forEach((o) => {
  252. const pt = new BMap.Point(o.lng, o.lat);
  253. const marker = new BMap.Marker(pt);
  254. const labelContent = `<div style="border:1px solid #409EFF;background:#fff;color:#409EFF;padding:2px 6px;border-radius:4px;font-size:12px;">${o.type}</div>`;
  255. const label = new BMap.Label(labelContent, { position: pt, offset: new BMap.Size(-20, -35) });
  256. label.setStyle({ border: 'none', background: 'transparent' });
  257. map.addOverlay(marker);
  258. map.addOverlay(label);
  259. });
  260. }
  261. };
  262. const setMapFilter = (val) => {
  263. activeMapFilter.value = val;
  264. };
  265. const focusMapPoint = (lng, lat) => {
  266. if (!map) return;
  267. const pt = new BMap.Point(lng, lat);
  268. map.panTo(pt);
  269. map.setZoom(16);
  270. };
  271. watch(activeMapFilter, () => {
  272. refreshMarkers();
  273. });
  274. watch([currentOrderTab, currentRiderTab], () => {
  275. refreshMarkers();
  276. });
  277. onMounted(() => {
  278. getServiceList()
  279. getAreaStationList()
  280. loadBMapScript()
  281. .then(() => {
  282. initMap();
  283. })
  284. .catch((err) => {
  285. console.error('Map loading failed', err);
  286. ElMessage.error('地图加载失败,请检查网络');
  287. });
  288. });
  289. // Dispatch Dialog State
  290. const dispatchDialogVisible = ref(false);
  291. const currentDispatchOrder = ref(null);
  292. const currentRider = ref(null);
  293. const openDispatchDialog = (order) => {
  294. currentDispatchOrder.value = order;
  295. if (order.riderId) {
  296. currentRider.value = ridersList.value.find((r) => r.id === order.riderId) || null;
  297. } else {
  298. currentRider.value = null;
  299. }
  300. dispatchDialogVisible.value = true;
  301. };
  302. const handleDispatchSubmit = (data) => {
  303. dispatchDialogVisible.value = false;
  304. ElMessage.success('派单成功');
  305. if (currentDispatchOrder.value && currentDispatchOrder.value.status === 'pending_dispatch') {
  306. const idx = ordersList.value.findIndex((o) => o.id === currentDispatchOrder.value.id);
  307. if (idx !== -1) ordersList.value[idx].status = 'pending_accept';
  308. }
  309. };
  310. const filteredOrders = computed(() => {
  311. let result = ordersList.value;
  312. const statusMap = {
  313. 'PendingDispatch': 'pending_dispatch',
  314. 'PendingAccept': 'pending_accept',
  315. 'Processing': 'processing'
  316. };
  317. const targetStatus = statusMap[currentOrderTab.value];
  318. if (targetStatus) {
  319. result = result.filter((o) => o.status === targetStatus);
  320. }
  321. if (filters.orderType !== 'all') {
  322. result = result.filter((o) => String(o.serviceId || o.service) === String(filters.orderType));
  323. }
  324. return result;
  325. });
  326. const filteredRiders = computed(() => {
  327. if (currentRiderTab.value === 'All') return ridersList.value;
  328. const map = {
  329. 'Working': ['online', 'busy'],
  330. 'Resting': ['offline'],
  331. 'Disabled': ['disabled']
  332. };
  333. const allowed = map[currentRiderTab.value] || [];
  334. return ridersList.value.filter((r) => allowed.includes(r.status));
  335. });
  336. </script>
  337. <style scoped>
  338. .dispatch-container {
  339. height: calc(100vh - 84px);
  340. display: flex;
  341. flex-direction: column;
  342. background-color: #f0f2f5;
  343. overflow: hidden;
  344. }
  345. /* 1. Top Bar */
  346. .top-filter-bar {
  347. height: 56px;
  348. background: #fff;
  349. border-bottom: 1px solid #e4e7ed;
  350. display: flex;
  351. align-items: center;
  352. justify-content: space-between;
  353. padding: 0 24px;
  354. box-shadow: 0 2px 6px rgba(0, 0, 0, 0.02);
  355. z-index: 20;
  356. }
  357. /* 2. Main Content */
  358. .main-content {
  359. flex: 1;
  360. display: flex;
  361. position: relative;
  362. overflow: hidden;
  363. }
  364. /* Left Map Area */
  365. .map-wrapper {
  366. flex: 1;
  367. position: relative;
  368. background: #fcf9f2;
  369. overflow: hidden;
  370. }
  371. .map-view {
  372. width: 100%;
  373. height: 100%;
  374. }
  375. /* Map Controls */
  376. .map-controls-panel {
  377. position: absolute;
  378. bottom: 24px;
  379. left: 24px;
  380. display: flex;
  381. align-items: center;
  382. gap: 16px;
  383. z-index: 10;
  384. }
  385. .control-group {
  386. background: #fff;
  387. border-radius: 8px;
  388. padding: 6px;
  389. display: flex;
  390. gap: 8px;
  391. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  392. }
  393. .c-btn {
  394. padding: 6px 16px;
  395. border-radius: 6px;
  396. font-size: 13px;
  397. cursor: pointer;
  398. background: #f5f7fa;
  399. color: #606266;
  400. font-weight: 500;
  401. transition: all 0.2s;
  402. }
  403. .c-btn.active {
  404. background: #2d8cf0;
  405. color: #fff;
  406. }
  407. .c-btn.red {
  408. background: #fef0f0;
  409. color: #f56c6c;
  410. border: 1px solid #fde2e2;
  411. }
  412. .c-btn.green {
  413. background: #f0f9eb;
  414. color: #67c23a;
  415. border: 1px solid #e1f3d8;
  416. }
  417. .c-btn.gray {
  418. background: #f0f2f5;
  419. color: #909399;
  420. border: 1px solid #dcdfe6;
  421. cursor: default;
  422. }
  423. .c-btn.blue {
  424. background: #ecf5ff;
  425. color: #409eff;
  426. border: 1px solid #d9ecff;
  427. }
  428. /* Right Panel */
  429. .right-panel {
  430. width: 440px;
  431. background: #fff;
  432. border-left: 1px solid #e4e7ed;
  433. display: flex;
  434. flex-direction: column;
  435. box-shadow: -4px 0 16px rgba(0, 0, 0, 0.05);
  436. z-index: 30;
  437. }
  438. </style>