张田田 2 miesięcy temu
rodzic
commit
8584004731

+ 77 - 63
src/model/order.model.ts

@@ -7,43 +7,43 @@
 // }
 
 export interface OrderModel {
-id: number; //	线下服务ID
-patientConditioningProgramId: number; //患者调理方案id
-sequence: number; //次序
-liaison:string;//联系人
-phone: string; //联系电话
-provinceName: string; //省名称
-cityName: string; //市名称
-areaName: string; //区名称
-detailAddress: string; //详细地址
-orderNo: string; //订单编号
-institutionId: number; //订单所属机构ID
-institutionName: string; //订单所属机构名称
-conditioningProgramName: string; //项目名称
-conditioningProgramType: string; //项目类型
-applyTime:string;//用户操作时间
-offlineDuration:string;//	服务时长
-arrangeDate:string;//预约日期
-arrangeTime:string;//预约时间段-开始时间
-arrangeEndTime:string;//预约时间段-结束时间
-conditioningProgramSupplierId:number;//本次供应商ID
-conditioningProgramSupplierName:string;//本次供应商名称
-preConditioningProgramSupplierId:number;//上次供应商ID
-preConditioningProgramSupplierName:string;//上次供应商名称
-pieBy:string;//派单人
-pieTime:string;//派单时间
-operateBy:string;//操作者
-operateTime:string;//操作时间
-startTime:string;//操作开始时间
-endTime:string;//操作结束时间
-feedback:string;//治疗备注
-photo:string;//图片
-type:number;//订单类型 1-未派单 2-已派单但未核销 3-已核销
-// 实体商品
-receiptStatus:string;//收货状态 0-待发货 1-已发货 2-已收货
-types:string[];//订单类型 1-未派单 2-已派单但未核销 3-已核销	
-pieTimeStart:string;//派单时间开始
-pieTimeEnd:string;//派单时间结束
+  id: number; //	线下服务ID
+  patientConditioningProgramId: number; //患者调理方案id
+  sequence: number; //次序
+  liaison: string;//联系人
+  phone: string; //联系电话
+  provinceName: string; //省名称
+  cityName: string; //市名称
+  areaName: string; //区名称
+  detailAddress: string; //详细地址
+  orderNo: string; //订单编号
+  institutionId: number; //订单所属机构ID
+  institutionName: string; //订单所属机构名称
+  conditioningProgramName: string; //项目名称
+  conditioningProgramType: string; //项目类型
+  applyTime: string;//用户操作时间
+  offlineDuration: string;//	服务时长
+  arrangeDate: string;//预约日期
+  arrangeTime: string;//预约时间段-开始时间
+  arrangeEndTime: string;//预约时间段-结束时间
+  conditioningProgramSupplierId: number;//本次供应商ID
+  conditioningProgramSupplierName: string;//本次供应商名称
+  preConditioningProgramSupplierId: number;//上次供应商ID
+  preConditioningProgramSupplierName: string;//上次供应商名称
+  pieBy: string;//派单人
+  pieTime: string;//派单时间
+  operateBy: string;//操作者
+  operateTime: string;//操作时间
+  startTime: string;//操作开始时间
+  endTime: string;//操作结束时间
+  feedback: string;//治疗备注
+  photo: string;//图片
+  type: number;//订单类型 1-未派单 2-已派单但未核销 3-已核销
+  // 实体商品
+  receiptStatus: string;//收货状态 0-待发货 1-已发货 2-已收货
+  types: string[];//订单类型 1-未派单 2-已派单但未核销 3-已核销	
+  pieTimeStart: string;//派单时间开始
+  pieTimeEnd: string;//派单时间结束
 }
 export type OrderQuery = Partial<OrderModel>;
 //派单机构列表
@@ -90,23 +90,23 @@ export type OrderLiaisonListQuery = Partial<OrderLiaisonListModel>;
 
 
 
