| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686 |
- <template>
- <el-dialog v-model="dialogVisible" title="派单调度" width="900px" top="5vh" destroy-on-close append-to-body>
- <div class="dispatch-dialog-content">
- <!-- Top: Order Info (OrderDispatch Style) -->
- <div class="dispatch-order-info" v-if="order">
- <div class="list-card order-card" style="margin:0; box-shadow:none; cursor:default; border:none;">
- <div class="card-left">
- <div class="type-tag" :class="order.typeCode">
- {{ getShortType(order.typeCode) }}
- </div>
- </div>
- <div class="card-main">
- <template v-if="order.typeCode === 'transport'">
- <div class="row-addr" :title="order.pickAddr">
- <span class="tag pick">取</span> {{ order.pickAddr }}
- </div>
- <div class="row-addr" :title="order.dropAddr">
- <span class="tag drop">送</span> {{ order.dropAddr }}
- </div>
- </template>
- <template v-else>
- <div class="row-addr" :title="order.address">
- <span class="tag home">址</span> {{ order.address }}
- </div>
- </template>
- <div class="row-time" style="margin-top: 4px;">
- <el-icon>
- <Clock />
- </el-icon> {{ order.time }}
- <span class="days-tag" v-if="order.daysLater">{{ order.daysLater }}</span>
- </div>
- </div>
- <!-- 新增右侧按钮组 -->
- <div class="card-right" style="display: flex; align-items: center; gap: 10px; padding-left: 20px;">
- <el-button type="primary" size="small" plain round icon="User" @click="openCustomerDetail" :loading="orderInfoLoading">用户档案</el-button>
- <el-button type="success" size="small" plain round @click="openPetDetail" :loading="orderInfoLoading" style="margin-left: 0;">宠物档案</el-button>
- </div>
- </div>
- </div>
- <!-- Current Rider Info (If Exists) -->
- <div class="current-rider-section" v-if="currentRider">
- <div class="select-header" style="margin-bottom:8px;">
- <span class="tit">当前派单履约者</span>
- </div>
- <div class="list-card rider-card"
- style="margin-bottom: 20px; border: 1px solid #e4e7ed; background:#fafafa; cursor:default;">
- <div class="card-left relative">
- <el-avatar :src="currentRider.avatar" :size="40" />
- </div>
- <div class="card-main">
- <div class="row-1"
- style="justify-content: space-between; align-items: flex-start; display: flex;">
- <div style="display:flex; align-items:baseline; gap:8px;">
- <span class="r-name">{{ currentRider.name || '--' }}</span>
- <span class="r-phone">{{ currentRider.phone || '--' }}</span>
- <dict-tag :options="sys_user_sex" :value="currentRider.gender" />
- <el-tag v-if="currentRider.status" size="small" :type="getStatusType(currentRider.status)" effect="plain">
- {{ getStatusText(currentRider.status) }}
- </el-tag>
- </div>
- </div>
- <div class="row-2 categories-row"
- style="margin-top: 6px; display:flex; gap:4px; flex-wrap:wrap;">
- <el-tag v-for="typeId in (currentRider.serviceTypes ? String(currentRider.serviceTypes).split(',') : [])" :key="typeId" size="small"
- type="primary" effect="plain">{{ getServiceTypeText(typeId) }}</el-tag>
- </div>
- <div class="row-3 time-row" style="margin-top: 4px;">
- <span class="last-time">下一单: {{ currentRider.nextOrderTime || '-' }}</span>
- </div>
- </div>
- </div>
- </div>
- <!-- Middle: Rider Selection -->
- <div class="dispatch-rider-select">
- <div class="select-header">
- <span class="tit">选择履约者</span>
- <el-input v-model="dispatchSearchQuery" placeholder="搜索履约者姓名/手机号" prefix-icon="Search" clearable
- style="width: 240px" />
- </div>
- <div class="rider-grid-wrapper">
- <el-scrollbar class="rider-scroll">
- <div class="rider-grid">
- <div v-for="rider in filteredDispatchRiders" :key="rider.id"
- class="list-card rider-card select-card"
- :class="{ active: selectedRiderId === rider.id }" @click="selectedRiderId = rider.id">
- <!-- Reusing Rider Card Layout -->
- <div class="card-left relative">
- <el-avatar :src="rider.avatar" :size="40" />
- </div>
- <div class="card-main">
- <div class="row-1" style="justify-content: space-between; align-items: flex-start; display: flex;">
- <div style="display:flex; align-items:baseline; gap:8px;">
- <span class="r-name">{{ rider.name || '--' }}</span>
- <span class="r-phone">{{ rider.phone || '--' }}</span>
- <dict-tag :options="sys_user_sex" :value="rider.gender" />
- </div>
- <el-tag v-if="rider.status" size="small" :type="getStatusType(rider.status)" effect="plain">
- {{ getStatusText(rider.status) }}
- </el-tag>
- </div>
- <div class="row-2 categories-row"
- style="margin-top: 6px; display:flex; gap:4px; flex-wrap:wrap;">
- <el-tag v-for="typeId in (rider.serviceTypes ? String(rider.serviceTypes).split(',') : [])" :key="typeId" size="small"
- type="primary" effect="plain">{{ getServiceTypeText(typeId) }}</el-tag>
- </div>
- <div class="row-3 time-row" style="margin-top: 4px">
- <span class="last-time">下一单: {{ rider.nextOrderTime || '-' }}</span>
- </div>
- </div>
- <!-- Selected Check -->
- <div class="selected-mark" v-if="selectedRiderId === rider.id">
- <el-icon>
- <Check />
- </el-icon>
- </div>
- </div>
- <div v-if="filteredDispatchRiders.length === 0" class="empty-text">暂无符合条件的履约者</div>
- </div>
- </el-scrollbar>
- </div>
- <div class="rider-pagination" style="margin-top: 20px;">
- <el-pagination v-model:current-page="pageNum" v-model:page-size="pageSize"
- :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" :total="total"
- @current-change="loadRiders" @size-change="handlePageSizeChange" />
- </div>
- </div>
- <!-- Bottom: Fee & Submit -->
- <div class="dispatch-footer">
- <div class="fee-input">
- <span class="label">服务费用:</span>
- <el-input-number v-model="dispatchFee" :min="0" :precision="2" :step="10" placeholder="请输入"
- style="width: 140px;" />
- <span class="unit">元</span>
- </div>
- <div class="btns">
- <el-button @click="dialogVisible = false">取消</el-button>
- <el-button type="primary" :disabled="!canSubmit" @click="handleDispatchSubmit">确认派单</el-button>
- </div>
- </div>
- </div>
- </el-dialog>
- <CustomerDetailDrawer v-model:visible="customerDialogVisible" :customer-id="customerId" />
- <PetDetailDrawer v-model:visible="petDialogVisible" :pet-id="petId" />
- </template>
- <script setup>
- import { ref, computed, watch, getCurrentInstance, toRefs } from 'vue'
- import { ElMessage } from 'element-plus'
- import { pageFulfillerOnOrder } from '@/api/fulfiller/pool'
- import { listAllTag } from '@/api/fulfiller/tag'
- import { getSubOrderInfo } from '@/api/order/subOrder/index'
- import { listServiceOnOrder } from '@/api/service/list/index'
- import CustomerDetailDrawer from '@/components/CustomerDetailDrawer/index.vue'
- import PetDetailDrawer from '@/components/PetDetailDrawer/index.vue'
- const props = defineProps({
- visible: Boolean,
- order: Object
- })
- const emit = defineEmits(['update:visible', 'submit'])
- const { proxy } = getCurrentInstance();
- const { sys_user_sex } = toRefs(proxy.useDict('sys_user_sex'));
- const dialogVisible = computed({
- get: () => props.visible,
- set: (val) => emit('update:visible', val)
- })
- const ridersList = ref([])
- const total = ref(0)
- const pageNum = ref(1)
- const pageSize = ref(10)
- const allTags = ref([])
- const tagMap = computed(() => {
- const map = {}
- for (const t of (allTags.value || [])) {
- if (t && t.id !== undefined && t.id !== null) map[t.id] = t
- }
- return map
- })
- const currentRider = ref(null)
- const dispatchSearchQuery = ref('')
- const selectedRiderId = ref(null)
- const dispatchFee = ref(0)
- const customerDialogVisible = ref(false)
- const petDialogVisible = ref(false)
- const customerId = ref(null)
- const petId = ref(null)
- const orderInfoLoading = ref(false)
- const loadAllTags = async () => {
- if (allTags.value && allTags.value.length > 0) return
- try {
- const res = await listAllTag({ category: 'fulfiller' })
- allTags.value = res?.data || []
- } catch {
- allTags.value = []
- }
- }
- const serviceOptions = ref([])
- const loadServiceOptions = async () => {
- if (serviceOptions.value.length > 0) return
- try {
- const res = await listServiceOnOrder()
- serviceOptions.value = res?.data || []
- } catch { /* ignore */ }
- }
- const getServiceTypeText = (id) => {
- const s = serviceOptions.value.find(item => String(item.id) === String(id))
- return s ? s.name : String(id)
- }
- const loadRiders = async () => {
- try {
- const res = await pageFulfillerOnOrder({
- content: dispatchSearchQuery.value || undefined,
- pageNum: pageNum.value,
- pageSize: pageSize.value,
- service: props.order?.service
- })
- const list = res?.rows || []
- ridersList.value = list.map(r => ({
- ...r,
- nextOrderTime: r.nextOrderTime || '-',
- gender: r.gender ?? r.sex
- }))
- total.value = res?.total || 0
- if (props.order?.riderId) {
- currentRider.value = ridersList.value.find(r => r.id === props.order.riderId) || null
- }
- } catch {
- ridersList.value = []
- total.value = 0
- }
- }
- const handlePageSizeChange = (size) => {
- pageSize.value = size
- pageNum.value = 1
- loadRiders()
- }
- watch(() => props.visible, (val) => {
- if (val && props.order) {
- currentRider.value = null
- dispatchSearchQuery.value = ''
- selectedRiderId.value = null
- // price 单位为分,转成元显示
- dispatchFee.value = props.order?.price ? Number((props.order.price / 100).toFixed(2)) : 0
- if (props.order?.riderId) {
- currentRider.value = {
- id: props.order.riderId,
- gender: props.order.riderGender ?? props.order.riderSex
- }
- }
- pageNum.value = 1
- loadAllTags()
- loadServiceOptions()
- loadRiders()
- // 获取订单详细信息
- customerId.value = null
- petId.value = null
- orderInfoLoading.value = true
- getSubOrderInfo(props.order.id).then((res) => {
- if(res.data) {
- // 如果 usrCustomer / usrPet 是对象则取其 id,如果是 ID 直接取
- customerId.value = res.data.usrCustomer?.id || res.data.usrCustomer
- petId.value = res.data.usrPet?.id || res.data.usrPet
-
- // 接到详情后,把真实的金额放进去(后端金额单位为分)
- if (res.data.price !== undefined && res.data.price !== null) {
- dispatchFee.value = Number((res.data.price / 100).toFixed(2))
- }
- // 如果已经有履约者且不是在列表中找到的,从详情中补全性别
- if (props.order?.riderId && !currentRider.value) {
- currentRider.value = {
- id: props.order.riderId,
- name: res.data.fulfillerName,
- gender: res.data.fulfillerGender ?? res.data.fulfillerSex,
- status: res.data.fulfillerStatus
- }
- } else if (currentRider.value && (res.data.fulfillerGender || res.data.fulfillerSex)) {
- currentRider.value.gender = res.data.fulfillerGender ?? res.data.fulfillerSex
- }
- }
- }).catch((e) => {
- console.error('获取订单详细信息失败', e)
- }).finally(() => {
- orderInfoLoading.value = false
- })
- }
- })
- const openCustomerDetail = () => {
- if (!customerId.value) {
- ElMessage.warning('未能获取到用户信息')
- return
- }
- customerDialogVisible.value = true
- }
- const openPetDetail = () => {
- if (!petId.value) {
- ElMessage.warning('未能获取到宠物信息')
- return
- }
- petDialogVisible.value = true
- }
- const getTagText = (tagId) => {
- const t = tagMap.value?.[tagId]
- return t?.name || String(tagId)
- }
- const getTagType = (tagId) => {
- const t = tagMap.value?.[tagId]
- const type = t?.colorType
- if (type === 'success' || type === 'warning' || type === 'danger' || type === 'info') return type
- return ''
- }
- watch(dispatchSearchQuery, () => {
- pageNum.value = 1
- loadRiders()
- })
- const getShortType = (code) => {
- const map = { 'transport': '接送', 'feeding': '喂遛', 'washing': '洗护' }
- return map[code] || '订单'
- }
- const getStatusText = (status) => {
- const statusMap = {
- resting: '休息',
- busy: '接单中',
- disabled: '禁用'
- }
- return statusMap[status] || status
- }
- const getStatusType = (status) => {
- const typeMap = {
- resting: 'info',
- busy: 'success',
- disabled: 'danger'
- }
- return typeMap[status] || 'info'
- }
- const filteredDispatchRiders = computed(() => {
- return ridersList.value || []
- })
- const canSubmit = computed(() => {
- return !!selectedRiderId.value && !!dispatchFee.value
- })
- const handleDispatchSubmit = () => {
- if (!selectedRiderId.value) {
- ElMessage.warning('请选择履约者')
- return
- }
- if (!dispatchFee.value) {
- ElMessage.warning('请输入服务费用')
- return
- }
- const rider = ridersList.value.find(r => r.id === selectedRiderId.value)
- emit('submit', {
- riderId: rider.id,
- riderName: rider.name,
- fee: dispatchFee.value
- })
- dialogVisible.value = false
- }
- </script>
- <style scoped>
- /* Dispatch Dialog Styles */
- .list-card {
- background: #fff;
- border: 1px solid #ebeef5;
- border-radius: 8px;
- padding: 12px;
- margin-bottom: 10px;
- display: flex;
- align-items: stretch;
- gap: 12px;
- transition: all 0.2s;
- cursor: pointer;
- }
- .list-card:hover {
- border-color: #c6e2ff;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
- }
- .card-left {
- flex-shrink: 0;
- display: flex;
- align-items: center;
- }
- .order-card .type-tag {
- width: 40px;
- height: 40px;
- border-radius: 8px;
- color: #fff;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 12px;
- font-weight: bold;
- }
- .type-tag.transport {
- background: #e6a23c;
- }
- .type-tag.feeding {
- background: #67c23a;
- }
- .type-tag.washing {
- background: #409eff;
- }
- .card-main {
- flex: 1;
- overflow: hidden;
- display: flex;
- flex-direction: column;
- justify-content: center;
- gap: 4px;
- }
- .row-addr {
- font-size: 13px;
- color: #303133;
- display: flex;
- align-items: center;
- gap: 4px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- line-height: 1.5;
- }
- .row-addr .tag {
- font-size: 11px;
- color: #fff;
- padding: 1px 4px;
- border-radius: 4px;
- flex-shrink: 0;
- transform: scale(0.9);
- }
- .tag.pick {
- background: #409eff;
- }
- .tag.drop {
- background: #e6a23c;
- }
- .tag.home {
- background: #67c23a;
- }
- .row-time {
- font-size: 12px;
- color: #909399;
- display: flex;
- align-items: center;
- gap: 4px;
- }
- .days-tag {
- color: #f56c6c;
- background: #fef0f0;
- padding: 0 4px;
- border-radius: 4px;
- font-size: 11px;
- border: 1px solid #fde2e2;
- transform: scale(0.95);
- }
- .dispatch-order-info {
- background: #f5f7fa;
- padding: 10px;
- border-radius: 4px;
- margin-bottom: 20px;
- border: 1px solid #e4e7ed;
- display: block;
- }
- .dispatch-rider-select .select-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 10px;
- }
- .dispatch-rider-select .tit {
- font-weight: bold;
- font-size: 14px;
- }
- .rider-scroll {
- max-height: 45vh;
- }
- .rider-grid {
- display: grid;
- grid-template-columns: repeat(2, 1fr);
- gap: 12px;
- padding-right: 10px;
- }
- .rider-pagination {
- margin-top: 10px;
- }
- .rider-card.select-card {
- cursor: pointer;
- border: 1px solid #dcdfe6;
- position: relative;
- transition: all 0.2s;
- margin-bottom: 0;
- }
- .rider-card.select-card:hover {
- border-color: #409eff;
- }
- .rider-card.select-card.active {
- border-color: #409eff;
- background-color: #ecf5ff;
- }
- .selected-mark {
- position: absolute;
- top: 0;
- right: 0;
- background: #409eff;
- color: #fff;
- border-bottom-left-radius: 6px;
- width: 20px;
- height: 20px;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 12px;
- }
- .rider-card .card-left .dot {
- position: absolute;
- bottom: 0;
- right: 0;
- width: 10px;
- height: 10px;
- border-radius: 50%;
- border: 2px solid #fff;
- }
- .dot.online {
- background: #67c23a;
- }
- .dot.busy {
- background: #409eff;
- }
- .dot.offline {
- background: #909399;
- }
- .r-name {
- font-weight: bold;
- font-size: 14px;
- color: #303133;
- margin-right: 8px;
- }
- .r-phone {
- font-size: 12px;
- color: #909399;
- }
- .status-badge {
- font-size: 11px;
- padding: 2px 6px;
- border-radius: 4px;
- display: inline-block;
- font-weight: bold;
- }
- .status-badge.online {
- background: #f0f9eb;
- color: #67c23a;
- }
- .status-badge.busy {
- background: #ecf5ff;
- color: #409eff;
- }
- .status-badge.offline {
- background: #f4f4f5;
- color: #909399;
- }
- .cat-tag {
- background: #f4f4f5;
- color: #909399;
- font-size: 10px;
- padding: 1px 4px;
- border-radius: 2px;
- margin-right: 4px;
- }
- .cat-tag.cat-transport {
- background: #e6f7ff;
- color: #1890ff;
- border: 1px solid #91d5ff;
- }
- .cat-tag.cat-feeding {
- background: #f6ffed;
- color: #52c41a;
- border: 1px solid #b7eb8f;
- }
- .cat-tag.cat-washing {
- background: #fff0f6;
- color: #eb2f96;
- border: 1px solid #ffadd2;
- }
- .last-time {
- font-size: 11px;
- color: #999;
- }
- .empty-text {
- text-align: center;
- color: #909399;
- padding: 20px;
- width: 100%;
- grid-column: span 2;
- }
- .dispatch-footer {
- margin-top: 20px;
- padding-top: 20px;
- border-top: 1px solid #ebeef5;
- display: flex;
- justify-content: space-between;
- align-items: center;
- }
- .dispatch-footer .fee-input {
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 14px;
- }
- </style>
|