张田田 před 2 měsíci
rodič
revize
367d321840

+ 1 - 0
src/order/DispatchOrderPanel.vue

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

+ 123 - 0
src/service/ReviewMediaPreview.vue

@@ -0,0 +1,123 @@
+<script setup lang="ts">
+import { ref } from 'vue';
+// @ts-expect-error swiper 模块导出与类型声明解析
+import { Navigation, Pagination } from 'swiper';
+import { Swiper, SwiperSlide } from 'swiper/vue';
+import 'swiper/css';
+import 'swiper/css/navigation';
+import 'swiper/css/pagination';
+
+export type MediaItem = { type: 'image' | 'video'; url: string };
+
+defineProps<{
+  mediaList: MediaItem[];
+  initialIndex: number;
+}>();
+
+const carouselRef = ref<HTMLElement | null>(null);
+const swiperModules = [Navigation, Pagination];
+
+function onSlideChange() {
+  carouselRef.value?.querySelectorAll('video').forEach((v) => v.pause());
+}
+</script>
+
+<template>
+  <div ref="carouselRef" class="review-preview-carousel">
+    <swiper
+      v-if="mediaList?.length"
+      :modules="swiperModules"
+      :initial-slide="initialIndex"
+      :slides-per-view="1"
+      :space-between="0"
+      navigation
+      pagination
+      class="review-preview-swiper"
+      @slide-change="onSlideChange"
+    >
+      <swiper-slide v-for="(media, i) in mediaList" :key="i" class="review-preview-slide">
+        <div v-if="media.type === 'image'" class="review-preview-inner">
+          <img :src="media.url" alt="" class="review-preview-img" />
+        </div>
+        <div v-else class="review-preview-inner review-preview-video-wrap">
+          <video :src="media.url" controls class="review-preview-video" />
+        </div>
+      </swiper-slide>
+    </swiper>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.review-preview-carousel {
+  width: 100%;
+  height: 70vh;
+  min-height: 300px;
+
+  .review-preview-swiper {
+    width: 100%;
+    height: 100%;
+    --swiper-navigation-size: 36px;
+    --swiper-theme-color: #1890ff;
+  }
+
+  .review-preview-slide {
+    width: 100%;
+    height: 100%;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    background: #000;
+    box-sizing: border-box;
+  }
+
+  .review-preview-inner {
+    width: 100%;
+    height: 100%;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    box-sizing: border-box;
+  }
+
+  .review-preview-img {
+    width: 100%;
+    height: 100%;
+    object-fit: contain;
+    display: block;
+  }
+
+  .review-preview-video-wrap {
+    width: 100%;
+    height: 100%;
+  }
+
+  .review-preview-video {
+    width: 100%;
+    height: 100%;
+    object-fit: contain;
+    display: block;
+  }
+
+  :deep(.swiper-wrapper) {
+    height: 100%;
+  }
+
+  :deep(.swiper-slide) {
+    height: 100%;
+  }
+
+  :deep(.swiper-button-prev),
+  :deep(.swiper-button-next) {
+    color: #fff;
+  }
+
+  :deep(.swiper-pagination-bullet) {
+    background: #fff;
+    opacity: 0.5;
+  }
+
+  :deep(.swiper-pagination-bullet-active) {
+    opacity: 1;
+  }
+}
+</style>

+ 221 - 2
src/service/SingleItemDetail.vue

@@ -1,7 +1,12 @@
 <script setup lang="ts">
 import type { SystemCwModel } from '@/model/care.model';
-import { computed } from 'vue';
+import { PlayCircleOutlined } from '@ant-design/icons-vue';
+import { computed, h } 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';
+
 const props = defineProps<{
   data: SystemCwModel['items'][number];
 }>();
@@ -62,6 +67,43 @@ const sellTypeText: Record<string, string> = {
   '2': '线下服务',
   '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[],
+  },
+];
+
+const REVIEW_PREVIEW_MODAL_ID = 'review-media-preview-modal';
+//预览图片/视频
+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>
@@ -213,7 +255,7 @@ const sellTypeText: Record<string, string> = {
         <vxe-column type="seq" title="序号" width="60" align="center" />
         <vxe-column field="arrangeDate" title="服务日期" align="center" />
         <vxe-column field="arrangePeriod" title="服务时间段" align="center" />
-        <vxe-column field="operateTime" title="服务状态" align="center">
+        <vxe-column field="verifyStatus" title="服务状态" align="center">
           <template #default="{ row }">
             {{ row.operateTime ? '已核销' : '已预约' }}
           </template>
@@ -239,6 +281,50 @@ const sellTypeText: Record<string, string> = {
         <vxe-column field="feedback" title="治疗备注" align="center" />
       </vxe-table>
     </div>
+    <!-- 用户评价 -->
+    <div class="info-section">
+      <div class="info-title-row">
+        <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>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
   </div>
 </template>
 
@@ -279,6 +365,139 @@ const sellTypeText: Record<string, string> = {
     color: #333;
   }
 
+  .info-title-row {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    margin-bottom: 12px;
+
+    .info-title {
+      margin-bottom: 0;
+    }
+  }
+
+  .review-item {
+    padding: 12px 0;
+    border-bottom: 1px solid #f0f0f0;
+
+    &:last-child {
+      border-bottom: none;
+    }
+  }
+
+  .review-top-row {
+    display: flex;
+    align-items: flex-start;
+    gap: 24px;
+    margin-bottom: 12px;
+  }
+
+  .review-rating-row {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    flex-shrink: 0;
+    margin-right: 40px;
+  }
+
+  .review-criterion {
+    font-size: 14px;
+    color: #333;
+  }
+
+  .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-label {
+    font-size: 14px;
+    color: #333;
+    margin-right: 4px;
+  }
+
+  .review-content-text {
+    font-size: 14px;
+    color: #333;
+    line-height: 22px;
+    margin: 8px 0 12px;
+    white-space: pre-wrap;
+    max-width: 560px;
+    word-break: break-word;
+  }
+
+  .review-media-row {
+    display: flex;
+    gap: 8px;
+    flex-wrap: wrap;
+    margin-top: 12px;
+  }
+
+  .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;
+      }
+    }
+  }
+
   .info-content-wrapper .info-title {
     margin-top: 20px;
     margin-bottom: 12px;