-export interface ShipmentModel{
+export interface ShipmentModel {
   id: number; //	患者调理方案ID
   orderNo: string; //订单ID
-  conditioningProgramSupplierId:number;//供应商ID
-  conditioningProgramSupplierName:string;//供应商名称
-  payTimeStart:string;//付款时间——起始时间	
-  payTimeEnd:string;//付款时间——截止时间
+  conditioningProgramSupplierId: number;//供应商ID
+  conditioningProgramSupplierName: string;//供应商名称
+  payTimeStart: string;//付款时间——起始时间	
+  payTimeEnd: string;//付款时间——截止时间
   payTime: string; //订单编号
   conditioningProgramName: string; //调理方案名称
   pricingUnit: string; //计价单位
   unitPrice: number; //单价
-  totalMeasure:number;//总用量
-  totalPrice:number;//总价格
-  receiptStatus:string;//发货状态 0-待发货 1-已发货 2-已收货
-  receiptType:string;//	发货形式 0-配送 1-线下取货
-  expressType:string;//快递类型 0-邮政速递 1-顺丰速运 2-京东快递 3-中通快递 4-圆通速递 5-申通快递 6-韵达快递 7-极兔速递
-  expressNo:string;//快递单号
+  totalMeasure: number;//总用量
+  totalPrice: number;//总价格
+  receiptStatus: string;//发货状态 0-待发货 1-已发货 2-已收货
+  receiptType: string;//	发货形式 0-配送 1-线下取货
+  expressType: string;//快递类型 0-邮政速递 1-顺丰速运 2-京东快递 3-中通快递 4-圆通速递 5-申通快递 6-韵达快递 7-极兔速递
+  expressNo: string;//快递单号
 
 }
 export type ShipmentQuery = Partial<ShipmentModel>;
@@ -128,23 +128,37 @@ export interface RevenueSharingDetailModel {
   id: number; //	主键ID
   orderNo: string; //订单ID
   payTime: string; //付款时间
-  profitSharingTimeStart:string;//分账时间开始
-  profitSharingTimeEnd:string;//分账时间结束
-  finishTime:string;//收货/核销/完成时间
-  profitSharingTime:string;//分账时间
+  profitSharingTimeStart: string;//分账时间开始
+  profitSharingTimeEnd: string;//分账时间结束
+  finishTime: string;//收货/核销/完成时间
+  profitSharingTime: string;//分账时间
   conditioningProgramName: string; //调理方案名称
-  conditioningProgramType:string;//调理方案类型
-  sellType:string;//	商品类型 1-实体商品 2-线下服务 3-线上权益
+  conditioningProgramType: string;//调理方案类型
+  sellType: string;//	商品类型 1-实体商品 2-线下服务 3-线上权益
   pricingUnit: string; //计价单位
   unitPrice: number; //单价
-  totalMeasure:number;//总用量
-  totalPrice:number;//总价格
-  conditioningProgramSupplierName:string;//供应商名称
-  profitSharing:string;//分账比例
-  profitSharingAmount:number;//预计分账金额分账金额
-  realAmount:string;//实际到账金额
-  profitSharingStatus:string;//分账状态 1-未分账 2-已分账 3-分账异常
-  
+  totalMeasure: number;//总用量
+  totalPrice: number;//总价格
+  conditioningProgramSupplierName: string;//供应商名称
+  profitSharing: string;//分账比例
+  profitSharingAmount: number;//预计分账金额分账金额
+  realAmount: string;//实际到账金额
+  profitSharingStatus: string;//分账状态 1-未分账 2-已分账 3-分账异常
+
 
 }
-export type RevenueSharingDetailQuery = Partial<RevenueSharingDetailModel>;
+export type RevenueSharingDetailQuery = Partial<RevenueSharingDetailModel>;
+
+
+export interface EvaluateDetailModel {
+  patientConditioningRecordId: number; //	患者调理记录ID
+  patientConditioningProgramId: string; //患者调理方案ID
+  lineId: string; //调理任务ID
+  complianceScore: string;//相符度评分
+  qualityScore: string;//质量评分
+  attitudeScore: string;//	态度评分
+  environmentScore: string;//环境评分
+  depict: string;//	评价描述
+  imageVideos: string[];//图片视频
+  createTime: string;//创建时间
+}

+ 1 - 1
src/order/DispatchOrderPanel.vue

@@ -171,7 +171,7 @@ const gridOptions = reactive<VxeGridProps<Institution>>({
     { field: 'detailAddress', title: '地址' },
     { field: 'phone', title: '联系电话' },
     { field: 'todayOrderQuantity', title: '当日线下订单数' },
-    { field: 'score', title: '机构评分' },
+    { field: 'evaluateScore', title: '机构评分' },
   ],
   data: [],
   pagerConfig: {

+ 16 - 1
src/request/api/order.api.ts

@@ -1,5 +1,8 @@
 import type { List, Tree } from '@/model';
-import type { OrderQuery, OrderModel, OrderLiaisonListModel, OrderLiaisonListQuery, ShipmentModel, ShipmentQuery, PieOrderCountModel,RevenueSharingDetailModel,RevenueSharingDetailQuery } from '@/model/order.model';
+import type {
+  OrderQuery,
+  OrderModel, OrderLiaisonListModel, ShipmentModel, ShipmentQuery, PieOrderCountModel, RevenueSharingDetailModel, RevenueSharingDetailQuery, EvaluateDetailModel
+} from '@/model/order.model';
 import request from '@/request/alova';
 
 // 线下服务  今日指派订单分页列表
@@ -163,4 +166,16 @@ export function getRevenueSharingDetailListMethod(page: number, size: number, qu
       hitSource: /revenueSharing$/, // 匹配失效源
     }
   );
+}
+
+
+// 获取订单商品-线下核销记录的患者个人评价   type 	1-商品 2-线下核销记录
+export function getEvaluateDetailMethod(type: string, id: string) {
+  return request.Post<EvaluateDetailModel>(
+    `/fdhb-pc/patientCrManage/getEvaluate/${type}/${id}`,
+    {},
+    {
+      hitSource: /order$/, // 匹配失效源
+    }
+  );
 } 

+ 189 - 93
src/service/SingleItemDetail.vue

@@ -1,15 +1,35 @@
 <script setup lang="ts">
 import type { SystemCwModel } from '@/model/care.model';
 import { PlayCircleOutlined } from '@ant-design/icons-vue';
-import { computed, h } from 'vue';
+import { computed, h, onMounted, ref } from 'vue';
 import { notification } from 'ant-design-vue';
 import VxeUI from 'vxe-table';
 import ReviewMediaPreview from '@/service/ReviewMediaPreview.vue';
 import type { MediaItem } from '@/service/ReviewMediaPreview.vue';
+import seeEvaluate from '@/service/seeEvaluate.vue';
+import { getEvaluateDetailMethod } from '@/request/api/order.api';
+
 
 const props = defineProps<{
   data: SystemCwModel['items'][number];
 }>();
+// 服务记录--查看评价
+function openSeeEvaluate(row: any) {
+    VxeUI.modal.open({
+      title: '用户评价',
+      width: 900,
+      height: 600,
+      escClosable: true,
+      destroyOnClose: true,
+      slots: {
+        default() {
+          return h(seeEvaluate, {
+            data: row,
+          });
+        },
+      },
+    });
+}
 // 复制物流信息
 function handleCopyTracking() {
   const trackingNumber = mockLogisticsData.value.trackingNumber;
@@ -68,21 +88,63 @@ const sellTypeText: Record<string, string> = {
   '3': '线上权益',
 };
 
-// 用户评价假数据(淘宝式:每条评价有多张图/多个视频,先展示缩略图排列,点击可左右滑动预览)
-const mockReviewList = [
-  {
-    criterion: '描述相符',
-    score: 5,
-    content: '描述相符描述相符描述相符描述相符描述相符描述相符 描述相符描述相符描述相符描述相符描述相符描述相符 描述相符描述相符描述相符描述相符描述相符描述相符描述相符 描述相符描述相符描述相符描述相符描述相符描述相符 描述相符描述相符描述相符描述相符描述相符描述相符描述相符 描述相符描述相符描述相符描述相符描述相符描述相符 描述相符描述相符描述相符描述相符描述相符描述相符描述相符 描述相符描述相符描述相符描述相符描述相符描述相符 描述相符描述相符描述相符描述相符描述相符描述相符描述相符 描述相符描述相符描述相符描述相符描述相符描述相符 描述相符',
-   //图片/视频列表
-    mediaList: [
-      { type: 'image', url: 'https://picsum.photos/id/1/400/400' },
-      { type: 'image', url: 'https://picsum.photos/id/10/400/400' },
-      { type: 'video', url: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4' },
-      { type: 'image', url: 'https://picsum.photos/id/20/400/400' },
-    ] as MediaItem[],
-  },
-];
+function toScore(value: unknown) {
+  const n = Number(value);
+  return Number.isFinite(n) ? n : 0;
+}
+
+function normalizeMediaList(input: unknown): MediaItem[] {
+  if (!Array.isArray(input)) return [];
+  return input
+    .map((it) => {
+      if (typeof it === 'string') {
+        const url = it;
+        const type: MediaItem['type'] = /\.(mp4|webm|ogg)(\?|#|$)/i.test(url) ? 'video' : 'image';
+        return { type, url } as MediaItem;
+      }
+      if (it && typeof it === 'object' && 'url' in it) {
+        const anyIt = it as any;
+        const url = String(anyIt.url ?? '');
+        if (!url) return null;
+        const type: MediaItem['type'] =
+          anyIt.type === 'video' || anyIt.type === 'image'
+            ? anyIt.type
+            : /\.(mp4|webm|ogg)(\?|#|$)/i.test(url)
+              ? 'video'
+              : 'image';
+        return { type, url } as MediaItem;
+      }
+      return null;
+    })
+    .filter((x): x is MediaItem => Boolean(x));
+}
+
+// 用户评价(与 seeEvaluate.vue 逻辑一致)
+const scroeType = ref([
+  { name: '描述相符', score: 0 },
+]);
+
+const evaluateDetail = ref({
+  depict: '',
+  mediaList: [] as MediaItem[],
+});
+
+const hasEvaluate = computed(() => {
+  const depict = (evaluateDetail.value.depict ?? '').trim();
+  const hasMedia = (evaluateDetail.value.mediaList?.length ?? 0) > 0;
+  const hasScore = scroeType.value.some((i) => toScore(i.score) > 0);
+  return Boolean(depict) || hasMedia || hasScore;
+});
+
+async function getEvaluateDetail() {
+  const id = (props.data as any)?.id;
+  if (!id) return;
+  const res = await getEvaluateDetailMethod('1', id);
+  if (!res) return;
+  if (scroeType.value[0]) scroeType.value[0].score = toScore(res.complianceScore);
+  evaluateDetail.value.depict = res.depict ?? '';
+  evaluateDetail.value.mediaList = normalizeMediaList(res.imageVideos);
+}
 
 const REVIEW_PREVIEW_MODAL_ID = 'review-media-preview-modal';
 //预览图片/视频
@@ -104,6 +166,10 @@ function openPreview(list: MediaItem[], index: number) {
     },
   });
 }
+
+onMounted(() => {
+  getEvaluateDetail();
+});
 </script>
 
 <template>
@@ -191,57 +257,57 @@ function openPreview(list: MediaItem[], index: number) {
       <!-- <h3 class="info-title">{{ data.receiptType === '0' ? '物流信息' : data.receiptType === '1' ? '线下取货' : '' }}</h3> -->
       <h3 class="info-title">物流信息</h3>
       <div v-if="data.receiptType !== null">
-      <div class="info-content-wrapper" v-if="data.receiptStatus !== null">
-        <!-- 物流信息 -->
-        <div class="logistics-content" v-if="data.receiptType === '0'">
-          <div class="logistics-tracking">
-            <span class="tracking-number">{{ mockLogisticsData.trackingNumber }}</span>
-            <a @click="handleCopyTracking" class="copy-link">复制</a>
+        <div class="info-content-wrapper" v-if="data.receiptStatus !== null">
+          <!-- 物流信息 -->
+          <div class="logistics-content" v-if="data.receiptType === '0'">
+            <div class="logistics-tracking">
+              <span class="tracking-number">{{ mockLogisticsData.trackingNumber }}</span>
+              <a @click="handleCopyTracking" class="copy-link">复制</a>
+            </div>
+            <div class="logistics-recipient">
+              <span class="receive-icon">收</span>
+              <span class="recipient-info">
+                {{ mockLogisticsData.recipientName
+                  || '' }} {{ mockLogisticsData.recipientPhone || '' }} {{
+                  mockLogisticsData.recipientAddress || '' }}
+              </span>
+            </div>
           </div>
-          <div class="logistics-recipient">
-            <span class="receive-icon">收</span>
-            <span class="recipient-info">
-              {{ mockLogisticsData.recipientName
-                || '' }} {{ mockLogisticsData.recipientPhone || '' }} {{
-                mockLogisticsData.recipientAddress || '' }}
-            </span>
+          <div class="logistics-content" v-if="data.receiptType === '1'">
+            线下取货
           </div>
-        </div>
-        <div class="logistics-content" v-if="data.receiptType === '1'">
-          线下取货
-        </div>
-        <!-- 包裹内商品 -->
-        <div class="package-items-wrapper" v-if="mockPackageItems.length > 0">
-          <h3 class="info-title package-title" v-if="data.receiptType === '0'">
-            <span class="star-icon">★</span>
-            包裹内商品
-          </h3>
-          <div class="package-items">
-          
-            <div v-for="(item, index) in mockPackageItems" :key="index" class="package-item">
-              <div class="package-item-image" v-if="item.conditioningProgramPhoto">
-                <a-image v-if="item.conditioningProgramPhoto" :width="60" :height="60" style="border-radius: 4px;"
-                  :src="item.conditioningProgramPhoto" class="item-img" />
-              </div>
-              <div class="package-item-placeholder" v-else>
-                <text class="placeholder-icon">📦</text>
-              </div>
-              <div class="package-item-details">
-                <div class="package-item-name">{{ item.conditioningProgramName }}</div>
-                <div class="package-item-spec">{{ item.convertDose }} {{ item.convertUnit }}</div>
-              </div>
-              <div class="package-item-details">
-                <div class="package-item-price">¥{{ item.unitPrice }}</div>
-                <div class="package-item-quantity">x{{ item.totalMeasure }}</div>
+          <!-- 包裹内商品 -->
+          <div class="package-items-wrapper" v-if="mockPackageItems.length > 0">
+            <h3 class="info-title package-title" v-if="data.receiptType === '0'">
+              <span class="star-icon">★</span>
+              包裹内商品
+            </h3>
+            <div class="package-items">
+
+              <div v-for="(item, index) in mockPackageItems" :key="index" class="package-item">
+                <div class="package-item-image" v-if="item.conditioningProgramPhoto">
+                  <a-image v-if="item.conditioningProgramPhoto" :width="60" :height="60" style="border-radius: 4px;"
+                    :src="item.conditioningProgramPhoto" class="item-img" />
+                </div>
+                <div class="package-item-placeholder" v-else>
+                  <text class="placeholder-icon">📦</text>
+                </div>
+                <div class="package-item-details">
+                  <div class="package-item-name">{{ item.conditioningProgramName }}</div>
+                  <div class="package-item-spec">{{ item.convertDose }} {{ item.convertUnit }}</div>
+                </div>
+                <div class="package-item-details">
+                  <div class="package-item-price">¥{{ item.unitPrice }}</div>
+                  <div class="package-item-quantity">x{{ item.totalMeasure }}</div>
+                </div>
               </div>
             </div>
           </div>
         </div>
+        <div class="info-content-wrapper" v-else>
+          暂无信息
+        </div>
       </div>
-      <div class="info-content-wrapper" v-else>
-        暂无信息
-      </div>
-    </div>
     </div>
 
     <!-- 服务记录 线下服务才显示  1-实体商品 2-线下服务 3-线上权益-->
@@ -279,6 +345,11 @@ function openPreview(list: MediaItem[], index: number) {
         </vxe-column>
         <vxe-column field="operateBy" title="操作人" align="center" />
         <vxe-column field="feedback" title="治疗备注" align="center" />
+        <vxe-column field="acuPointNames" title="用户评价" align="center">
+          <template #default="{ row }">
+            <vxe-button @click="openSeeEvaluate(row)" content="查看" status="primary" />
+          </template>
+        </vxe-column>
       </vxe-table>
     </div>
     <!-- 用户评价 -->
@@ -287,42 +358,40 @@ function openPreview(list: MediaItem[], index: number) {
         <h3 class="info-title">用户评价</h3>
       </div>
       <div class="info-content-wrapper">
-        <div v-for="(item, index) in mockReviewList" :key="index" class="review-item">
-          <div class="review-top-row">
-            <div class="review-rating-row">
-              <span class="review-criterion">{{ item.criterion }}</span>
-              <a-rate :value="item.score" disabled allow-half class="review-stars" />
-              <span class="review-score">{{ item.score }}分</span>
-            </div>
-            <div class="review-content-block">
-              <span class="review-content-label">评价内容:</span>
-              <p class="review-content-text">{{ item.content }}</p>
-              <div v-if="item.mediaList?.length" class="review-media-row">
-                <div
-                  v-for="(media, i) in item.mediaList"
-                  :key="i"
-                  class="review-media-thumb"
-                  :class="{ 'is-video': media.type === 'video' }"
-                  role="button"
-                  tabindex="0"
-                  @click.stop.prevent="openPreview(item.mediaList, i)"
-                  @keydown.enter.space.prevent="openPreview(item.mediaList, i)"
-                >
-                  <img
-                    v-if="media.type === 'image'"
-                    :src="media.url"
-                    class="review-thumb-img"
-                    alt=""
-                  />
-                  <div v-else class="review-thumb-video">
-                    <video :src="media.url" muted preload="metadata" />
-                    <span class="review-thumb-play"><PlayCircleOutlined /></span>
+        <template v-if="hasEvaluate">
+          <div class="review-item">
+            <div class="review-top-row">
+              <div class="review-rating-box">
+                <div class="review-rating-row" v-for="scroe in scroeType" :key="scroe.name">
+                  <span class="review-criterion">{{ scroe.name }}</span>
+                  <a-rate :value="scroe.score" disabled allow-half class="review-stars" />
+                  <span class="review-score">{{ scroe.score }}分</span>
+                </div>
+              </div>
+              <div class="review-content-block">
+                <div class="review-content-line">
+                  <span class="review-content-label">评价内容:</span>
+                  <span class="review-content-text">{{ evaluateDetail.depict }}</span>
+                </div>
+                <div v-if="evaluateDetail.mediaList?.length" class="review-media-row">
+                  <div v-for="(media, i) in evaluateDetail.mediaList" :key="i" class="review-media-thumb"
+                    :class="{ 'is-video': media.type === 'video' }" role="button" tabindex="0"
+                    @click.stop.prevent="openPreview(evaluateDetail.mediaList, i)"
+                    @keydown.enter.space.prevent="openPreview(evaluateDetail.mediaList, i)">
+                    <img v-if="media.type === 'image'" :src="media.url" class="review-thumb-img" alt="" />
+                    <div v-else class="review-thumb-video">
+                      <video :src="media.url" muted preload="metadata" />
+                      <span class="review-thumb-play">
+                        <PlayCircleOutlined />
+                      </span>
+                    </div>
                   </div>
                 </div>
               </div>
             </div>
           </div>
-        </div>
+        </template>
+        <div v-else class="empty-text">无</div>
       </div>
     </div>
   </div>
@@ -392,17 +461,28 @@ function openPreview(list: MediaItem[], index: number) {
     margin-bottom: 12px;
   }
 
+  .review-rating-box {
+    flex-shrink: 0;
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 12px;
+    min-width: 240px;
+  }
+
   .review-rating-row {
     display: flex;
     align-items: center;
     gap: 8px;
     flex-shrink: 0;
-    margin-right: 40px;
   }
 
   .review-criterion {
     font-size: 14px;
     color: #333;
+    width: 72px;
+    text-align: left;
+    white-space: nowrap;
   }
 
   .review-stars {
@@ -411,6 +491,7 @@ function openPreview(list: MediaItem[], index: number) {
     :deep(.ant-rate-star-full .ant-rate-star-second) {
       color: #fadb14;
     }
+
     :deep(.ant-rate-star-full .ant-rate-star-first) {
       color: #fadb14;
     }
@@ -426,17 +507,24 @@ function openPreview(list: MediaItem[], index: number) {
     min-width: 0;
   }
 
+  .review-content-line {
+    display: flex;
+    align-items: flex-start;
+    gap: 6px;
+  }
+
   .review-content-label {
     font-size: 14px;
     color: #333;
     margin-right: 4px;
+    flex-shrink: 0;
   }
 
   .review-content-text {
     font-size: 14px;
     color: #333;
     line-height: 22px;
-    margin: 8px 0 12px;
+    margin: 0;
     white-space: pre-wrap;
     max-width: 560px;
     word-break: break-word;
@@ -447,6 +535,7 @@ function openPreview(list: MediaItem[], index: number) {
     gap: 8px;
     flex-wrap: wrap;
     margin-top: 12px;
+    margin-left: 70px;
   }
 
   .review-media-thumb {
@@ -517,6 +606,13 @@ function openPreview(list: MediaItem[], index: number) {
     padding: 10px 20px;
   }
 
+  .empty-text {
+    color: #999;
+    font-size: 14px;
+    line-height: 22px;
+    padding: 8px 0;
+  }
+
   .product-info {
     display: flex;
     align-items: flex-start;

+ 340 - 0
src/service/seeEvaluate.vue

@@ -0,0 +1,340 @@
+<script setup lang="ts">
+import { PlayCircleOutlined } from '@ant-design/icons-vue';
+import { computed, h, onMounted, ref } from 'vue';
+import VxeUI from 'vxe-table';
+import ReviewMediaPreview from '@/service/ReviewMediaPreview.vue';
+import type { MediaItem } from '@/service/ReviewMediaPreview.vue';
+import { getEvaluateDetailMethod } from '@/request/api/order.api';
+const props = defineProps<{
+    data: any;
+}>();
+
+function toScore(value: unknown) {
+    const n = Number(value);
+    return Number.isFinite(n) ? n : 0;
+}
+
+function normalizeMediaList(input: unknown): MediaItem[] {
+    if (!Array.isArray(input)) return [];
+    return input
+        .map((it) => {
+            if (typeof it === 'string') {
+                const url = it;
+                const type: MediaItem['type'] = /\.(mp4|webm|ogg)(\?|#|$)/i.test(url) ? 'video' : 'image';
+                return { type, url } as MediaItem;
+            }
+            if (it && typeof it === 'object' && 'url' in it) {
+                const anyIt = it as any;
+                const url = String(anyIt.url ?? '');
+                if (!url) return null;
+                const type: MediaItem['type'] =
+                    anyIt.type === 'video' || anyIt.type === 'image'
+                        ? anyIt.type
+                        : /\.(mp4|webm|ogg)(\?|#|$)/i.test(url)
+                            ? 'video'
+                            : 'image';
+                return { type, url } as MediaItem;
+            }
+            return null;
+        })
+        .filter((x): x is MediaItem => Boolean(x));
+}
+const scroeType = ref([{
+    name: '服务质量',
+    score: 0,
+}, {
+    name: '服务态度',
+    score: 0,
+}, {
+    name: '环境',
+    score: 0,
+}]);
+// 用户评价假数据(淘宝式:每条评价有多张图/多个视频,先展示缩略图排列,点击可左右滑动预览)
+const evaluateDetail = ref(
+    {
+        depict: '',
+        // 图片/视频列表
+        mediaList: [] as MediaItem[],
+    });
+
+const hasEvaluate = computed(() => {
+    const depict = String(evaluateDetail.value.depict ?? '').trim();
+    const hasMedia = (evaluateDetail.value.mediaList?.length ?? 0) > 0;
+    const hasScore = scroeType.value.some((i) => toScore(i.score) > 0);
+    return Boolean(depict) || hasMedia || hasScore;
+});
+
+const REVIEW_PREVIEW_MODAL_ID = 'review-media-preview-modal';
+async function getEvaluateDetail() {
+    console.log(props.data, "传过来的参数");
+    if (props.data && props.data.id) {
+        const res = await getEvaluateDetailMethod('2', props.data.id);
+        console.log(res, "获取评价");
+        if (res) {
+            if (scroeType.value[0]) scroeType.value[0].score = toScore(res.qualityScore); //服务质量
+            if (scroeType.value[1]) scroeType.value[1].score = toScore(res.attitudeScore); //服务态度
+            if (scroeType.value[2]) scroeType.value[2].score = toScore(res.environmentScore); //环境
+            evaluateDetail.value.depict = res.depict;
+            evaluateDetail.value.mediaList = normalizeMediaList(res.imageVideos);
+        }
+    }
+    console.log(evaluateDetail.value, "evaluateDetail");
+}
+
+onMounted(() => {
+    // 获取用户评价
+    getEvaluateDetail();
+});
+
+
+
+// 预览图片/视频
+function openPreview(list: MediaItem[], index: number) {
+    if (!list?.length) return;
+    VxeUI.modal.open({
+        id: REVIEW_PREVIEW_MODAL_ID,
+        title: '预览',
+        width: 720,
+        escClosable: true,
+        destroyOnClose: true,
+        slots: {
+            default() {
+                return h(ReviewMediaPreview, {
+                    mediaList: list,
+                    initialIndex: index,
+                });
+            },
+        },
+    });
+}
+</script>
+
+<template>
+    <div class="service-detail">
+        <!-- 用户评价 -->
+        <div class="info-section">
+            <div class="info-content-wrapper">
+                <template v-if="hasEvaluate">
+                    <div class="review-item">
+                        <div class="review-top-container">
+                            <div class="review-rating-box">
+                                <div class="review-rating-row" v-for="scroe in scroeType" :key="scroe.name">
+                                    <span class="review-criterion">{{ scroe.name }}</span>
+                                    <a-rate :value="scroe.score" disabled allow-half class="review-stars" />
+                                    <span class="review-score">{{ scroe.score }}分</span>
+                                </div>
+                            </div>
+                            <div class="review-content-block">
+                                <div class="review-content-line">
+                                    <span class="review-content-label">评价内容:</span>
+                                    <span class="review-content-text">{{ evaluateDetail.depict }}</span>
+                                </div>
+                                <div v-if="evaluateDetail.mediaList?.length" class="review-media-row">
+                                    <div v-for="(media, i) in evaluateDetail.mediaList" :key="i" class="review-media-thumb"
+                                        :class="{ 'is-video': media.type === 'video' }" role="button" tabindex="0"
+                                        @click.stop.prevent="openPreview(evaluateDetail.mediaList, i)"
+                                        @keydown.enter.space.prevent="openPreview(evaluateDetail.mediaList, i)">
+                                        <img v-if="media.type === 'image'" :src="media.url" class="review-thumb-img" alt="" />
+                                        <div v-else class="review-thumb-video">
+                                            <video :src="media.url" muted preload="metadata" />
+                                            <span class="review-thumb-play">
+                                                <PlayCircleOutlined />
+                                            </span>
+                                        </div>
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+                    </div>
+                </template>
+                <div v-else class="empty-text">无</div>
+            </div>
+        </div>
+    </div>
+</template>
+
+<style scoped lang="scss">
+.service-detail {
+    padding: 20px;
+    color: black;
+    background: #fff;
+
+    .info-section {
+        margin-top: 24px;
+        margin-bottom: 24px;
+        background: #fff;
+    }
+
+    .info-title {
+        font-size: 16px;
+        font-weight: 600;
+        margin-bottom: 12px;
+        color: #333;
+    }
+
+    .info-title-row {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+        margin-bottom: 12px;
+
+        .info-title {
+            margin-bottom: 0;
+        }
+    }
+
+    .info-content-wrapper {
+        border: 1px solid #e8e8e8;
+        border-radius: 4px;
+        padding: 10px 20px;
+    }
+
+    .empty-text {
+        color: #999;
+        font-size: 14px;
+        line-height: 22px;
+        padding: 8px 0;
+    }
+
+    .review-item {
+        padding: 12px 0;
+        border-bottom: 1px solid #f0f0f0;
+
+        &:last-child {
+            border-bottom: none;
+        }
+    }
+
+    .review-top-container {
+        display: flex;
+        align-items: flex-start;
+        gap: 24px;
+        margin-bottom: 12px;
+    }
+
+    .review-rating-box {
+        flex-shrink: 0;
+        display: flex;
+        flex-direction: column;
+        align-items: flex-start;
+        gap: 12px;
+        min-width: 240px;
+    }
+
+    .review-rating-row {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+        flex-shrink: 0;
+    }
+
+    .review-criterion {
+        font-size: 14px;
+        color: #333;
+        width: 72px;
+        text-align: left;
+        white-space: nowrap;
+    }
+
+    .review-stars {
+        font-size: 16px;
+
+        :deep(.ant-rate-star-full .ant-rate-star-second) {
+            color: #fadb14;
+        }
+
+        :deep(.ant-rate-star-full .ant-rate-star-first) {
+            color: #fadb14;
+        }
+    }
+
+    .review-score {
+        font-size: 13px;
+        color: #999;
+    }
+
+    .review-content-block {
+        flex: 1;
+        min-width: 0;
+    }
+
+    .review-content-line {
+        display: flex;
+        align-items: flex-start;
+        gap: 6px;
+    }
+
+    .review-content-label {
+        font-size: 14px;
+        color: #333;
+        margin-right: 4px;
+        flex-shrink: 0;
+    }
+
+    .review-content-text {
+        font-size: 14px;
+        color: #333;
+        line-height: 22px;
+        margin: 0;
+        white-space: pre-wrap;
+        word-break: break-word;
+    }
+
+    .review-media-row {
+        display: flex;
+        gap: 8px;
+        flex-wrap: wrap;
+        margin-top: 12px;
+        margin-left: 70px;
+    }
+
+    .review-media-thumb {
+        width: 72px;
+        height: 72px;
+        border-radius: 6px;
+        border: 1px solid #e8e8e8;
+        overflow: hidden;
+        flex-shrink: 0;
+        cursor: pointer;
+        background: #f5f5f5;
+        transition: border-color 0.2s, box-shadow 0.2s;
+
+        &:hover {
+            border-color: #1890ff;
+            box-shadow: 0 0 0 1px #1890ff;
+        }
+
+        .review-thumb-img {
+            width: 100%;
+            height: 100%;
+            object-fit: cover;
+            display: block;
+        }
+
+        .review-thumb-video {
+            position: relative;
+            width: 100%;
+            height: 100%;
+            background: #2a2a2a;
+
+            video {
+                width: 100%;
+                height: 100%;
+                object-fit: cover;
+                display: block;
+            }
+
+            .review-thumb-play {
+                position: absolute;
+                inset: 0;
+                display: flex;
+                align-items: center;
+                justify-content: center;
+                color: rgba(255, 255, 255, 0.9);
+                font-size: 28px;
+                pointer-events: none;
+            }
+        }
+    }
+}
+</style>