| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505 |
- <template>
- <div class="dispatch-container">
- <!-- Top Filter Bar -->
- <div class="top-filter-bar glass-panel-sm">
- <div class="filter-left">
- <el-radio-group v-model="filters.orderType" size="default" fill="#2d8cf0">
- <el-radio-button label="all">全部</el-radio-button>
- <el-radio-button v-for="item in serviceOnOrderList" :key="item.id" :label="item.id">{{ item.name
- }}</el-radio-button>
- </el-radio-group>
- </div>
- <div class="filter-right">
- <el-cascader v-model="regionValue" :options="areaOptions" placeholder="省市区域" clearable
- style="width: 170px; margin-right: 10px" @change="handleAreaChange" />
- <el-select v-model="filters.station" placeholder="站点" clearable style="width: 170px">
- <el-option v-for="item in siteOptions" :key="item.value" :label="item.label" :value="item.value" />
- </el-select>
- </div>
- </div>
- <div class="main-content">
- <!-- Left: Real Baidu Map Area -->
- <div class="map-wrapper">
- <div id="baidu-map" class="map-view"></div>
- <!-- Bottom Left: Map Controls & Stats -->
- <div class="map-controls-panel">
- <div class="control-group">
- <div class="c-btn" :class="{ active: activeMapFilter === 'all' }" @click="setMapFilter('all')">全部</div>
- <div class="c-btn red" :class="{ active: activeMapFilter === 'merchants' }"
- @click="setMapFilter('merchants')">商家(16)</div>
- <div class="c-btn green" :class="{ active: activeMapFilter === 'fulfillers' }"
- @click="setMapFilter('fulfillers')">履约者(12)</div>
- <div class="c-btn blue" :class="{ active: activeMapFilter === 'orders' }" @click="setMapFilter('orders')">
- 订单(5)</div>
- <div class="c-btn gray">灰色表示离线</div>
- </div>
- </div>
- </div>
- <!-- Right: Dispatch Control Panel -->
- <div class="right-panel">
- <OrderListPanel v-model="currentOrderTab" :orders="filteredOrders" :stats="orderStats" @focus="focusMapPoint"
- @dispatch="openDispatchDialog" />
- <RiderListPanel v-model="currentRiderTab" :riders="filteredRiders" @focus="focusMapPoint"
- @view-orders="handleViewRiderOrders" />
- </div>
- </div>
- <RiderOrdersDialog v-model="riderOrdersVisible" :riderInfo="currentRiderInfo" :orders="currentRiderOrders" />
- <DispatchDialog v-model="dispatchDialogVisible" :order="currentDispatchOrder" :currentRider="currentRider"
- :ridersList="ridersList" @submit="handleDispatchSubmit" />
- </div>
- </template>
- <script setup>
- import { ref, computed, reactive, onMounted, watch } from 'vue';
- import { ElMessage } from 'element-plus';
- import { listServiceOnOrder } from '@/api/service/list/index'
- import { listOnStore as listAreaStationOnStore } from '@/api/system/areaStation'
- import OrderListPanel from './components/OrderListPanel.vue';
- import RiderListPanel from './components/RiderListPanel.vue';
- import RiderOrdersDialog from './components/RiderOrdersDialog.vue';
- import DispatchDialog from './components/DispatchDialog.vue';
- // Mock Data
- import dispatchMockData from '@/mock/dispatch.json';
- import riderOrdersMockData from '@/mock/RiderOrdersDialog.json';
- // --- Data & State ---
- const filters = reactive({
- orderType: 'all',
- station: undefined
- });
- const currentOrderTab = ref('PendingDispatch');
- const currentRiderTab = ref('All');
- const activeMapFilter = ref('all');
- const ordersList = ref(dispatchMockData.ordersList);
- const ridersList = ref(dispatchMockData.ridersList);
- const merchantList = ref(dispatchMockData.merchantList);
- const orderStats = reactive(dispatchMockData.stats);
- const serviceOnOrderList = ref([])
- const getServiceList = () => {
- listServiceOnOrder().then(res => {
- serviceOnOrderList.value = res?.data?.data || res?.data || []
- }).catch(() => {
- serviceOnOrderList.value = []
- })
- }
- const areaStationList = ref([])
- const areaOptions = ref([])
- const siteOptions = ref([])
- const regionValue = ref([])
- const buildTree = (data, parentId) => {
- return (data || [])
- .filter(item => String(item.parentId) === String(parentId))
- .map(item => ({
- value: item.id,
- label: item.name,
- children: buildTree(data, item.id)
- }))
- }
- const handleAreaChange = (value) => {
- filters.station = undefined
- if (value && value.length > 0) {
- const areaId = value[value.length - 1]
- siteOptions.value = areaStationList.value
- .filter(item => Number(item.type) === 2 && String(item.parentId) === String(areaId))
- .map(item => ({ value: item.id, label: item.name }))
- } else {
- siteOptions.value = []
- }
- }
- const getAreaStationList = async () => {
- try {
- const res = await listAreaStationOnStore()
- const data = res?.data || res
- areaStationList.value = Array.isArray(data) ? data : []
- const areaData = areaStationList.value.filter(item => Number(item.type) === 0 || Number(item.type) === 1)
- areaOptions.value = buildTree(areaData, 0)
- siteOptions.value = []
- const allStations = areaStationList.value.filter(item => Number(item.type) === 2)
- if (allStations.length > 0) {
- const randomStation = allStations[Math.floor(Math.random() * allStations.length)]
- const areaId = randomStation.parentId
- const path = []
- let currentId = areaId
- while (currentId && String(currentId) !== '0') {
- path.unshift(currentId)
- const currentArea = areaStationList.value.find(item => String(item.id) === String(currentId))
- if (currentArea) {
- currentId = currentArea.parentId
- } else {
- break
- }
- }
- regionValue.value = path
- siteOptions.value = areaStationList.value
- .filter(item => Number(item.type) === 2 && String(item.parentId) === String(areaId))
- .map(item => ({ value: item.id, label: item.name }))
- filters.station = randomStation.id
- }
- } catch {
- areaStationList.value = []
- areaOptions.value = []
- siteOptions.value = []
- }
- }
- // Rider Orders State
- const riderOrdersVisible = ref(false);
- const currentRiderInfo = ref(null);
- const currentRiderOrders = ref([]);
- const handleViewRiderOrders = (rider) => {
- currentRiderInfo.value = {
- name: rider.name,
- maskPhone: rider.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'),
- station: rider.station + ' - 东城片区',
- workType: '全职专送',
- categories: ['宠物接送', '上门喂遛', '洗护套餐']
- };
- currentRiderOrders.value = riderOrdersMockData.orders;
- riderOrdersVisible.value = true;
- };
- // Map Logic
- let map = null;
- const ak = 'E4805d16520de693a3fe707cdc962045'; // Public Key
- const loadBMapScript = () => {
- return new Promise((resolve, reject) => {
- if (window.BMap) {
- resolve(window.BMap);
- return;
- }
- window.initBMapCallback = () => resolve(window.BMap);
- const script = document.createElement('script');
- script.src = `https://api.map.baidu.com/api?v=3.0&ak=${ak}&callback=initBMapCallback`;
- script.onerror = reject;
- document.head.appendChild(script);
- });
- };
- const initMap = () => {
- if (!window.BMap) return;
- map = new BMap.Map('baidu-map');
- const point = new BMap.Point(116.4551, 39.9255); // Chaoyang center
- map.centerAndZoom(point, 14);
- map.enableScrollWheelZoom(true);
- map.setMapStyleV2({
- styleId: '3d71dc5a4ce6228d3e9680188e982438'
- });
- refreshMarkers();
- };
- const refreshMarkers = () => {
- if (!map) return;
- map.clearOverlays();
- const filter = activeMapFilter.value;
- // 1. Merchants
- if (filter === 'all' || filter === 'merchants') {
- merchantList.value.forEach((m) => {
- const pt = new BMap.Point(m.lng, m.lat);
- const iconImg = m.icon || 'https://cube.elemecdn.com/e/fd/0fc7d20532fdaf769a25683617711png.png';
- const html = `
- <div style="position:absolute; transform:translate(-50%, -100%); width: 220px; filter: drop-shadow(0 4px 10px rgba(0,0,0,0.2));">
- <div style="background:#fff; border-radius:8px; padding: 12px; display: flex; align-items: center; gap: 10px; position: relative;">
- <!-- Close Icon -->
- <div style="position: absolute; top: 4px; right: 8px; color: #999; font-size: 16px; cursor: pointer;">×</div>
-
- <!-- Icon Ring -->
- <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;">
- <img src="${iconImg}" style="width: 100%; height: 100%; border-radius: 50%; object-fit: cover;">
- </div>
-
- <!-- Info -->
- <div style="flex: 1; overflow: hidden;">
- <div style="font-weight: bold; font-size: 14px; color: #333; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${m.name}</div>
- <div style="font-size: 12px; color: #666;">今日 <span style="color: #F56C6C; font-weight: bold; font-size: 14px;">${m.orders}</span> 单</div>
- </div>
- </div>
- <!-- Triangle -->
- <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>
- </div>
- `;
- const label = new BMap.Label(html, { position: pt, offset: new BMap.Size(0, 0) });
- label.setStyle({ border: 'none', background: 'transparent' });
- map.addOverlay(label);
- });
- }
- // 2. Fulfiller
- if (filter === 'all' || filter === 'fulfillers') {
- ridersList.value.forEach((r) => {
- const pt = new BMap.Point(r.lng, r.lat);
- const borderColor = r.status === 'online' ? '#67C23A' : r.status === 'busy' ? '#409EFF' : '#DCDFE6';
- const pendingText =
- r.pendingCount > 0 ? `<span style="color:#67C23A;font-weight:bold;">挂${r.pendingCount}单</span>` : `<span style="color:#999;">挂0单</span>`;
- const avatar = r.avatar || 'https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png';
- const html = `
- <div style="position:absolute; transform:translate(-50%, -100%); width: 200px; filter: drop-shadow(0 4px 10px rgba(0,0,0,0.2));">
- <div style="background:#fff; border-radius:8px; padding: 10px; display: flex; align-items: center; gap: 10px; position: relative;">
- <!-- Close Icon -->
- <div style="position: absolute; top: 2px; right: 6px; color: #999; font-size: 14px; cursor: pointer;">×</div>
-
- <!-- Avatar Ring -->
- <div style="width: 44px; height: 44px; border-radius: 50%; border: 3px solid ${borderColor}; padding: 1px; flex-shrink: 0; box-sizing: border-box;">
- <img src="${avatar}" style="width: 100%; height: 100%; border-radius: 50%; object-fit: cover;">
- </div>
-
- <!-- Info -->
- <div style="flex: 1; overflow: hidden;">
- <div style="font-weight: bold; font-size: 13px; color: #333; margin-bottom: 2px;">[履约者]${r.name}</div>
- <div style="font-size: 12px; color: #999;">${pendingText}</div>
- </div>
- </div>
- <!-- Triangle -->
- <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>
- </div>
- `;
- const label = new BMap.Label(html, { position: pt, offset: new BMap.Size(0, 0) });
- label.setStyle({ border: 'none', background: 'transparent' });
- map.addOverlay(label);
- });
- }
- // 3. Orders (Blue/Purple)
- if (filter === 'all' || filter === 'orders') {
- filteredOrders.value.forEach((o) => {
- const pt = new BMap.Point(o.lng, o.lat);
- const marker = new BMap.Marker(pt);
- const labelContent = `<div style="border:1px solid #409EFF;background:#fff;color:#409EFF;padding:2px 6px;border-radius:4px;font-size:12px;">${o.type}</div>`;
- const label = new BMap.Label(labelContent, { position: pt, offset: new BMap.Size(-20, -35) });
- label.setStyle({ border: 'none', background: 'transparent' });
- map.addOverlay(marker);
- map.addOverlay(label);
- });
- }
- };
- const setMapFilter = (val) => {
- activeMapFilter.value = val;
- };
- const focusMapPoint = (lng, lat) => {
- if (!map) return;
- const pt = new BMap.Point(lng, lat);
- map.panTo(pt);
- map.setZoom(16);
- };
- watch(activeMapFilter, () => {
- refreshMarkers();
- });
- watch([currentOrderTab, currentRiderTab], () => {
- refreshMarkers();
- });
- onMounted(() => {
- getServiceList()
- getAreaStationList()
- loadBMapScript()
- .then(() => {
- initMap();
- })
- .catch((err) => {
- console.error('Map loading failed', err);
- ElMessage.error('地图加载失败,请检查网络');
- });
- });
- // Dispatch Dialog State
- const dispatchDialogVisible = ref(false);
- const currentDispatchOrder = ref(null);
- const currentRider = ref(null);
- const openDispatchDialog = (order) => {
- currentDispatchOrder.value = order;
- if (order.riderId) {
- currentRider.value = ridersList.value.find((r) => r.id === order.riderId) || null;
- } else {
- currentRider.value = null;
- }
- dispatchDialogVisible.value = true;
- };
- const handleDispatchSubmit = (data) => {
- dispatchDialogVisible.value = false;
- ElMessage.success('派单成功');
- if (currentDispatchOrder.value && currentDispatchOrder.value.status === 'pending_dispatch') {
- const idx = ordersList.value.findIndex((o) => o.id === currentDispatchOrder.value.id);
- if (idx !== -1) ordersList.value[idx].status = 'pending_accept';
- }
- };
- const filteredOrders = computed(() => {
- let result = ordersList.value;
- const statusMap = {
- 'PendingDispatch': 'pending_dispatch',
- 'PendingAccept': 'pending_accept',
- 'Processing': 'processing'
- };
- const targetStatus = statusMap[currentOrderTab.value];
- if (targetStatus) {
- result = result.filter((o) => o.status === targetStatus);
- }
- if (filters.orderType !== 'all') {
- result = result.filter((o) => String(o.serviceId || o.service) === String(filters.orderType));
- }
- return result;
- });
- const filteredRiders = computed(() => {
- if (currentRiderTab.value === 'All') return ridersList.value;
- const map = {
- 'Working': ['online', 'busy'],
- 'Resting': ['offline'],
- 'Disabled': ['disabled']
- };
- const allowed = map[currentRiderTab.value] || [];
- return ridersList.value.filter((r) => allowed.includes(r.status));
- });
- </script>
- <style scoped>
- .dispatch-container {
- height: calc(100vh - 84px);
- display: flex;
- flex-direction: column;
- background-color: #f0f2f5;
- overflow: hidden;
- }
- /* 1. Top Bar */
- .top-filter-bar {
- height: 56px;
- background: #fff;
- border-bottom: 1px solid #e4e7ed;
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 0 24px;
- box-shadow: 0 2px 6px rgba(0, 0, 0, 0.02);
- z-index: 20;
- }
- /* 2. Main Content */
- .main-content {
- flex: 1;
- display: flex;
- position: relative;
- overflow: hidden;
- }
- /* Left Map Area */
- .map-wrapper {
- flex: 1;
- position: relative;
- background: #fcf9f2;
- overflow: hidden;
- }
- .map-view {
- width: 100%;
- height: 100%;
- }
- /* Map Controls */
- .map-controls-panel {
- position: absolute;
- bottom: 24px;
- left: 24px;
- display: flex;
- align-items: center;
- gap: 16px;
- z-index: 10;
- }
- .control-group {
- background: #fff;
- border-radius: 8px;
- padding: 6px;
- display: flex;
- gap: 8px;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
- }
- .c-btn {
- padding: 6px 16px;
- border-radius: 6px;
- font-size: 13px;
- cursor: pointer;
- background: #f5f7fa;
- color: #606266;
- font-weight: 500;
- transition: all 0.2s;
- }
- .c-btn.active {
- background: #2d8cf0;
- color: #fff;
- }
- .c-btn.red {
- background: #fef0f0;
- color: #f56c6c;
- border: 1px solid #fde2e2;
- }
- .c-btn.green {
- background: #f0f9eb;
- color: #67c23a;
- border: 1px solid #e1f3d8;
- }
- .c-btn.gray {
- background: #f0f2f5;
- color: #909399;
- border: 1px solid #dcdfe6;
- cursor: default;
- }
- .c-btn.blue {
- background: #ecf5ff;
- color: #409eff;
- border: 1px solid #d9ecff;
- }
- /* Right Panel */
- .right-panel {
- width: 440px;
- background: #fff;
- border-left: 1px solid #e4e7ed;
- display: flex;
- flex-direction: column;
- box-shadow: -4px 0 16px rgba(0, 0, 0, 0.05);
- z-index: 30;
- }
- </style>
|