index.vue 24 KB

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