OrderDetailDrawer.vue 36 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027
  1. <template>
  2. <el-drawer v-model="drawerVisible" title="订单详情" direction="rtl" size="60%" class="order-detail-drawer">
  3. <div class="detail-container" v-if="order">
  4. <!-- 1. Header Status -->
  5. <div class="detail-header">
  6. <div class="left-head">
  7. <span class="order-no">{{ order.orderNo }}</span>
  8. <el-tag :type="getStatusTag(order.status)" effect="dark" class="status-tag">{{
  9. getStatusName(order.status) }}</el-tag>
  10. <el-tag effect="plain" class="type-tag"
  11. :type="order.type === 'transport' ? '' : (order.type === 'feeding' ? 'warning' : 'danger')">
  12. {{ getTypeName(order.type) }}
  13. </el-tag>
  14. </div>
  15. <div class="right-head">
  16. <!-- Action Buttons Group -->
  17. <div class="detail-actions">
  18. <!-- <template v-if="[0, 1, 2].includes(order.status)">-->
  19. <!-- <el-button type="success" icon="Bicycle" @click="emit('dispatch', order)">-->
  20. <!-- {{ order.fulfiller || order.fulfillerName ? '重新派单' : '立即派单' }}-->
  21. <!-- </el-button>-->
  22. <!-- </template>-->
  23. <template v-if="order.status === 0">
  24. <el-button v-hasPermi="['order:management:cancel']" type="danger" plain icon="CircleClose"
  25. @click="emit('cancel', order)">取消订单</el-button>
  26. </template>
  27. <template v-if="order.status === 3">
  28. <el-button type="primary" icon="CircleCheck"
  29. @click="emit('command', 'complete', order)">确认完成</el-button>
  30. </template>
  31. <template v-if="[3, 4].includes(order.status)">
  32. <el-button v-hasPermi="['order:management:nursingSummary']" icon="Notebook"
  33. @click="emit('care-summary', order)">护理小结</el-button>
  34. </template>
  35. <el-dropdown trigger="click" @command="(cmd) => emit('command', cmd, order)"
  36. style="margin-left: 12px;">
  37. <el-button icon="More">更多操作</el-button>
  38. <template #dropdown>
  39. <el-dropdown-menu>
  40. <el-dropdown-item v-hasPermi="['order:management:reward']" command="reward"
  41. icon="Trophy">奖惩操作</el-dropdown-item>
  42. <el-dropdown-item v-hasPermi="['order:management:remark']" command="remark"
  43. icon="EditPen">订单备注</el-dropdown-item>
  44. <el-dropdown-item command="delete" v-if="[5, 4].includes(order.status)" divided
  45. icon="Delete" style="color: #f56c6c;">删除订单</el-dropdown-item>
  46. </el-dropdown-menu>
  47. </template>
  48. </el-dropdown>
  49. </div>
  50. </div>
  51. </div>
  52. <div class="detail-scroll-area">
  53. <!-- 2. Progress Section -->
  54. <div class="progress-section">
  55. <el-steps :active="currentOrderSteps.active" finish-status="success" align-center
  56. class="custom-steps">
  57. <el-step v-for="(step, index) in currentOrderSteps.steps" :key="index" :title="step.title"
  58. :description="step.time" />
  59. </el-steps>
  60. </div>
  61. <!-- 3. Top Info: Pet & User -->
  62. <div class="top-info-row">
  63. <!-- Left: Pet Info -->
  64. <div class="info-section pet-section" style="cursor: pointer" @click="petDetailVisible = true">
  65. <div class="sec-header">
  66. <span class="label">宠物档案</span>
  67. <el-tag size="small" effect="plain">{{ order.petBreed }}</el-tag>
  68. </div>
  69. <div class="pet-basic-row">
  70. <el-avatar :size="50" :src="order.petAvatar" shape="square" class="pet-avatar-lg">{{
  71. (order.petName || '').charAt(0) }}</el-avatar>
  72. <div class="pet-names">
  73. <div class="b-name">{{ order.petName }}
  74. <el-icon v-if="order.petGender === 'male'" color="#409eff">
  75. <Male />
  76. </el-icon>
  77. <el-icon v-else color="#f56c6c">
  78. <Female />
  79. </el-icon>
  80. </div>
  81. <div class="b-tags">
  82. <el-tag size="small" type="info">{{ order.petAge || '未知年龄' }}</el-tag>
  83. <el-tag size="small" type="info">{{ order.petWeight || '未知体重' }}</el-tag>
  84. </div>
  85. </div>
  86. </div>
  87. <el-descriptions :column="2" size="small" class="pet-desc" border>
  88. <el-descriptions-item label="品种">{{ order.petBreed || '未知' }}</el-descriptions-item>
  89. <el-descriptions-item label="疫苗状态"><span style="color:#67c23a">{{ order.petVaccine || '未知'
  90. }}</span></el-descriptions-item>
  91. <el-descriptions-item label="性格特点">{{ order.petCharacter || '温顺' }}</el-descriptions-item>
  92. <el-descriptions-item label="健康状况">{{ order.petHealth || '健康' }}</el-descriptions-item>
  93. </el-descriptions>
  94. </div>
  95. <!-- Right: User Info -->
  96. <div class="info-section user-section">
  97. <div class="sec-header">
  98. <span class="label">用户信息</span>
  99. </div>
  100. <div class="user-content">
  101. <div class="u-row">
  102. <el-avatar :size="40" :src="order.userAvatar">{{ (order.userName || '').charAt(0)
  103. }}</el-avatar>
  104. <div class="u-info">
  105. <div class="nm">{{ order.userName }}</div>
  106. <div class="ph">{{ order.contactPhone }}</div>
  107. </div>
  108. </div>
  109. <div class="addr-box">
  110. <div class="addr-label">服务地址</div>
  111. <div class="addr-txt">{{ order.city }}{{ order.district }} {{ order.address || '' }}
  112. </div>
  113. </div>
  114. </div>
  115. </div>
  116. </div>
  117. <!-- 4. Bottom Tabs -->
  118. <el-tabs v-model="activeDetailTab" class="detail-tabs type-card">
  119. <!-- Tab 1: Basic Info -->
  120. <el-tab-pane label="订单基础信息" name="basic">
  121. <div class="tab-pane-content">
  122. <div class="section-block">
  123. <div class="sec-title-bar">基础业务信息</div>
  124. <el-descriptions :column="3" border size="default" class="custom-desc">
  125. <el-descriptions-item label="系统单号">{{ order.orderNo }}</el-descriptions-item>
  126. <el-descriptions-item label="服务类型">
  127. {{ getTypeName(order.type) }}
  128. <el-tag size="small" v-if="order.type === 'transport'" style="margin-left:5px"
  129. effect="light">{{ getTransportModeName(order.transportType) }}</el-tag>
  130. </el-descriptions-item>
  131. <el-descriptions-item label="归属门店">{{ order.merchantName }}
  132. ({{ Number(order.platformId) === 1 ? '门店下单' : '平台代下单' }})</el-descriptions-item>
  133. <el-descriptions-item label="宠主信息">{{ order.userName }} / {{ order.contactPhone
  134. }}</el-descriptions-item>
  135. <el-descriptions-item label="服务费用" label-class-name="money-label">
  136. <span style="color:#f56c6c; font-weight:bold;">¥ {{ order.fulfillerFee }}</span>
  137. </el-descriptions-item>
  138. <el-descriptions-item label="预约时间">{{ getServiceTimeRange(order.serviceTime)
  139. }}</el-descriptions-item>
  140. <el-descriptions-item label="团购套餐">{{ order.groupBuyPackage || '未使用团购套餐'
  141. }}</el-descriptions-item>
  142. <el-descriptions-item label="创建时间">{{ order.createTime }}</el-descriptions-item>
  143. <el-descriptions-item label="订单备注" :span="3">
  144. {{ order.remark || '暂无备注' }}
  145. </el-descriptions-item>
  146. </el-descriptions>
  147. </div>
  148. <div v-if="order.type === 'transport'" class="section-block transport-split-block">
  149. <div class="sec-title-bar">接送任务详情</div>
  150. <div class="transport-one">
  151. <div class="t-row">
  152. <el-tag size="small" effect="plain" class="sub-badge">{{
  153. getTransportLabel(order.subOrderType) }}</el-tag>
  154. <span class="time">{{ order.serviceTime }}</span>
  155. </div>
  156. <div class="t-row">
  157. <span class="t-k">起点</span>
  158. <span class="t-v">{{ order.detail?.fromAddress || order.detail?.pickAddr || '--'
  159. }}</span>
  160. </div>
  161. <div class="t-row">
  162. <span class="t-k">终点</span>
  163. <span class="t-v">{{ order.detail?.toAddress || order.detail?.dropAddr ||
  164. order.toAddress ||
  165. '--' }}</span>
  166. </div>
  167. <div class="t-row sub">
  168. <span class="t-v">{{ order.contact || order.userName || '--' }} {{
  169. order.contactPhoneNumber
  170. || order.contactPhone || '--' }}</span>
  171. </div>
  172. </div>
  173. </div>
  174. <div v-if="['feeding', 'washing'].includes(order.type)" class="section-block">
  175. <div class="sec-title-bar">服务执行要求</div>
  176. <el-descriptions :column="2" border size="default" class="custom-desc">
  177. <el-descriptions-item label="服务地址" :span="2">{{ order.detail?.area || order.address
  178. }}</el-descriptions-item>
  179. </el-descriptions>
  180. </div>
  181. </div>
  182. </el-tab-pane>
  183. <!-- Tab 2: Fulfiller Info -->
  184. <el-tab-pane label="指派履约者" name="fulfiller">
  185. <div class="tab-pane-content">
  186. <div v-if="order.fulfillerName" class="fulfiller-card">
  187. <div class="f-left">
  188. <el-avatar :size="60" :src="order.fulfillerAvatar">{{ order.fulfillerName.charAt(0)
  189. }}</el-avatar>
  190. </div>
  191. <div class="f-right">
  192. <div class="f-row1">
  193. <span class="f-name">{{ order.fulfillerName }}</span>
  194. <el-tag size="small" type="primary" effect="plain" round>Lv1 普通</el-tag>
  195. </div>
  196. <div class="f-row2">
  197. <span>联系电话:{{ order.fulfillerPhone || '138****0000' }}</span>
  198. <span class="sep">|</span>
  199. <span>归属区域:{{ order.fulfillerStation || '朝阳一站' }}</span>
  200. </div>
  201. <div class="f-row3"
  202. style="margin-top: 8px; font-size: 13px; color: #606266; background: #f9fafe; padding: 8px; border-radius: 4px; display: flex; gap: 20px;">
  203. <span><span style="color:#909399;">指派时间:</span>{{ order.createTime }}</span>
  204. <span><span style="color:#909399;">接单时间:</span>{{ order.detail?.receiveTime ||
  205. order.serviceTime }}</span>
  206. </div>
  207. </div>
  208. </div>
  209. <div v-else class="empty-state">
  210. <el-result icon="info" title="暂无履约者" sub-title="该订单尚未指派履约人员"></el-result>
  211. </div>
  212. </div>
  213. </el-tab-pane>
  214. <!-- Tab 3: Service Progress -->
  215. <el-tab-pane label="服务进度" name="service">
  216. <div class="tab-pane-content">
  217. <div v-if="serviceProgressSteps.length === 0" class="empty-progress"
  218. style="padding:40px; text-align:center; color:#909399;">
  219. <el-result icon="info" title="待接单" sub-title="履约者接单后将在此记录服务进度"></el-result>
  220. </div>
  221. <el-timeline style="padding: 10px 20px;" v-else>
  222. <el-timeline-item v-for="(step, index) in serviceProgressSteps" :key="index"
  223. :timestamp="step.time" placement="top" :color="step.color" :icon="step.icon"
  224. size="large">
  225. <div class="progress-card">
  226. <h4 class="p-title">{{ step.title }}</h4>
  227. <p class="p-desc">{{ step.desc }}</p>
  228. <div class="p-media" v-if="step.media && step.media.length">
  229. <div v-for="(item, i) in step.media" :key="i" class="media-item">
  230. <!-- 图片类型 -->
  231. <el-image v-if="item.type === 'image'" :src="item.url"
  232. :preview-src-list="step.media.filter(m => m.type === 'image').map(m => m.url)"
  233. fit="cover" class="p-img" :preview-teleported="true" />
  234. <!-- 视频类型 -->
  235. <div v-else-if="item.type === 'video'" class="p-video-box"
  236. @click="openVideoPreview(item.url)">
  237. <video :src="item.url" preload="metadata"
  238. class="p-img p-video"></video>
  239. <div class="play-icon-overlay">
  240. <el-icon>
  241. <VideoPlay />
  242. </el-icon>
  243. </div>
  244. </div>
  245. </div>
  246. </div>
  247. </div>
  248. </el-timeline-item>
  249. </el-timeline>
  250. </div>
  251. </el-tab-pane>
  252. <!-- Tab 4: Logs -->
  253. <el-tab-pane label="订单日志" name="logs">
  254. <div class="tab-pane-content">
  255. <div style="display: flex; justify-content: flex-end; margin-bottom: 15px;">
  256. <el-button type="primary" size="small" icon="Download"
  257. v-hasPermi="['order:management:queryExportExcel']"
  258. @click="handleExportLogs">导出日志Excel</el-button>
  259. </div>
  260. <el-timeline>
  261. <el-timeline-item v-for="(log, index) in (orderLogs || [])" :key="index"
  262. :timestamp="log.createTime || log.time || ''" :type="'primary'" :icon="undefined"
  263. placement="top">
  264. <div class="log-card">
  265. <div class="l-tit">{{ log.title }}</div>
  266. <div class="l-txt">{{ log.content }}</div>
  267. </div>
  268. </el-timeline-item>
  269. </el-timeline>
  270. </div>
  271. </el-tab-pane>
  272. <!-- Tab 5: Complaint Records -->
  273. <el-tab-pane label="投诉记录" name="complaints">
  274. <div class="tab-pane-content">
  275. <div v-if="complaintList.length === 0" class="empty-state">
  276. <el-result icon="success" title="暂无投诉" sub-title="该订单暂无投诉记录"></el-result>
  277. </div>
  278. <el-timeline v-else>
  279. <el-timeline-item v-for="(complaint, index) in complaintList" :key="index"
  280. :timestamp="complaint.createTime" placement="top" color="#f56c6c">
  281. <div class="log-card">
  282. <div class="l-tit">履约者:{{ complaint.fulfiller }}</div>
  283. <div class="l-txt">{{ complaint.reason }}</div>
  284. </div>
  285. </el-timeline-item>
  286. </el-timeline>
  287. </div>
  288. </el-tab-pane>
  289. </el-tabs>
  290. </div>
  291. <PetDetailDrawer v-model:visible="petDetailVisible" :pet-id="order?.pet || order?.petId" />
  292. </div>
  293. </el-drawer>
  294. <!-- 视频播放弹窗 -->
  295. <el-dialog v-model="videoPreview.visible" title="视频播放" width="800px" append-to-body @closed="videoPreview.url = ''">
  296. <div
  297. style="width: 100%; display: flex; justify-content: center; background: #000; border-radius: 4px; overflow: hidden;">
  298. <video v-if="videoPreview.url" :src="videoPreview.url" controls autoplay
  299. style="max-width: 100%; max-height: 70vh;"></video>
  300. </div>
  301. </el-dialog>
  302. </template>
  303. <script setup>
  304. import { ref, reactive, computed, watch, getCurrentInstance } from 'vue'
  305. import { ElMessage } from 'element-plus'
  306. import { getPet } from '@/api/archieves/pet'
  307. import { getCustomer } from '@/api/archieves/customer'
  308. import { listSubOrderLog, exportSubOrderLogUrl } from '@/api/order/subOrderLog/index'
  309. import { listComplaintByOrder } from '@/api/fulfiller/complaint'
  310. import PetDetailDrawer from '@/components/PetDetailDrawer/index.vue'
  311. // 视频判定辅助函数
  312. const isVideo = (url) => {
  313. if (!url) return false;
  314. const videoExts = ['.mp4', '.mov', '.avi', '.wmv', '.webm', '.ogg'];
  315. return videoExts.some(ext => String(url).toLowerCase().endsWith(ext));
  316. }
  317. // 视频预览状态
  318. const videoPreview = reactive({
  319. visible: false,
  320. url: ''
  321. })
  322. const openVideoPreview = (url) => {
  323. videoPreview.url = url;
  324. videoPreview.visible = true;
  325. }
  326. const { proxy } = getCurrentInstance()
  327. const props = defineProps({
  328. visible: Boolean,
  329. order: Object
  330. })
  331. const emit = defineEmits(['update:visible', 'dispatch', 'cancel', 'command', 'care-summary'])
  332. const drawerVisible = computed({
  333. get: () => props.visible,
  334. set: (val) => emit('update:visible', val)
  335. })
  336. const orderDetail = ref(null)
  337. const order = computed(() => orderDetail.value || props.order)
  338. const loadSeq = ref(0)
  339. const orderLogs = ref([])
  340. const fulfillerLogs = ref([])
  341. const complaintList = ref([])
  342. const loadOrderLogs = async (order) => {
  343. const id = order?.id
  344. if (!id) {
  345. orderLogs.value = []
  346. fulfillerLogs.value = []
  347. return
  348. }
  349. try {
  350. const res = await listSubOrderLog({ orderId: id })
  351. const list = res?.data?.data || res?.data || []
  352. const arr = Array.isArray(list) ? list : []
  353. orderLogs.value = arr.filter(i => Number(i?.logType) === 0)
  354. fulfillerLogs.value = arr.filter(i => Number(i?.logType) === 1)
  355. } catch {
  356. orderLogs.value = []
  357. fulfillerLogs.value = []
  358. }
  359. try {
  360. const complaintRes = await listComplaintByOrder(id)
  361. complaintList.value = complaintRes?.data || []
  362. } catch {
  363. complaintList.value = []
  364. }
  365. }
  366. const loadPetAndCustomer = async (order) => {
  367. const seq = ++loadSeq.value
  368. const next = { ...(order || {}) }
  369. const petId = next?.pet || next?.petId
  370. if (petId) {
  371. try {
  372. const res = await getPet(petId)
  373. const pet = res?.data
  374. if (pet) {
  375. next.petName = pet.name ?? next.petName
  376. next.petAvatar = pet.avatarUrl ?? next.petAvatar
  377. next.petGender = pet.gender ?? next.petGender
  378. next.petAge = (pet.age !== undefined && pet.age !== null) ? `${pet.age}岁` : next.petAge
  379. next.petWeight = (pet.weight !== undefined && pet.weight !== null) ? `${pet.weight}kg` : next.petWeight
  380. next.petBreed = pet.breed ?? next.petBreed
  381. next.petSterilized = (pet.isSterilized !== undefined && pet.isSterilized !== null)
  382. ? (Number(pet.isSterilized) === 1)
  383. : next.petSterilized
  384. next.petVaccine = pet.vaccineStatus ?? next.petVaccine
  385. next.petCharacter = pet.personality ?? next.petCharacter
  386. next.petHealth = pet.healthStatus ?? next.petHealth
  387. }
  388. } catch {
  389. }
  390. }
  391. const customerId = next?.customer || next?.customerId
  392. if (customerId) {
  393. try {
  394. const res = await getCustomer(customerId)
  395. const customer = res?.data
  396. if (customer) {
  397. next.userName = customer.name ?? next.userName
  398. next.userAvatar = customer.avatarUrl ?? next.userAvatar
  399. next.contactPhone = customer.phone ?? next.contactPhone
  400. next.city = customer.areaName ?? next.city
  401. next.address = customer.address ?? next.address
  402. }
  403. } catch {
  404. }
  405. }
  406. if (seq !== loadSeq.value) return
  407. orderDetail.value = next
  408. }
  409. watch(() => props.order, (val) => {
  410. if (!val) {
  411. orderDetail.value = null
  412. orderLogs.value = []
  413. fulfillerLogs.value = []
  414. return
  415. }
  416. loadPetAndCustomer(val)
  417. loadOrderLogs(val)
  418. }, { immediate: true, deep: true })
  419. const petDetailVisible = ref(false)
  420. const activeDetailTab = ref('basic')
  421. const getStatusName = (status) => {
  422. const map = { 0: '待派单', 1: '待接单', 2: '服务中', 3: '待商家确认', 4: '已完成', 5: '已取消' }
  423. return map[status] || '未知'
  424. }
  425. const getStatusTag = (status) => {
  426. const map = { 0: 'danger', 1: 'warning', 2: 'primary', 3: 'warning', 4: 'success', 5: 'info' }
  427. return map[status] || 'info'
  428. }
  429. const getTypeName = (type) => {
  430. const map = { transport: '宠物接送', feeding: '上门喂遛', washing: '上门洗护' }
  431. return map[type]
  432. }
  433. const getTransportModeName = (type) => {
  434. const map = { round: '往返接送', pick: '单程接(到店)', drop: '单程送(回家)' }
  435. return map[type] || '接送服务'
  436. }
  437. const getTransportLabel = (t) => {
  438. if (t === 0 || t === '0') return '接'
  439. if (t === 1 || t === '1') return '送'
  440. if (t === 2 || t === '2') return '单程接'
  441. if (t === 3 || t === '3') return '单程送'
  442. return '接送'
  443. }
  444. const getServiceTimeRange = (timeStr) => {
  445. if (!timeStr) return '--'
  446. try {
  447. if (timeStr.length < 16) return timeStr
  448. let timePart = timeStr.substring(11, 16)
  449. let [hh, mm] = timePart.split(':').map(Number)
  450. let endH = hh + 2
  451. if (endH >= 24) endH -= 24
  452. let endHStr = endH.toString().padStart(2, '0')
  453. return `${timeStr}-${endHStr}:${mm.toString().padStart(2, '0')}`
  454. } catch (e) {
  455. return timeStr
  456. }
  457. }
  458. const currentOrderSteps = computed(() => {
  459. if (!props.order) return { active: 0, steps: [] }
  460. const steps = [
  461. { title: '商户下单', status: 'created', time: '' },
  462. { title: '运营派单', status: 'dispatched', time: '' },
  463. { title: '履约接单', status: 'accepted', time: '' },
  464. { title: '服务中', status: 'serving', time: '' },
  465. { title: '待商家确认', status: 'confirming', time: '' },
  466. { title: '已完成', status: 'completed', time: '' }
  467. ]
  468. const logs = orderLogs.value || []
  469. const status = props.order.status
  470. let active = 0
  471. const findTime = (keyword) => {
  472. const log = logs.find(l => l.title.includes(keyword) || l.content.includes(keyword))
  473. return log ? log.time : ''
  474. }
  475. steps[0].time = props.order.createTime || findTime('下单') || findTime('创建')
  476. if (steps[0].time) active = 1
  477. if ([0].includes(status)) {
  478. steps[1].time = findTime('派单') || steps[0].time
  479. } else {
  480. steps[1].time = findTime('派单') || ''
  481. }
  482. if ([1, 2, 3, 4].includes(status)) active = 2
  483. steps[2].time = findTime('接单')
  484. if ([1].includes(status)) {
  485. steps[2].title = '待履约者接单'
  486. } else if ([2, 3, 4].includes(status)) {
  487. steps[2].title = '履约者已接单'
  488. active = 3
  489. }
  490. steps[3].time = findTime('到达') || findTime('出发')
  491. if ([2].includes(status)) {
  492. steps[3].title = '服务进行中'
  493. } else if ([3, 4].includes(status)) {
  494. steps[3].title = '服务已完成'
  495. active = 4
  496. }
  497. steps[4].time = findTime('等待商家确认') || findTime('待验收')
  498. if ([3].includes(status)) {
  499. steps[4].title = '待商家确认'
  500. } else if ([4].includes(status)) {
  501. steps[4].title = '商家已确认'
  502. active = 5
  503. }
  504. if (status === 4) {
  505. steps[5].time = findTime('完成')
  506. active = 6
  507. }
  508. if (status === 5) {
  509. return {
  510. active: 1,
  511. steps: [
  512. { title: '商户下单', time: steps[0].time },
  513. { title: '已取消', time: findTime('取消') || '订单已取消' }
  514. ]
  515. }
  516. }
  517. return { active, steps }
  518. })
  519. const serviceProgressSteps = computed(() => {
  520. const list = fulfillerLogs.value || []
  521. return list.map((i) => {
  522. const media = (i?.photoUrls || []).map(url => {
  523. const isVideo = url.toLowerCase().match(/\.(mp4|mov|avi|wmv|flv|mkv)$/)
  524. return {
  525. type: isVideo ? 'video' : 'image',
  526. url
  527. }
  528. })
  529. return {
  530. title: i?.title || '--',
  531. time: i?.createTime || '',
  532. icon: undefined,
  533. color: '#ff9900',
  534. desc: i?.content || '',
  535. media: media
  536. }
  537. })
  538. })
  539. const handleExportLogs = () => {
  540. const id = props.order?.id;
  541. if (!id) {
  542. ElMessage.warning('订单信息不完整,无法导出');
  543. return;
  544. }
  545. proxy?.download(
  546. exportSubOrderLogUrl(id),
  547. {},
  548. `OrderLogs_${props.order.orderNo}_${new Date().getTime()}.xlsx`
  549. );
  550. }
  551. </script>
  552. <style scoped>
  553. /* Detail Styles */
  554. .order-detail-drawer :deep(.el-drawer__body) {
  555. padding: 0 !important;
  556. }
  557. .detail-container {
  558. height: 100%;
  559. display: flex;
  560. flex-direction: column;
  561. background: #f5f7fa;
  562. }
  563. .detail-header {
  564. background: #fff;
  565. padding: 20px 24px;
  566. border-bottom: 1px solid #ebeef5;
  567. display: flex;
  568. justify-content: space-between;
  569. align-items: center;
  570. }
  571. .left-head {
  572. display: flex;
  573. align-items: center;
  574. gap: 12px;
  575. }
  576. .order-no {
  577. font-size: 20px;
  578. font-weight: bold;
  579. color: #303133;
  580. }
  581. .type-tag {
  582. font-weight: normal;
  583. }
  584. .detail-actions {
  585. display: flex;
  586. align-items: center;
  587. gap: 12px;
  588. }
  589. .detail-scroll-area {
  590. flex: 1;
  591. overflow-y: auto;
  592. padding: 20px 24px;
  593. }
  594. /* Progress */
  595. .progress-section {
  596. background: #fff;
  597. padding: 30px 20px 20px;
  598. border-radius: 8px;
  599. margin-bottom: 20px;
  600. box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
  601. }
  602. .custom-steps :deep(.el-step__title) {
  603. font-size: 13px;
  604. }
  605. /* Top Info Row */
  606. .top-info-row {
  607. display: flex;
  608. gap: 20px;
  609. margin-bottom: 20px;
  610. align-items: stretch;
  611. }
  612. .info-section {
  613. flex: 1;
  614. background: #fff;
  615. border-radius: 8px;
  616. padding: 15px;
  617. box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
  618. }
  619. .sec-header {
  620. display: flex;
  621. justify-content: space-between;
  622. align-items: center;
  623. margin-bottom: 15px;
  624. padding-bottom: 10px;
  625. border-bottom: 1px solid #f2f2f2;
  626. }
  627. .sec-header .label {
  628. font-weight: bold;
  629. font-size: 15px;
  630. color: #303133;
  631. border-left: 3px solid #409eff;
  632. padding-left: 8px;
  633. }
  634. /* Pet Section */
  635. .pet-basic-row {
  636. display: flex;
  637. gap: 15px;
  638. margin-bottom: 15px;
  639. align-items: center;
  640. }
  641. .pet-avatar-lg {
  642. border-radius: 8px;
  643. background: #ecf5ff;
  644. color: #409eff;
  645. font-size: 20px;
  646. font-weight: bold;
  647. }
  648. .pet-names {
  649. display: flex;
  650. flex-direction: column;
  651. gap: 6px;
  652. }
  653. .b-name {
  654. font-size: 18px;
  655. font-weight: bold;
  656. display: flex;
  657. align-items: center;
  658. gap: 6px;
  659. }
  660. .b-tags {
  661. display: flex;
  662. gap: 5px;
  663. }
  664. .pet-desc :deep(.el-descriptions__label) {
  665. width: 70px;
  666. }
  667. /* User Section */
  668. .u-row {
  669. display: flex;
  670. gap: 12px;
  671. align-items: center;
  672. margin-bottom: 12px;
  673. }
  674. .u-info .nm {
  675. font-weight: bold;
  676. font-size: 15px;
  677. color: #303133;
  678. display: flex;
  679. align-items: center;
  680. gap: 6px;
  681. }
  682. .u-info .ph {
  683. font-size: 13px;
  684. color: #909399;
  685. margin-top: 2px;
  686. }
  687. .addr-box {
  688. background: #fdf6ec;
  689. padding: 8px 10px;
  690. border-radius: 4px;
  691. margin-bottom: 10px;
  692. }
  693. .addr-label {
  694. font-size: 12px;
  695. color: #e6a23c;
  696. margin-bottom: 2px;
  697. font-weight: bold;
  698. }
  699. .addr-txt {
  700. font-size: 13px;
  701. color: #606266;
  702. line-height: 1.4;
  703. }
  704. /* Tabs */
  705. .detail-tabs {
  706. background: #fff;
  707. padding: 10px 20px;
  708. border-radius: 8px;
  709. box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
  710. min-height: 400px;
  711. }
  712. .tab-pane-content {
  713. padding: 15px 0;
  714. }
  715. /* Fulfiller Card inside Tab */
  716. .fulfiller-card {
  717. display: flex;
  718. align-items: center;
  719. gap: 20px;
  720. padding: 20px;
  721. background: #fff;
  722. border: 1px solid #ebeef5;
  723. border-radius: 8px;
  724. }
  725. .f-right {
  726. flex: 1;
  727. display: flex;
  728. flex-direction: column;
  729. gap: 8px;
  730. }
  731. .f-row1 {
  732. display: flex;
  733. align-items: center;
  734. gap: 10px;
  735. }
  736. .f-name {
  737. font-size: 18px;
  738. font-weight: bold;
  739. color: #303133;
  740. }
  741. .f-row2 {
  742. font-size: 13px;
  743. color: #606266;
  744. display: flex;
  745. gap: 10px;
  746. }
  747. .sep {
  748. color: #e4e7ed;
  749. }
  750. .empty-state {
  751. padding: 40px 0;
  752. text-align: center;
  753. }
  754. /* Progress Card Styles */
  755. .progress-card {
  756. background: #f8fcfb;
  757. border-radius: 8px;
  758. padding: 12px;
  759. border: 1px solid #ebeef5;
  760. }
  761. .p-title {
  762. margin: 0 0 8px;
  763. font-size: 15px;
  764. font-weight: bold;
  765. color: #303133;
  766. }
  767. .p-desc {
  768. margin: 0 0 12px;
  769. color: #606266;
  770. font-size: 13px;
  771. line-height: 1.5;
  772. }
  773. .p-media {
  774. display: flex;
  775. gap: 8px;
  776. flex-wrap: wrap;
  777. }
  778. .p-video {
  779. object-fit: cover;
  780. }
  781. .p-img {
  782. width: 80px;
  783. height: 80px;
  784. border-radius: 4px;
  785. border: 1px solid #e4e7ed;
  786. cursor: pointer;
  787. background: #f5f7fa;
  788. }
  789. .p-video-box {
  790. position: relative;
  791. width: 80px;
  792. height: 80px;
  793. cursor: pointer;
  794. }
  795. .play-icon-overlay {
  796. position: absolute;
  797. top: 50%;
  798. left: 50%;
  799. transform: translate(-50%, -50%);
  800. background: rgba(0, 0, 0, 0.4);
  801. color: #fff;
  802. width: 32px;
  803. height: 32px;
  804. border-radius: 50%;
  805. display: flex;
  806. align-items: center;
  807. justify-content: center;
  808. font-size: 18px;
  809. transition: all 0.2s;
  810. }
  811. .p-video-box:hover .play-icon-overlay {
  812. background: rgba(0, 0, 0, 0.6);
  813. transform: translate(-50%, -50%) scale(1.1);
  814. }
  815. /* New Transport Split Styles */
  816. .transport-split-block {
  817. margin-top: 20px;
  818. }
  819. .transport-grid {
  820. display: flex;
  821. gap: 20px;
  822. }
  823. .transport-card {
  824. flex: 1;
  825. border: 1px solid #ebeef5;
  826. border-radius: 6px;
  827. overflow: hidden;
  828. background: #fff;
  829. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02);
  830. }
  831. .transport-card .t-header {
  832. background: #f5f7fa;
  833. padding: 10px 15px;
  834. border-bottom: 1px solid #ebeef5;
  835. display: flex;
  836. justify-content: space-between;
  837. align-items: center;
  838. }
  839. .transport-card .t-header .time {
  840. font-size: 13px;
  841. font-weight: bold;
  842. color: #f56c6c;
  843. }
  844. .transport-card .t-body {
  845. padding: 15px;
  846. display: flex;
  847. flex-direction: column;
  848. gap: 10px;
  849. }
  850. .transport-card .row {
  851. display: flex;
  852. align-items: flex-start;
  853. gap: 8px;
  854. font-size: 14px;
  855. color: #303133;
  856. line-height: 1.4;
  857. }
  858. .transport-card .row.sub {
  859. color: #909399;
  860. font-size: 13px;
  861. margin-top: 4px;
  862. }
  863. .transport-card .row .el-icon {
  864. margin-top: 3px;
  865. }
  866. .transport-one {
  867. border: 1px solid #ebeef5;
  868. border-radius: 6px;
  869. background: #fff;
  870. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02);
  871. padding: 14px 16px;
  872. display: flex;
  873. flex-direction: column;
  874. gap: 10px;
  875. }
  876. .transport-one .t-row {
  877. display: flex;
  878. align-items: baseline;
  879. gap: 10px;
  880. }
  881. .transport-one .t-row .time {
  882. font-size: 13px;
  883. font-weight: bold;
  884. color: #f56c6c;
  885. }
  886. .transport-one .t-k {
  887. width: 40px;
  888. color: #909399;
  889. font-size: 13px;
  890. }
  891. .transport-one .t-v {
  892. color: #303133;
  893. font-size: 14px;
  894. line-height: 1.4;
  895. }
  896. .transport-one .t-row.sub .t-v {
  897. color: #909399;
  898. font-size: 13px;
  899. }
  900. /* Logs */
  901. .log-card {
  902. background: #f4f4f5;
  903. padding: 10px 15px;
  904. border-radius: 4px;
  905. position: relative;
  906. top: -5px;
  907. width: 100%;
  908. }
  909. .l-tit {
  910. font-weight: bold;
  911. font-size: 14px;
  912. margin-bottom: 4px;
  913. color: #303133;
  914. }
  915. .l-txt {
  916. font-size: 13px;
  917. color: #606266;
  918. line-height: 1.5;
  919. }
  920. </style>