| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783 |
- <template>
- <el-dialog
- :visible.sync="dialogVisible"
- width="1100px"
- custom-class="dispatch-order-dialog"
- :close-on-click-modal="false"
- :show-close="false"
- append-to-body
- destroy-on-close
- @opened="onDialogOpened"
- @closed="onDialogClosed"
- >
- <div class="dispatch-order-panel">
- <div class="panel-header">
- <span class="panel-title">订单分配</span>
- <span v-if="currentOrder" class="panel-subtitle">
- 订单号:{{ currentOrder.orderNo }}
- <template v-if="currentOrder.cAddress"> · {{ currentOrder.cAddress }}</template>
- </span>
- <i class="el-icon-close panel-close" @click="handleClose" />
- </div>
- <div class="filter-bar">
- <el-select v-model="filterForm.region" placeholder="地区" clearable class="filter-item">
- <el-option
- v-for="item in regionOptions"
- :key="item.value"
- :label="item.cityName"
- :value="item.cityCode"
- />
- </el-select>
- <el-select placeholder="服务类目" v-model="filterForm.category" clearable class="filter-item">
- <el-option v-for="item in serveTypes" :label="item.name" :value="item.id"></el-option>
- </el-select>
- <!-- <el-select v-model="filterForm.category" placeholder="服务类目" clearable class="filter-item">
- <el-option label="按摩" value="按摩" />
- <el-option label="陪玩" value="陪玩" />
- </el-select> -->
- <el-input
- v-model="filterForm.keyword"
- placeholder="搜索技师名称或姓名"
- prefix-icon="el-icon-search"
- clearable
- class="filter-search"
- @keyup.enter.native="handleSearch"
- />
- <div class="filter-actions">
- <el-button type="primary" @click="handleSearch">搜索</el-button>
- <el-button @click="handleClear">清空</el-button>
- </div>
- </div>
- <div class="panel-body">
- <div class="technician-list">
- <div
- v-for="item in technicians"
- :key="item.id"
- class="technician-card"
- :class="{ 'is-active': selectedId === item.id }"
- @click="selectTechnician(item)"
- >
- <span class="technician-radio" :class="{ 'is-checked': selectedId === item.merchantId }">
- <span class="technician-radio__inner" />
- </span>
- <img :src="item.merchantAvatar" class="technician-avatar" alt="" />
- <div class="technician-info">
- <div class="technician-name-row">
- <span class="technician-name">{{ item.merchantNickName }}</span>
- <span class="status-tag" :class="`status-tag--${item.status}`">{{ statusLabel(item.postStateName) }}</span>
- <span v-if="item.distance != null" class="technician-distance">距用户 {{ formatDistance(item.distanceShow) }}</span>
- </div>
- <div class="technician-address">{{ item.merchantAddress }}</div>
- </div>
- </div>
- <div v-if="!technicians.length" class="empty-tip">暂无符合条件的技师</div>
- </div>
- <div class="map-wrap">
- <div :id="mapId" class="map-container" />
- </div>
- </div>
- <div class="panel-footer">
- <el-button @click="handleClose">取消</el-button>
- <el-button type="primary" @click="handleConfirm">确认派单</el-button>
- </div>
- </div>
- </el-dialog>
- </template>
- <script>
- import AMapLoader from '@amap/amap-jsapi-loader'
- import { calcDistance, formatDistance } from '@/utils/geo'
- import userDestIcon from '@/assets/images/map/user.png'
- import { getDispatchMerchantList,getCityList,serviceCategory,dispatchOrder} from '@/api/order/serviceOrder'
- const AMAP_KEY = '41bc0f647d4432045d472f554bf03d75'
- const AMAP_SECURITY = '80ba66cc0bba5b07de6a275d7aa02a39'
- const STATUS_MAP = {
- online: '在线',
- onway: '在路途中',
- rest: '休息中'
- }
- const DEFAULT_FILTER = {
- region: 'city',
- category: '',
- keyword: ''
- }
- let mapInstanceSeed = 0
- /** 示例技师数据 */
- const MOCK_TECHNICIANS = [
- { id: 1, nickname: 'NaNny', name: '娜娜', category: '按摩', region: 'city', status: 'rest', lng: 116.397428, lat: 39.90923, address: '北京市东城区王府井大街88号附近', avatar: require('@/assets/images/map/user.png') },
- { id: 2, nickname: '小艾', name: '艾琳', category: '按摩', region: 'city', status: 'onway', lng: 116.451328, lat: 39.908765, address: '北京市朝阳区建国门外大街1号附近', avatar: require('@/assets/images/map/user.png') },
- { id: 3, nickname: '阿杰', name: '杰哥', category: '陪玩', region: 'city', status: 'online', lng: 116.280892, lat: 39.921234, address: '北京市海淀区中关村大街27号附近', avatar: require('@/assets/images/map/user.png') },
- { id: 4, nickname: '小雨', name: '雨晴', category: '陪玩', region: 'city', status: 'online', lng: 116.385621, lat: 39.954812, address: '北京市西城区西单北大街120号附近', avatar: require('@/assets/images/map/user.png') },
- { id: 5, nickname: '大明', name: '明明', category: '按摩', region: 'city', status: 'rest', lng: 116.420981, lat: 39.893456, address: '北京市丰台区南三环西路16号附近', avatar: require('@/assets/images/map/user.png') },
- { id: 6, nickname: '小美', name: '美美', category: '按摩', region: 'city', status: 'online', lng: 116.362481, lat: 39.915612, address: '北京市东城区东直门内大街8号附近', avatar: require('@/assets/images/map/user.png') },
- { id: 7, nickname: '阿强', name: '强子', category: '陪玩', region: 'city', status: 'onway', lng: 116.432156, lat: 39.928341, address: '北京市朝阳区三里屯路19号附近', avatar: require('@/assets/images/map/user.png') },
- { id: 8, nickname: '莉莉', name: '丽丽', category: '按摩', region: 'city', status: 'rest', lng: 116.318765, lat: 39.876543, address: '北京市丰台区方庄路15号附近', avatar: require('@/assets/images/map/user.png') },
- { id: 9, nickname: '小军', name: '军哥', category: '陪玩', region: 'city', status: 'online', lng: 116.468912, lat: 39.941234, address: '北京市朝阳区望京街10号附近', avatar: require('@/assets/images/map/user.png') },
- { id: 10, nickname: '小芳', name: '芳芳', category: '按摩', region: 'city', status: 'onway', lng: 116.351234, lat: 39.902345, address: '北京市西城区金融大街35号附近', avatar: require('@/assets/images/map/user.png') }
- ]
- export default {
- name: 'DispatchOrderDialog',
- data() {
- return {
- dialogVisible: false,
- mapId: `dispatch-order-map-${++mapInstanceSeed}`,
- currentOrder: null,
- orderDest: null,
- filterForm: { ...DEFAULT_FILTER },
- searchParams: { ...DEFAULT_FILTER },
- regionOptions: [{ label: '城市运营中心', value: 'city' }],
- technicians:[],// MOCK_TECHNICIANS,
- selectedId: null,
- orderStatus: 'pending',
- AMap: null,
- map: null,
- markerInstances: [],
- destMarker: null,
- serveTypes: []
- }
- },
- computed: {
- sortedTechnicians() {
- const { region, category, keyword } = this.searchParams
- const kw = keyword.trim().toLowerCase()
- let list = this.technicians.filter((item) => {
- if (region && item.region !== region) return false
- if (category && item.category !== category) return false
- if (kw && !item.nickname.toLowerCase().includes(kw) && !item.name.toLowerCase().includes(kw)) {
- return false
- }
- return true
- })
- if (this.orderDest) {
- const { lng, lat } = this.orderDest
- list = list
- .map((item) => ({
- ...item,
- distance: calcDistance(lng, lat, item.lng, item.lat)
- }))
- .sort((a, b) => a.distance - b.distance)
- }
- return list
- },
- mapMarkers() {
- return this.sortedTechnicians.map((item) => ({
- ...item,
- active: item.id === this.selectedId
- }))
- }
- },
- watch: {
- mapMarkers: {
- deep: true,
- handler() {
- this.renderMarkers()
- }
- }
- },
- beforeDestroy() {
- this.destroyMap()
- },
- methods: {
- formatDistance,
- /**
- * 打开派单弹窗
- * @param {Object} order - 订单信息,需含 arrivalLongitude / arrivalLatitude(用户目的地经纬度)
- */
- open(order) {
- this.serviceCategoryFun()
- this.getCityListFun()
- console.log(order,'order9999')
- console.log(order.region,'order9999')
- if (!order) return
- const lng = Number(order.merchantLongitude)
- const lat = Number(order.merchantLatitude)
- if (!Number.isFinite(lng) || !Number.isFinite(lat)) {
- this.$message.warning('该订单缺少用户目的地经纬度,无法分配')
- return
- }
- this.currentOrder = order
- this.orderDest = { lng, lat }
- this.orderStatus = order.dispatchStatus || 'pending'
- this.filterForm = {
- orderId: order.id,
- region: order.region,
- category: order.categoryId,
- keyword: ''
- }
- console.log(this.filterForm,'this.filterForm')
- this.getDispatchMerchantListFun()
- this.searchParams = { ...this.filterForm }
- this.selectedId = null
- this.dialogVisible = true
- },
- getDispatchMerchantListFun() {
- getDispatchMerchantList(this.filterForm.orderId,{
- region: this.filterForm.region,
- category: this.filterForm.category,
- keyword: this.filterForm.keyword
- }).then(res => {
- this.technicians = res.data || []
- })
- },
- //获取服务类型
- serviceCategoryFun() {
- serviceCategory().then(res => {
- if (res.code === 200) {
- this.serveTypes = res.data
- }
- })
- },
- getCityListFun() {
- getCityList().then(res => {
- this.regionOptions = res.data || []
- })
- },
- onDialogOpened() {
- this.$nextTick(() => {
- this.initMap()
- })
- },
- onDialogClosed() {
- this.destroyMap()
- this.currentOrder = null
- this.orderDest = null
- this.selectedId = null
- this.filterForm = { ...DEFAULT_FILTER }
- this.searchParams = { ...DEFAULT_FILTER }
- },
- handleSearch() {
- this.getDispatchMerchantListFun()
- return
- this.searchParams = { ...this.filterForm }
- if (this.selectedId && !this.sortedTechnicians.some((item) => item.id === this.selectedId)) {
- this.selectedId = null
- }
- this.$nextTick(() => {
- this.fitMarkerView()
- })
- },
- handleClear() {
- this.filterForm = { region: '', category: '', keyword: '' }
- this.searchParams = { region: '', category: '', keyword: '' }
- this.selectedId = null
- this.$nextTick(() => {
- this.fitMarkerView()
- })
- },
- statusLabel(status) {
- return STATUS_MAP[status] || status
- },
- initMap() {
- if (this.map) return
- window._AMapSecurityConfig = { securityJsCode: AMAP_SECURITY }
- const center = this.orderDest
- ? [this.orderDest.lng, this.orderDest.lat]
- : [116.397428, 39.90923]
- AMapLoader.load({
- key: AMAP_KEY,
- version: '2.0'
- }).then((AMap) => {
- this.AMap = AMap
- this.map = new AMap.Map(this.mapId, {
- resizeEnable: true,
- zoom: 13,
- center,
- viewMode: '2D'
- })
- this.renderDestinationMarker()
- this.renderMarkers()
- this.fitMarkerView()
- this.$nextTick(() => {
- if (this.map) this.map.resize()
- })
- }).catch((err) => {
- console.error('地图加载失败', err)
- this.$message.error('地图加载失败,请检查网络或 Key 配置')
- })
- },
- createDestinationMarkerContent() {
- const wrap = document.createElement('div')
- wrap.className = 'dispatch-map-dest'
- wrap.innerHTML = `
- <div class="dispatch-map-dest__label">用户目的地</div>
- <img class="dispatch-map-dest__icon" src="${userDestIcon}" alt="用户目的地" />
- `
- return wrap
- },
- renderDestinationMarker() {
- if (!this.map || !this.AMap || !this.orderDest) return
- if (this.destMarker) {
- this.map.remove(this.destMarker)
- this.destMarker = null
- }
- this.destMarker = new this.AMap.Marker({
- position: [this.orderDest.lng, this.orderDest.lat],
- content: this.createDestinationMarkerContent(),
- offset: new this.AMap.Pixel(0, 0),
- anchor: 'bottom-center',
- zIndex: 300
- })
- this.map.add(this.destMarker)
- },
- createMarkerContent(item) {
- const wrap = document.createElement('div')
- wrap.className = item.active
- ? 'dispatch-map-marker dispatch-map-marker--active'
- : 'dispatch-map-marker'
- wrap.innerHTML = `
- <div class="dispatch-map-marker__label">${item.nickname}</div>
- <div class="dispatch-map-marker__avatar-wrap">
- <img class="dispatch-map-marker__avatar" src="${item.avatar}" alt="${item.nickname}" />
- </div>
- <div class="dispatch-map-marker__pointer"></div>
- `
- return wrap
- },
- renderMarkers() {
- if (!this.map || !this.AMap) return
- this.clearMarkers()
- this.mapMarkers.forEach((item) => {
- const marker = new this.AMap.Marker({
- position: [item.lng, item.lat],
- content: this.createMarkerContent(item),
- offset: new this.AMap.Pixel(0, 0),
- anchor: 'bottom-center',
- zIndex: item.active ? 200 : 100
- })
- marker.on('click', () => {
- this.selectTechnician(item)
- })
- this.map.add(marker)
- this.markerInstances.push(marker)
- })
- },
- clearMarkers() {
- if (this.markerInstances.length && this.map) {
- this.map.remove(this.markerInstances)
- }
- this.markerInstances = []
- },
- fitMarkerView() {
- if (!this.map) return
- const allMarkers = [...this.markerInstances]
- if (this.destMarker) allMarkers.push(this.destMarker)
- if (!allMarkers.length) return
- this.map.setFitView(allMarkers, false, [60, 60, 60, 60])
- },
- selectTechnician(item) {
- if (this.selectedId === item.merchantId) {
- this.selectedId = null
- return
- }
- this.selectedId = item.merchantId
- if (this.map) {
- this.map.setZoomAndCenter(15, [item.merchantLongitude, item.merchantLatitude])
- }
- },
- handleClose() {
- this.dialogVisible = false
- },
- handleConfirm() {
- if (!this.selectedId) {
- this.$message.warning('请先选择技师')
- return
- }
- if (this.orderStatus !== 'pending') {
- this.$message.warning('订单状态已变更,无法派单')
- return
- }
- //const tech = this.technicians.find((t) => t.id === this.selectedId)
- dispatchOrder(this.filterForm.orderId, {
- merchantId: this.selectedId
- }).then(res => {
- console.log(res)
- if (res.code === 200) {
- this.$message.success(res.msg)
- this.$emit('success',this.filterForm.orderId)
- this.dialogVisible = false
- }
- }).catch(err => {
- this.$message.error(`订单 ${this.currentOrder.orderNo} 已成功派单给 ${tech.nickname}`)
- })
- // this.$emit('success', {
- // order: this.currentOrder,
- // technician: tech
- // })
- },
- destroyMap() {
- this.clearMarkers()
- if (this.destMarker && this.map) {
- this.map.remove(this.destMarker)
- this.destMarker = null
- }
- if (this.map) {
- this.map.destroy()
- this.map = null
- }
- this.AMap = null
- }
- }
- }
- </script>
- <style lang="scss" scoped>
- .dispatch-order-panel {
- display: flex;
- flex-direction: column;
- height: 560px;
- overflow: hidden;
- }
- .panel-header {
- display: flex;
- align-items: center;
- padding: 16px 20px;
- border-bottom: 1px solid #ebeef5;
- gap: 12px;
- .panel-title {
- font-size: 16px;
- font-weight: 600;
- color: #303133;
- flex-shrink: 0;
- }
- .panel-subtitle {
- flex: 1;
- font-size: 12px;
- color: #909399;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .panel-close {
- font-size: 18px;
- color: #909399;
- cursor: pointer;
- flex-shrink: 0;
- &:hover {
- color: #606266;
- }
- }
- }
- .filter-bar {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 16px 20px;
- border-bottom: 1px solid #ebeef5;
- .filter-item {
- width: 160px;
- flex-shrink: 0;
- }
- .filter-search {
- flex: 1;
- min-width: 120px;
- }
- .filter-actions {
- display: flex;
- flex-shrink: 0;
- gap: 8px;
- }
- }
- .panel-body {
- display: flex;
- flex: 1;
- min-height: 0;
- overflow: hidden;
- }
- .technician-list {
- width: 360px;
- flex-shrink: 0;
- align-self: stretch;
- min-height: 0;
- overflow-x: hidden;
- overflow-y: auto;
- -webkit-overflow-scrolling: touch;
- border-right: 1px solid #ebeef5;
- background: #fafafa;
- }
- .technician-card {
- display: flex;
- align-items: flex-start;
- padding: 14px 16px;
- cursor: pointer;
- border-bottom: 1px solid #f0f0f0;
- transition: background 0.2s;
- &:hover,
- &.is-active {
- background: #ecf5ff;
- }
- .technician-radio {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 14px;
- height: 14px;
- margin-right: 8px;
- margin-top: 14px;
- flex-shrink: 0;
- border: 1px solid #dcdfe6;
- border-radius: 50%;
- background: #fff;
- &__inner {
- width: 4px;
- height: 4px;
- border-radius: 50%;
- background: #fff;
- transform: scale(0);
- transition: transform 0.15s ease-in;
- }
- &.is-checked {
- border-color: #409eff;
- background: #409eff;
- .technician-radio__inner {
- transform: scale(1);
- }
- }
- }
- .technician-avatar {
- width: 44px;
- height: 44px;
- border-radius: 50%;
- object-fit: cover;
- flex-shrink: 0;
- margin-right: 10px;
- background: #eee;
- }
- .technician-info {
- flex: 1;
- min-width: 0;
- }
- .technician-name-row {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 6px;
- margin-bottom: 6px;
- }
- .technician-name {
- font-size: 14px;
- font-weight: 500;
- color: #303133;
- }
- .technician-distance {
- margin-left: auto;
- font-size: 12px;
- color: #409eff;
- white-space: nowrap;
- }
- .status-tag {
- display: inline-block;
- padding: 0 6px;
- font-size: 12px;
- line-height: 20px;
- border-radius: 2px;
- &--online {
- color: #67c23a;
- background: #f0f9eb;
- }
- &--onway {
- color: #e6a23c;
- background: #fdf6ec;
- }
- &--rest {
- color: #909399;
- background: #f4f4f5;
- }
- }
- .technician-address {
- font-size: 12px;
- color: #909399;
- line-height: 1.5;
- word-break: break-all;
- }
- }
- .empty-tip {
- padding: 40px 16px;
- text-align: center;
- color: #909399;
- font-size: 14px;
- }
- .map-wrap {
- flex: 1;
- min-width: 0;
- min-height: 0;
- background: #eef1f5;
- }
- .map-container {
- width: 100%;
- height: 100%;
- }
- .panel-footer {
- display: flex;
- justify-content: flex-end;
- gap: 12px;
- padding: 14px 20px;
- border-top: 1px solid #ebeef5;
- }
- </style>
- <style lang="scss">
- .dispatch-order-dialog {
- .el-dialog__header {
- display: none;
- }
- .el-dialog__body {
- padding: 0;
- }
- }
- .dispatch-map-dest {
- display: flex;
- flex-direction: column;
- align-items: center;
- &__label {
- margin-bottom: 4px;
- padding: 2px 8px;
- background: #f56c6c;
- color: #fff;
- font-size: 12px;
- border-radius: 3px;
- white-space: nowrap;
- }
- &__icon {
- display: block;
- height: 40px;
- width: auto;
- filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
- }
- }
- .dispatch-map-marker {
- display: flex;
- flex-direction: column;
- align-items: center;
- cursor: pointer;
- &__label {
- margin-bottom: 4px;
- padding: 2px 8px;
- background: rgba(0, 0, 0, 0.65);
- color: #fff;
- font-size: 12px;
- border-radius: 3px;
- white-space: nowrap;
- }
- &__avatar-wrap {
- width: 40px;
- height: 40px;
- border-radius: 50%;
- border: 2px solid #fff;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
- overflow: hidden;
- background: #eee;
- }
- &__avatar {
- display: block;
- width: 100%;
- height: 100%;
- object-fit: cover;
- }
- &__pointer {
- width: 0;
- height: 0;
- margin-top: -1px;
- border-left: 6px solid transparent;
- border-right: 6px solid transparent;
- border-top: 8px solid #fff;
- filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.15));
- }
- &--active {
- .dispatch-map-marker__label {
- background: #409eff;
- }
- .dispatch-map-marker__avatar-wrap {
- width: 48px;
- height: 48px;
- border-color: #409eff;
- border-width: 3px;
- box-shadow: 0 2px 12px rgba(64, 158, 255, 0.5);
- }
- .dispatch-map-marker__pointer {
- border-top-color: #409eff;
- }
- }
- }
- </style>
|