DispatchDialog.vue 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686
  1. <template>
  2. <el-dialog v-model="dialogVisible" title="派单调度" width="900px" top="5vh" destroy-on-close append-to-body>
  3. <div class="dispatch-dialog-content">
  4. <!-- Top: Order Info (OrderDispatch Style) -->
  5. <div class="dispatch-order-info" v-if="order">
  6. <div class="list-card order-card" style="margin:0; box-shadow:none; cursor:default; border:none;">
  7. <div class="card-left">
  8. <div class="type-tag" :class="order.typeCode">
  9. {{ getShortType(order.typeCode) }}
  10. </div>
  11. </div>
  12. <div class="card-main">
  13. <template v-if="order.typeCode === 'transport'">
  14. <div class="row-addr" :title="order.pickAddr">
  15. <span class="tag pick">取</span> {{ order.pickAddr }}
  16. </div>
  17. <div class="row-addr" :title="order.dropAddr">
  18. <span class="tag drop">送</span> {{ order.dropAddr }}
  19. </div>
  20. </template>
  21. <template v-else>
  22. <div class="row-addr" :title="order.address">
  23. <span class="tag home">址</span> {{ order.address }}
  24. </div>
  25. </template>
  26. <div class="row-time" style="margin-top: 4px;">
  27. <el-icon>
  28. <Clock />
  29. </el-icon> {{ order.time }}
  30. <span class="days-tag" v-if="order.daysLater">{{ order.daysLater }}</span>
  31. </div>
  32. </div>
  33. <!-- 新增右侧按钮组 -->
  34. <div class="card-right" style="display: flex; align-items: center; gap: 10px; padding-left: 20px;">
  35. <el-button type="primary" size="small" plain round icon="User" @click="openCustomerDetail" :loading="orderInfoLoading">用户档案</el-button>
  36. <el-button type="success" size="small" plain round @click="openPetDetail" :loading="orderInfoLoading" style="margin-left: 0;">宠物档案</el-button>
  37. </div>
  38. </div>
  39. </div>
  40. <!-- Current Rider Info (If Exists) -->
  41. <div class="current-rider-section" v-if="currentRider">
  42. <div class="select-header" style="margin-bottom:8px;">
  43. <span class="tit">当前派单履约者</span>
  44. </div>
  45. <div class="list-card rider-card"
  46. style="margin-bottom: 20px; border: 1px solid #e4e7ed; background:#fafafa; cursor:default;">
  47. <div class="card-left relative">
  48. <el-avatar :src="currentRider.avatar" :size="40" />
  49. </div>
  50. <div class="card-main">
  51. <div class="row-1"
  52. style="justify-content: space-between; align-items: flex-start; display: flex;">
  53. <div style="display:flex; align-items:baseline; gap:8px;">
  54. <span class="r-name">{{ currentRider.name || '--' }}</span>
  55. <span class="r-phone">{{ currentRider.phone || '--' }}</span>
  56. <dict-tag :options="sys_user_sex" :value="currentRider.gender" />
  57. <el-tag v-if="currentRider.status" size="small" :type="getStatusType(currentRider.status)" effect="plain">
  58. {{ getStatusText(currentRider.status) }}
  59. </el-tag>
  60. </div>
  61. </div>
  62. <div class="row-2 categories-row"
  63. style="margin-top: 6px; display:flex; gap:4px; flex-wrap:wrap;">
  64. <el-tag v-for="typeId in (currentRider.serviceTypes ? String(currentRider.serviceTypes).split(',') : [])" :key="typeId" size="small"
  65. type="primary" effect="plain">{{ getServiceTypeText(typeId) }}</el-tag>
  66. </div>
  67. <div class="row-3 time-row" style="margin-top: 4px;">
  68. <span class="last-time">下一单: {{ currentRider.nextOrderTime || '-' }}</span>
  69. </div>
  70. </div>
  71. </div>
  72. </div>
  73. <!-- Middle: Rider Selection -->
  74. <div class="dispatch-rider-select">
  75. <div class="select-header">
  76. <span class="tit">选择履约者</span>
  77. <el-input v-model="dispatchSearchQuery" placeholder="搜索履约者姓名/手机号" prefix-icon="Search" clearable
  78. style="width: 240px" />
  79. </div>
  80. <div class="rider-grid-wrapper">
  81. <el-scrollbar class="rider-scroll">
  82. <div class="rider-grid">
  83. <div v-for="rider in filteredDispatchRiders" :key="rider.id"
  84. class="list-card rider-card select-card"
  85. :class="{ active: selectedRiderId === rider.id }" @click="selectedRiderId = rider.id">
  86. <!-- Reusing Rider Card Layout -->
  87. <div class="card-left relative">
  88. <el-avatar :src="rider.avatar" :size="40" />
  89. </div>
  90. <div class="card-main">
  91. <div class="row-1" style="justify-content: space-between; align-items: flex-start; display: flex;">
  92. <div style="display:flex; align-items:baseline; gap:8px;">
  93. <span class="r-name">{{ rider.name || '--' }}</span>
  94. <span class="r-phone">{{ rider.phone || '--' }}</span>
  95. <dict-tag :options="sys_user_sex" :value="rider.gender" />
  96. </div>
  97. <el-tag v-if="rider.status" size="small" :type="getStatusType(rider.status)" effect="plain">
  98. {{ getStatusText(rider.status) }}
  99. </el-tag>
  100. </div>
  101. <div class="row-2 categories-row"
  102. style="margin-top: 6px; display:flex; gap:4px; flex-wrap:wrap;">
  103. <el-tag v-for="typeId in (rider.serviceTypes ? String(rider.serviceTypes).split(',') : [])" :key="typeId" size="small"
  104. type="primary" effect="plain">{{ getServiceTypeText(typeId) }}</el-tag>
  105. </div>
  106. <div class="row-3 time-row" style="margin-top: 4px">
  107. <span class="last-time">下一单: {{ rider.nextOrderTime || '-' }}</span>
  108. </div>
  109. </div>
  110. <!-- Selected Check -->
  111. <div class="selected-mark" v-if="selectedRiderId === rider.id">
  112. <el-icon>
  113. <Check />
  114. </el-icon>
  115. </div>
  116. </div>
  117. <div v-if="filteredDispatchRiders.length === 0" class="empty-text">暂无符合条件的履约者</div>
  118. </div>
  119. </el-scrollbar>
  120. </div>
  121. <div class="rider-pagination" style="margin-top: 20px;">
  122. <el-pagination v-model:current-page="pageNum" v-model:page-size="pageSize"
  123. :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next" :total="total"
  124. @current-change="loadRiders" @size-change="handlePageSizeChange" />
  125. </div>
  126. </div>
  127. <!-- Bottom: Fee & Submit -->
  128. <div class="dispatch-footer">
  129. <div class="fee-input">
  130. <span class="label">服务费用:</span>
  131. <el-input-number v-model="dispatchFee" :min="0" :precision="2" :step="10" placeholder="请输入"
  132. style="width: 140px;" />
  133. <span class="unit">元</span>
  134. </div>
  135. <div class="btns">
  136. <el-button @click="dialogVisible = false">取消</el-button>
  137. <el-button type="primary" :disabled="!canSubmit" @click="handleDispatchSubmit">确认派单</el-button>
  138. </div>
  139. </div>
  140. </div>
  141. </el-dialog>
  142. <CustomerDetailDrawer v-model:visible="customerDialogVisible" :customer-id="customerId" />
  143. <PetDetailDrawer v-model:visible="petDialogVisible" :pet-id="petId" />
  144. </template>
  145. <script setup>
  146. import { ref, computed, watch, getCurrentInstance, toRefs } from 'vue'
  147. import { ElMessage } from 'element-plus'
  148. import { pageFulfillerOnOrder } from '@/api/fulfiller/pool'
  149. import { listAllTag } from '@/api/fulfiller/tag'
  150. import { getSubOrderInfo } from '@/api/order/subOrder/index'
  151. import { listServiceOnOrder } from '@/api/service/list/index'
  152. import CustomerDetailDrawer from '@/components/CustomerDetailDrawer/index.vue'
  153. import PetDetailDrawer from '@/components/PetDetailDrawer/index.vue'
  154. const props = defineProps({
  155. visible: Boolean,
  156. order: Object
  157. })
  158. const emit = defineEmits(['update:visible', 'submit'])
  159. const { proxy } = getCurrentInstance();
  160. const { sys_user_sex } = toRefs(proxy.useDict('sys_user_sex'));
  161. const dialogVisible = computed({
  162. get: () => props.visible,
  163. set: (val) => emit('update:visible', val)
  164. })
  165. const ridersList = ref([])
  166. const total = ref(0)
  167. const pageNum = ref(1)
  168. const pageSize = ref(10)
  169. const allTags = ref([])
  170. const tagMap = computed(() => {
  171. const map = {}
  172. for (const t of (allTags.value || [])) {
  173. if (t && t.id !== undefined && t.id !== null) map[t.id] = t
  174. }
  175. return map
  176. })
  177. const currentRider = ref(null)
  178. const dispatchSearchQuery = ref('')
  179. const selectedRiderId = ref(null)
  180. const dispatchFee = ref(0)
  181. const customerDialogVisible = ref(false)
  182. const petDialogVisible = ref(false)
  183. const customerId = ref(null)
  184. const petId = ref(null)
  185. const orderInfoLoading = ref(false)
  186. const loadAllTags = async () => {
  187. if (allTags.value && allTags.value.length > 0) return
  188. try {
  189. const res = await listAllTag({ category: 'fulfiller' })
  190. allTags.value = res?.data || []
  191. } catch {
  192. allTags.value = []
  193. }
  194. }
  195. const serviceOptions = ref([])
  196. const loadServiceOptions = async () => {
  197. if (serviceOptions.value.length > 0) return
  198. try {
  199. const res = await listServiceOnOrder()
  200. serviceOptions.value = res?.data || []
  201. } catch { /* ignore */ }
  202. }
  203. const getServiceTypeText = (id) => {
  204. const s = serviceOptions.value.find(item => String(item.id) === String(id))
  205. return s ? s.name : String(id)
  206. }
  207. const loadRiders = async () => {
  208. try {
  209. const res = await pageFulfillerOnOrder({
  210. content: dispatchSearchQuery.value || undefined,
  211. pageNum: pageNum.value,
  212. pageSize: pageSize.value,
  213. service: props.order?.service
  214. })
  215. const list = res?.rows || []
  216. ridersList.value = list.map(r => ({
  217. ...r,
  218. nextOrderTime: r.nextOrderTime || '-',
  219. gender: r.gender ?? r.sex
  220. }))
  221. total.value = res?.total || 0
  222. if (props.order?.riderId) {
  223. currentRider.value = ridersList.value.find(r => r.id === props.order.riderId) || null
  224. }
  225. } catch {
  226. ridersList.value = []
  227. total.value = 0
  228. }
  229. }
  230. const handlePageSizeChange = (size) => {
  231. pageSize.value = size
  232. pageNum.value = 1
  233. loadRiders()
  234. }
  235. watch(() => props.visible, (val) => {
  236. if (val && props.order) {
  237. currentRider.value = null
  238. dispatchSearchQuery.value = ''
  239. selectedRiderId.value = null
  240. // price 单位为分,转成元显示
  241. dispatchFee.value = props.order?.price ? Number((props.order.price / 100).toFixed(2)) : 0
  242. if (props.order?.riderId) {
  243. currentRider.value = {
  244. id: props.order.riderId,
  245. gender: props.order.riderGender ?? props.order.riderSex
  246. }
  247. }
  248. pageNum.value = 1
  249. loadAllTags()
  250. loadServiceOptions()
  251. loadRiders()
  252. // 获取订单详细信息
  253. customerId.value = null
  254. petId.value = null
  255. orderInfoLoading.value = true
  256. getSubOrderInfo(props.order.id).then((res) => {
  257. if(res.data) {
  258. // 如果 usrCustomer / usrPet 是对象则取其 id,如果是 ID 直接取
  259. customerId.value = res.data.usrCustomer?.id || res.data.usrCustomer
  260. petId.value = res.data.usrPet?.id || res.data.usrPet
  261. // 接到详情后,把真实的金额放进去(后端金额单位为分)
  262. if (res.data.price !== undefined && res.data.price !== null) {
  263. dispatchFee.value = Number((res.data.price / 100).toFixed(2))
  264. }
  265. // 如果已经有履约者且不是在列表中找到的,从详情中补全性别
  266. if (props.order?.riderId && !currentRider.value) {
  267. currentRider.value = {
  268. id: props.order.riderId,
  269. name: res.data.fulfillerName,
  270. gender: res.data.fulfillerGender ?? res.data.fulfillerSex,
  271. status: res.data.fulfillerStatus
  272. }
  273. } else if (currentRider.value && (res.data.fulfillerGender || res.data.fulfillerSex)) {
  274. currentRider.value.gender = res.data.fulfillerGender ?? res.data.fulfillerSex
  275. }
  276. }
  277. }).catch((e) => {
  278. console.error('获取订单详细信息失败', e)
  279. }).finally(() => {
  280. orderInfoLoading.value = false
  281. })
  282. }
  283. })
  284. const openCustomerDetail = () => {
  285. if (!customerId.value) {
  286. ElMessage.warning('未能获取到用户信息')
  287. return
  288. }
  289. customerDialogVisible.value = true
  290. }
  291. const openPetDetail = () => {
  292. if (!petId.value) {
  293. ElMessage.warning('未能获取到宠物信息')
  294. return
  295. }
  296. petDialogVisible.value = true
  297. }
  298. const getTagText = (tagId) => {
  299. const t = tagMap.value?.[tagId]
  300. return t?.name || String(tagId)
  301. }
  302. const getTagType = (tagId) => {
  303. const t = tagMap.value?.[tagId]
  304. const type = t?.colorType
  305. if (type === 'success' || type === 'warning' || type === 'danger' || type === 'info') return type
  306. return ''
  307. }
  308. watch(dispatchSearchQuery, () => {
  309. pageNum.value = 1
  310. loadRiders()
  311. })
  312. const getShortType = (code) => {
  313. const map = { 'transport': '接送', 'feeding': '喂遛', 'washing': '洗护' }
  314. return map[code] || '订单'
  315. }
  316. const getStatusText = (status) => {
  317. const statusMap = {
  318. resting: '休息',
  319. busy: '接单中',
  320. disabled: '禁用'
  321. }
  322. return statusMap[status] || status
  323. }
  324. const getStatusType = (status) => {
  325. const typeMap = {
  326. resting: 'info',
  327. busy: 'success',
  328. disabled: 'danger'
  329. }
  330. return typeMap[status] || 'info'
  331. }
  332. const filteredDispatchRiders = computed(() => {
  333. return ridersList.value || []
  334. })
  335. const canSubmit = computed(() => {
  336. return !!selectedRiderId.value && !!dispatchFee.value
  337. })
  338. const handleDispatchSubmit = () => {
  339. if (!selectedRiderId.value) {
  340. ElMessage.warning('请选择履约者')
  341. return
  342. }
  343. if (!dispatchFee.value) {
  344. ElMessage.warning('请输入服务费用')
  345. return
  346. }
  347. const rider = ridersList.value.find(r => r.id === selectedRiderId.value)
  348. emit('submit', {
  349. riderId: rider.id,
  350. riderName: rider.name,
  351. fee: dispatchFee.value
  352. })
  353. dialogVisible.value = false
  354. }
  355. </script>
  356. <style scoped>
  357. /* Dispatch Dialog Styles */
  358. .list-card {
  359. background: #fff;
  360. border: 1px solid #ebeef5;
  361. border-radius: 8px;
  362. padding: 12px;
  363. margin-bottom: 10px;
  364. display: flex;
  365. align-items: stretch;
  366. gap: 12px;
  367. transition: all 0.2s;
  368. cursor: pointer;
  369. }
  370. .list-card:hover {
  371. border-color: #c6e2ff;
  372. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
  373. }
  374. .card-left {
  375. flex-shrink: 0;
  376. display: flex;
  377. align-items: center;
  378. }
  379. .order-card .type-tag {
  380. width: 40px;
  381. height: 40px;
  382. border-radius: 8px;
  383. color: #fff;
  384. display: flex;
  385. align-items: center;
  386. justify-content: center;
  387. font-size: 12px;
  388. font-weight: bold;
  389. }
  390. .type-tag.transport {
  391. background: #e6a23c;
  392. }
  393. .type-tag.feeding {
  394. background: #67c23a;
  395. }
  396. .type-tag.washing {
  397. background: #409eff;
  398. }
  399. .card-main {
  400. flex: 1;
  401. overflow: hidden;
  402. display: flex;
  403. flex-direction: column;
  404. justify-content: center;
  405. gap: 4px;
  406. }
  407. .row-addr {
  408. font-size: 13px;
  409. color: #303133;
  410. display: flex;
  411. align-items: center;
  412. gap: 4px;
  413. white-space: nowrap;
  414. overflow: hidden;
  415. text-overflow: ellipsis;
  416. line-height: 1.5;
  417. }
  418. .row-addr .tag {
  419. font-size: 11px;
  420. color: #fff;
  421. padding: 1px 4px;
  422. border-radius: 4px;
  423. flex-shrink: 0;
  424. transform: scale(0.9);
  425. }
  426. .tag.pick {
  427. background: #409eff;
  428. }
  429. .tag.drop {
  430. background: #e6a23c;
  431. }
  432. .tag.home {
  433. background: #67c23a;
  434. }
  435. .row-time {
  436. font-size: 12px;
  437. color: #909399;
  438. display: flex;
  439. align-items: center;
  440. gap: 4px;
  441. }
  442. .days-tag {
  443. color: #f56c6c;
  444. background: #fef0f0;
  445. padding: 0 4px;
  446. border-radius: 4px;
  447. font-size: 11px;
  448. border: 1px solid #fde2e2;
  449. transform: scale(0.95);
  450. }
  451. .dispatch-order-info {
  452. background: #f5f7fa;
  453. padding: 10px;
  454. border-radius: 4px;
  455. margin-bottom: 20px;
  456. border: 1px solid #e4e7ed;
  457. display: block;
  458. }
  459. .dispatch-rider-select .select-header {
  460. display: flex;
  461. justify-content: space-between;
  462. align-items: center;
  463. margin-bottom: 10px;
  464. }
  465. .dispatch-rider-select .tit {
  466. font-weight: bold;
  467. font-size: 14px;
  468. }
  469. .rider-scroll {
  470. max-height: 45vh;
  471. }
  472. .rider-grid {
  473. display: grid;
  474. grid-template-columns: repeat(2, 1fr);
  475. gap: 12px;
  476. padding-right: 10px;
  477. }
  478. .rider-pagination {
  479. margin-top: 10px;
  480. }
  481. .rider-card.select-card {
  482. cursor: pointer;
  483. border: 1px solid #dcdfe6;
  484. position: relative;
  485. transition: all 0.2s;
  486. margin-bottom: 0;
  487. }
  488. .rider-card.select-card:hover {
  489. border-color: #409eff;
  490. }
  491. .rider-card.select-card.active {
  492. border-color: #409eff;
  493. background-color: #ecf5ff;
  494. }
  495. .selected-mark {
  496. position: absolute;
  497. top: 0;
  498. right: 0;
  499. background: #409eff;
  500. color: #fff;
  501. border-bottom-left-radius: 6px;
  502. width: 20px;
  503. height: 20px;
  504. display: flex;
  505. align-items: center;
  506. justify-content: center;
  507. font-size: 12px;
  508. }
  509. .rider-card .card-left .dot {
  510. position: absolute;
  511. bottom: 0;
  512. right: 0;
  513. width: 10px;
  514. height: 10px;
  515. border-radius: 50%;
  516. border: 2px solid #fff;
  517. }
  518. .dot.online {
  519. background: #67c23a;
  520. }
  521. .dot.busy {
  522. background: #409eff;
  523. }
  524. .dot.offline {
  525. background: #909399;
  526. }
  527. .r-name {
  528. font-weight: bold;
  529. font-size: 14px;
  530. color: #303133;
  531. margin-right: 8px;
  532. }
  533. .r-phone {
  534. font-size: 12px;
  535. color: #909399;
  536. }
  537. .status-badge {
  538. font-size: 11px;
  539. padding: 2px 6px;
  540. border-radius: 4px;
  541. display: inline-block;
  542. font-weight: bold;
  543. }
  544. .status-badge.online {
  545. background: #f0f9eb;
  546. color: #67c23a;
  547. }
  548. .status-badge.busy {
  549. background: #ecf5ff;
  550. color: #409eff;
  551. }
  552. .status-badge.offline {
  553. background: #f4f4f5;
  554. color: #909399;
  555. }
  556. .cat-tag {
  557. background: #f4f4f5;
  558. color: #909399;
  559. font-size: 10px;
  560. padding: 1px 4px;
  561. border-radius: 2px;
  562. margin-right: 4px;
  563. }
  564. .cat-tag.cat-transport {
  565. background: #e6f7ff;
  566. color: #1890ff;
  567. border: 1px solid #91d5ff;
  568. }
  569. .cat-tag.cat-feeding {
  570. background: #f6ffed;
  571. color: #52c41a;
  572. border: 1px solid #b7eb8f;
  573. }
  574. .cat-tag.cat-washing {
  575. background: #fff0f6;
  576. color: #eb2f96;
  577. border: 1px solid #ffadd2;
  578. }
  579. .last-time {
  580. font-size: 11px;
  581. color: #999;
  582. }
  583. .empty-text {
  584. text-align: center;
  585. color: #909399;
  586. padding: 20px;
  587. width: 100%;
  588. grid-column: span 2;
  589. }
  590. .dispatch-footer {
  591. margin-top: 20px;
  592. padding-top: 20px;
  593. border-top: 1px solid #ebeef5;
  594. display: flex;
  595. justify-content: space-between;
  596. align-items: center;
  597. }
  598. .dispatch-footer .fee-input {
  599. display: flex;
  600. align-items: center;
  601. gap: 8px;
  602. font-size: 14px;
  603. }
  604. </style>