Huanyi 3 недель назад
Родитель
Сommit
a84f088048

+ 39 - 0
src/api/fulfiller/complaint/index.ts

@@ -0,0 +1,39 @@
+import request from '@/utils/request';
+import { AxiosPromise } from 'axios';
+import type { ComplaintForm, ComplaintVO, ComplaintPageVO } from './types';
+
+/**
+ * 新增投诉
+ * @param data
+ */
+export const addComplaint = (data: ComplaintForm): AxiosPromise<void> => {
+  return request({
+    url: '/fulfiller/complaint/add',
+    method: 'post',
+    data: data
+  });
+};
+
+/**
+ * 根据订单查询投诉记录
+ * @param orderId
+ */
+export const listComplaintByOrder = (orderId: string | number): AxiosPromise<ComplaintVO[]> => {
+  return request({
+    url: '/fulfiller/complaint/listByOrder',
+    method: 'get',
+    params: { orderId }
+  });
+};
+
+/**
+ * 根据履约者分页查询投诉记录
+ * @param params
+ */
+export const pageComplaintByFulfiller = (params: { fulfiller: string | number; pageNum: number; pageSize: number }): AxiosPromise<{ total: number; rows: ComplaintPageVO[] }> => {
+  return request({
+    url: '/fulfiller/complaint/pageByFulfiller',
+    method: 'get',
+    params
+  });
+};

+ 30 - 0
src/api/fulfiller/complaint/types.ts

@@ -0,0 +1,30 @@
+export interface ComplaintForm {
+  id?: string | number;
+  fulfiller: string | number;
+  orderId: string | number;
+  reason: string;
+  createDept?: string | number;
+  createBy?: string | number;
+  createTime?: string;
+  updateBy?: string | number;
+  updateTime?: string;
+  params?: any;
+}
+
+export interface ComplaintVO {
+  id: string | number;
+  fulfiller: string;
+  orderId: string | number;
+  reason: string;
+  createTime: string;
+}
+
+export interface ComplaintPageVO {
+  id: string | number;
+  fulfiller: string | number;
+  orderCode: string;
+  orderId: string | number;
+  reason: string;
+  createTime: string;
+  createBy: string | number;
+}

+ 8 - 0
src/api/order/subOrderLog/index.ts

@@ -20,3 +20,11 @@ export const exportSubOrderLog = (orderId: string | number) => {
         method: 'post'
     });
 };
+
+/**
+ * 导出订单日志 URL
+ * @param orderId 订单ID
+ */
+export const exportSubOrderLogUrl = (orderId: string | number) => {
+    return '/order/subOrderLog/export/' + orderId;
+};

+ 4 - 12
src/api/service/list/index.ts

@@ -1,22 +1,14 @@
 import request from '@/utils/request';
-import { ServiceListVO, ServiceOrderVO } from './types';
+import { ServiceVO } from './types';
 import { AxiosPromise } from 'axios';
 
-// 获取可用服务项目列表
-export function listOnStore(): AxiosPromise<ServiceListVO[]> {
-  return request({
-    url: '/service/list/listOnStore',
-    method: 'get'
-  });
-}
-
 /**
- * 查询下单页的服务列表
+ * 获取所有服务列表
  * @returns {*}
  */
-export const listServiceOnOrder = (): AxiosPromise<ServiceOrderVO[]> => {
+export const listAllService = (): AxiosPromise<ServiceVO[]> => {
   return request({
-    url: '/service/list/listOnOrder',
+    url: '/service/list/listAll',
     method: 'get'
   });
 };

+ 12 - 0
src/api/service/list/types.ts

@@ -10,3 +10,15 @@ export interface ServiceOrderVO {
   icon: string;
   mode: number;
 }
+
+export interface ServiceVO {
+  id: number;
+  name: string;
+  icon: string | number;
+  iconUrl: string;
+  mode: number;
+  sort: number;
+  remark: string;
+  createTime: string;
+  clockInRemark: string;
+}

+ 2 - 3
src/components/CustomerDetailDrawer/index.vue

@@ -25,7 +25,6 @@
           <el-descriptions-item label="所属区域">{{ currentUser.areaName || '-' }}</el-descriptions-item>
           <el-descriptions-item label="所属站点">{{ currentUser.stationName || '-' }}</el-descriptions-item>
           <el-descriptions-item label="所属品牌">{{ currentUser.tenantName || '-' }}</el-descriptions-item>
-          <el-descriptions-item label="录入来源">{{ currentUser.source || '-' }}</el-descriptions-item>
           <el-descriptions-item label="录入时间">{{ currentUser.createTime || '-' }}</el-descriptions-item>
         </el-descriptions>
 
@@ -126,7 +125,7 @@ import { listPetByUser } from '@/api/archieves/pet';
 import { listAllChangeLog } from '@/api/archieves/changeLog';
 import { listOnStore } from '@/api/system/areaStation';
 import { listSubOrderOnCustomer } from '@/api/order/subOrder/index';
-import { listServiceOnOrder } from '@/api/service/list/index';
+import { listAllService } from '@/api/service/list/index';
 
 const props = defineProps({
   visible: {
@@ -162,7 +161,7 @@ const detailActiveTab = ref('info');
 const allNodes = ref([]);
 
 const getServiceList = () => {
-  listServiceOnOrder().then((res) => {
+  listAllService().then((res) => {
     serviceOptions.value = res.data || [];
   });
 };

+ 2 - 2
src/components/PetDetailDrawer/index.vue

@@ -120,7 +120,7 @@ import { ref, computed, watch, onMounted, getCurrentInstance, toRefs } from 'vue
 import { getPet, updatePet } from '@/api/archieves/pet'
 import { listAllChangeLog } from '@/api/archieves/changeLog'
 import { listSubOrderOnPet } from '@/api/order/subOrder/index'
-import { listServiceOnOrder } from '@/api/service/list/index'
+import { listAllService } from '@/api/service/list/index'
 import { ElMessage } from 'element-plus'
 
 const props = defineProps({
@@ -159,7 +159,7 @@ const remarkDialogVisible = ref(false)
 const remarkContent = ref('')
 
 const getServiceList = () => {
-  listServiceOnOrder().then((res) => {
+  listAllService().then((res) => {
     serviceOptions.value = res.data || []
   })
 }

+ 4 - 0
src/enums/fulfiller.json

@@ -23,6 +23,10 @@
     "order_finish": {
       "label": "订单完成",
       "tagType": "info"
+    },
+    "violation_punish": {
+      "label": "违规惩罚",
+      "tagType": "danger"
     }
   },
   "BalanceBizType": {

+ 1 - 1
src/views/merchant/storeInfo/index.vue

@@ -91,7 +91,7 @@
 <script setup>
 import { reactive, ref, onMounted, computed } from 'vue';
 import { listOnMerchantStoreInfo, getStoreInfo } from '@/api/system/store';
-import { listOnStore } from '@/api/service/list';
+import { listAllService as listOnStore } from '@/api/service/list';
 import { regionData } from 'element-china-area-data';
 
 const currentStoreId = ref('');

+ 1 - 1
src/views/merchant/storeManagement/index.vue

@@ -312,7 +312,7 @@ import { listStore, getStore, delStore, addStore, updateStore, listStoreStatus,
 import { StoreVO, StoreQuery, StoreForm, StoreStatusVO, SysStorePageBo, StoreRenewReq } from '@/api/system/store/types';
 import { listOnStore } from '@/api/system/tenant';
 import { listOnStore as listTenantCategoriesOnStore } from '@/api/system/tenantCategories';
-import { listOnStore as listServiceOnStore } from '@/api/service/list';
+import { listAllService as listServiceOnStore } from '@/api/service/list';
 import { listSubOrderOnStore } from '@/api/order/subOrder';
 import { SubOrderStoreVO } from '@/api/order/subOrder/types';
 import { listOnStore as listAreaStationOnStore } from '@/api/system/areaStation';

+ 2 - 2
src/views/order/management/components/DispatchDialog.vue

@@ -159,7 +159,7 @@ 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 { listAllService } from '@/api/service/list/index'
 import CustomerDetailDrawer from '@/components/CustomerDetailDrawer/index.vue'
 import PetDetailDrawer from './PetDetailDrawer.vue'
 
@@ -216,7 +216,7 @@ const serviceOptions = ref([])
 const loadServiceOptions = async () => {
     if (serviceOptions.value.length > 0) return
     try {
-        const res = await listServiceOnOrder()
+        const res = await listAllService()
         serviceOptions.value = res?.data || []
     } catch { /* ignore */ }
 }

+ 114 - 22
src/views/order/management/components/OrderDetailDrawer.vue

@@ -240,14 +240,24 @@
                                         <h4 class="p-title">{{ step.title }}</h4>
                                         <p class="p-desc">{{ step.desc }}</p>
                                         <div class="p-media" v-if="step.media && step.media.length">
-                                            <div v-for="(item, i) in step.media" :key="i" class="media-item">
-                                                <el-image v-if="item.type === 'image'" :src="item.url"
-                                                    :preview-src-list="step.media.filter(m => m.type === 'image').map(m => m.url)"
-                                                    fit="cover" class="p-img" :preview-teleported="true" />
-                                                <video v-else-if="item.type === 'video'" :src="item.url" controls
-                                                    class="p-video"></video>
-                                            </div>
-                                        </div>
+                                             <div v-for="(item, i) in step.media" :key="i" class="media-item">
+                                                 <!-- 图片类型 -->
+                                                 <el-image v-if="item.type === 'image'" :src="item.url"
+                                                     :preview-src-list="step.media.filter(m => m.type === 'image').map(m => m.url)"
+                                                     fit="cover" class="p-img" :preview-teleported="true" />
+
+                                                 <!-- 视频类型 -->
+                                                 <div v-else-if="item.type === 'video'" class="p-video-box"
+                                                     @click="openVideoPreview(item.url)">
+                                                     <video :src="item.url" preload="metadata" class="p-img p-video"></video>
+                                                     <div class="play-icon-overlay">
+                                                         <el-icon>
+                                                             <VideoPlay />
+                                                         </el-icon>
+                                                     </div>
+                                                 </div>
+                                             </div>
+                                         </div>
                                     </div>
                                 </el-timeline-item>
                             </el-timeline>
@@ -262,8 +272,9 @@
                                     @click="handleExportLogs">导出日志Excel</el-button>
                             </div>
                             <el-timeline>
-                                <el-timeline-item v-for="(log, index) in (orderLogs || [])" :key="index" :timestamp="''"
-                                    :type="'primary'" :icon="undefined" placement="top">
+                                <el-timeline-item v-for="(log, index) in (orderLogs || [])" :key="index"
+                                    :timestamp="log.createTime || log.time || ''" :type="'primary'" :icon="undefined"
+                                    placement="top">
                                     <div class="log-card">
                                         <div class="l-tit">{{ log.title }}</div>
                                         <div class="l-txt">{{ log.content }}</div>
@@ -272,21 +283,67 @@
                             </el-timeline>
                         </div>
                     </el-tab-pane>
+
+                    <!-- Tab 5: Complaint Records -->
+                    <el-tab-pane label="投诉记录" name="complaints">
+                        <div class="tab-pane-content">
+                            <div v-if="complaintList.length === 0" class="empty-state">
+                                <el-result icon="success" title="暂无投诉" sub-title="该订单暂无投诉记录"></el-result>
+                            </div>
+                            <el-timeline v-else>
+                                <el-timeline-item v-for="(complaint, index) in complaintList" :key="index"
+                                    :timestamp="complaint.createTime" placement="top" color="#f56c6c">
+                                    <div class="log-card">
+                                        <div class="l-tit">履约者:{{ complaint.fulfiller }}</div>
+                                        <div class="l-txt">{{ complaint.reason }}</div>
+                                    </div>
+                                </el-timeline-item>
+                            </el-timeline>
+                        </div>
+                    </el-tab-pane>
                 </el-tabs>
             </div>
             <PetDetailDrawer v-model:visible="petDetailVisible" :pet-id="order?.pet || order?.petId" />
         </div>
     </el-drawer>
+
+    <!-- 视频播放弹窗 -->
+    <el-dialog v-model="videoPreview.visible" title="视频播放" width="800px" append-to-body @closed="videoPreview.url = ''">
+        <div
+            style="width: 100%; display: flex; justify-content: center; background: #000; border-radius: 4px; overflow: hidden;">
+            <video v-if="videoPreview.url" :src="videoPreview.url" controls autoplay
+                style="max-width: 100%; max-height: 70vh;"></video>
+        </div>
+    </el-dialog>
 </template>
 
 <script setup>
-import { ref, computed, watch, getCurrentInstance } from 'vue'
+import { ref, reactive, computed, watch, getCurrentInstance } from 'vue'
 import { ElMessage } from 'element-plus'
 import { getPet } from '@/api/archieves/pet'
 import { getCustomer } from '@/api/archieves/customer'
-import { listSubOrderLog, exportSubOrderLog } from '@/api/order/subOrderLog'
+import { listSubOrderLog, exportSubOrderLogUrl } from '@/api/order/subOrderLog/index'
+import { listComplaintByOrder } from '@/api/fulfiller/complaint'
 import PetDetailDrawer from '@/components/PetDetailDrawer/index.vue'
 
+// 视频判定辅助函数
+const isVideo = (url) => {
+    if (!url) return false;
+    const videoExts = ['.mp4', '.mov', '.avi', '.wmv', '.webm', '.ogg'];
+    return videoExts.some(ext => String(url).toLowerCase().endsWith(ext));
+}
+
+// 视频预览状态
+const videoPreview = reactive({
+    visible: false,
+    url: ''
+})
+
+const openVideoPreview = (url) => {
+    videoPreview.url = url;
+    videoPreview.visible = true;
+}
+
 const { proxy } = getCurrentInstance()
 
 const props = defineProps({
@@ -308,6 +365,7 @@ const loadSeq = ref(0)
 
 const orderLogs = ref([])
 const fulfillerLogs = ref([])
+const complaintList = ref([])
 
 const loadOrderLogs = async (order) => {
     const id = order?.id
@@ -326,6 +384,13 @@ const loadOrderLogs = async (order) => {
         orderLogs.value = []
         fulfillerLogs.value = []
     }
+    
+    try {
+        const complaintRes = await listComplaintByOrder(id)
+        complaintList.value = complaintRes?.data || []
+    } catch {
+        complaintList.value = []
+    }
 }
 
 const loadPetAndCustomer = async (order) => {
@@ -513,16 +578,16 @@ const serviceProgressSteps = computed(() => {
 })
 
 const handleExportLogs = () => {
-    const id = order.value?.id
+    const id = props.order?.id;
     if (!id) {
-        ElMessage.warning('订单号缺失,无法导出')
-        return
+        ElMessage.warning('订单信息不完整,无法导出');
+        return;
     }
     proxy?.download(
-        `order/subOrderLog/export/${id}`,
+        exportSubOrderLogUrl(id),
         {},
-        `OrderLogs_${order.value.orderNo}_${new Date().getTime()}.xlsx`
-    )
+        `OrderLogs_${props.order.orderNo}_${new Date().getTime()}.xlsx`
+    );
 }
 </script>
 
@@ -792,10 +857,7 @@ const handleExportLogs = () => {
 }
 
 .p-video {
-    width: 140px;
-    height: 80px;
-    border-radius: 4px;
-    background: #000;
+    object-fit: cover;
 }
 
 .p-img {
@@ -804,6 +866,36 @@ const handleExportLogs = () => {
     border-radius: 4px;
     border: 1px solid #e4e7ed;
     cursor: pointer;
+    background: #f5f7fa;
+}
+
+.p-video-box {
+    position: relative;
+    width: 80px;
+    height: 80px;
+    cursor: pointer;
+}
+
+.play-icon-overlay {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    background: rgba(0, 0, 0, 0.4);
+    color: #fff;
+    width: 32px;
+    height: 32px;
+    border-radius: 50%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 18px;
+    transition: all 0.2s;
+}
+
+.p-video-box:hover .play-icon-overlay {
+    background: rgba(0, 0, 0, 0.6);
+    transform: translate(-50%, -50%) scale(1.1);
 }
 
 /* New Transport Split Styles */

+ 2 - 2
src/views/order/management/index.vue

@@ -187,7 +187,7 @@ import CareSummaryDrawer from './components/CareSummaryDrawer.vue';
 import RewardDialog from './components/RewardDialog.vue';
 import RemarkDialog from './components/RemarkDialog.vue';
 import PetDetailDrawer from '@/components/PetDetailDrawer/index.vue';
-import { listServiceOnOrder } from '@/api/service/list/index';
+import { listAllService } from '@/api/service/list/index';
 import { listSubOrder, dispatchSubOrder, getSubOrderInfo, cancelSubOrder, remarkSubOrder, confirmSubOrder, nursingSummarySubOrder } from '@/api/order/subOrder/index';
 import { listOnStore as listAreaStationOnStore } from '@/api/system/areaStation';
 import { getStore } from '@/api/system/store';
@@ -227,7 +227,7 @@ onMounted(() => {
 });
 
 const getServiceList = () => {
-  listServiceOnOrder().then((res) => {
+  listAllService().then((res) => {
     serviceOptions.value = res.data || [];
   });
 };

+ 5 - 5
src/views/order/purchase/index.vue

@@ -94,11 +94,11 @@
           >
             <div class="icon-box">
               <img
-                v-if="item.icon && (item.icon.startsWith('http') || item.icon.startsWith('//') || item.icon.startsWith('/profile'))"
-                :src="item.icon"
+                v-if="item.iconUrl"
+                :src="item.iconUrl"
                 class="service-icon-img"
               />
-              <el-icon v-else-if="item.icon">
+              <el-icon v-else-if="typeof item.icon === 'string' && isNaN(Number(item.icon))">
                 <component :is="item.icon" />
               </el-icon>
               <el-icon v-else>
@@ -213,7 +213,7 @@ import AddUserDialog from './components/AddUserDialog.vue';
 import AddPetDialog from './components/AddPetDialog.vue';
 import PageSelect from '@/components/PageSelect/index.vue';
 import { listStoreOnOrder } from '@/api/system/store';
-import { listServiceOnOrder } from '@/api/service/list';
+import { listAllService } from '@/api/service/list/index';
 import { listCustomerOnOrder } from '@/api/archieves/customer';
 import { listPetByUser } from '@/api/archieves/pet';
 import { regionData as pcaOptions } from 'element-china-area-data';
@@ -744,7 +744,7 @@ const handleSubmit = async () => {
 // Initialize
 onMounted(() => {
   fetchStores();
-  listServiceOnOrder().then((res) => {
+  listAllService().then((res) => {
     allServices.value = res.data || [];
   });
 });