Explorar o código

feat:项目详情拼团模块

songzhen hai 2 meses
pai
achega
c176ee5278
Modificáronse 93 ficheiros con 2774 adicións e 230 borrados
  1. 2 1
      .env.development
  2. 4 0
      .env.production
  3. 1 0
      .env.staging
  4. 5 1
      nuxt.config.ts
  5. 2 1
      package.json
  6. 16 0
      pnpm-lock.yaml
  7. 1 14
      src/app.vue
  8. 5 0
      src/assets/css/font.css
  9. BIN=BIN
      src/assets/font/YouSheBiaoTiHei.woff2
  10. BIN=BIN
      src/assets/img/travel_project_home/avatar_1.png
  11. BIN=BIN
      src/assets/img/travel_project_home/avatar_2.png
  12. BIN=BIN
      src/assets/img/travel_project_home/avatar_3.png
  13. BIN=BIN
      src/assets/img/travel_project_home/avatar_4.png
  14. BIN=BIN
      src/assets/img/travel_project_home/avatar_5.png
  15. BIN=BIN
      src/assets/img/travel_project_home/avatar_6.png
  16. BIN=BIN
      src/assets/img/travel_project_home/avatar_7.png
  17. BIN=BIN
      src/assets/img/travel_project_home/avatar_8.png
  18. BIN=BIN
      src/assets/img/travel_project_home/comments_bg.png
  19. BIN=BIN
      src/assets/img/travel_project_home/contrast.png
  20. BIN=BIN
      src/assets/img/travel_project_home/icon_tiexinxiangban.png
  21. BIN=BIN
      src/assets/img/travel_project_home/icon_wuyouhaiwan.png
  22. BIN=BIN
      src/assets/img/travel_project_home/icon_zhuanshudingzhi.png
  23. BIN=BIN
      src/assets/img/travel_project_home/icon_zhuanyezhenxuan.png
  24. BIN=BIN
      src/assets/img/travel_project_home/kefu_1.png
  25. BIN=BIN
      src/assets/img/travel_project_home/kefu_1_qrcode.png
  26. BIN=BIN
      src/assets/img/travel_project_home/kefu_2.png
  27. BIN=BIN
      src/assets/img/travel_project_home/kefu_2_qrcode.png
  28. BIN=BIN
      src/assets/img/travel_project_home/kefu_3.png
  29. BIN=BIN
      src/assets/img/travel_project_home/kefu_3_qrcode.png
  30. BIN=BIN
      src/assets/img/travel_project_home/travel_project_home_pintuan_bg.png
  31. BIN=BIN
      src/assets/img/travel_projects_detail/bg_base_info.png
  32. BIN=BIN
      src/assets/img/travel_projects_detail/bg_price.png
  33. BIN=BIN
      src/assets/img/travel_projects_detail/bg_user_display.png
  34. BIN=BIN
      src/assets/img/travel_projects_detail/calendar.png
  35. BIN=BIN
      src/assets/img/travel_projects_detail/kaituan_kefu_qrcode.png
  36. BIN=BIN
      src/assets/img/travel_projects_detail/pintuan_01.png
  37. BIN=BIN
      src/assets/img/travel_projects_detail/pintuan_02.png
  38. BIN=BIN
      src/assets/img/travel_projects_detail/pintuan_03.png
  39. BIN=BIN
      src/assets/img/travel_projects_detail/pintuan_cad_bg.png
  40. BIN=BIN
      src/assets/img/travel_projects_detail/pintuan_cad_bg_selected.png
  41. BIN=BIN
      src/assets/img/travel_projects_detail/pintuan_cad_fire.png
  42. BIN=BIN
      src/assets/img/travel_projects_detail/pintuan_cad_fire_selected.png
  43. BIN=BIN
      src/assets/img/travel_projects_detail/pintuan_icon.png
  44. BIN=BIN
      src/assets/img/travel_projects_detail/pintuan_label.png
  45. BIN=BIN
      src/assets/img/travel_projects_detail/pintuan_success.png
  46. BIN=BIN
      src/assets/img/travel_projects_detail/recommend_icon.png
  47. BIN=BIN
      src/assets/img/travel_projects_detail/travel_detail_maidian copy.png
  48. BIN=BIN
      src/assets/img/travel_projects_detail/travel_detail_tip copy.png
  49. BIN=BIN
      src/assets/img/travel_projects_detail/tuanduixinxi.png
  50. BIN=BIN
      src/assets/img/travel_projects_detail/user_empty.png
  51. BIN=BIN
      src/assets/img/travel_projects_detail/woyaokaituan.png
  52. 15 0
      src/components/BaseModal/index.vue
  53. 1 2
      src/components/Home/TravelNotes/index.client.vue
  54. 86 28
      src/components/Profile/TravelOrders/Item.vue
  55. 49 0
      src/components/Profile/TravelOrders/ShareModal/ProjectInfo.vue
  56. 44 0
      src/components/Profile/TravelOrders/ShareModal/Tips.vue
  57. 77 0
      src/components/Profile/TravelOrders/ShareModal/Users.vue
  58. 113 0
      src/components/Profile/TravelOrders/ShareModal/index.vue
  59. 11 2
      src/components/TravelProject/Item.vue
  60. 49 13
      src/components/TravelProjectDetail/BaseInfo.vue
  61. 3 3
      src/components/TravelProjectDetail/BookInfoCalendar.vue
  62. 33 22
      src/components/TravelProjectDetail/BottomBar.vue
  63. 20 11
      src/components/TravelProjectDetail/Details.vue
  64. 281 0
      src/components/TravelProjectDetail/NomalBookModal.vue
  65. 0 0
      src/components/TravelProjectDetail/Old/BookInfo.vue
  66. 0 0
      src/components/TravelProjectDetail/Old/SellingPoint.vue
  67. 0 0
      src/components/TravelProjectDetail/Old/SpecialTips.vue
  68. 55 0
      src/components/TravelProjectDetail/PinTuan/AutoSwiper/AllUserModal.vue
  69. 79 0
      src/components/TravelProjectDetail/PinTuan/AutoSwiper/Item.vue
  70. 99 0
      src/components/TravelProjectDetail/PinTuan/AutoSwiper/index.vue
  71. 93 0
      src/components/TravelProjectDetail/PinTuan/BaseInfoCard.vue
  72. 254 0
      src/components/TravelProjectDetail/PinTuan/BookModal.vue
  73. 54 0
      src/components/TravelProjectDetail/PinTuan/Button.vue
  74. 67 0
      src/components/TravelProjectDetail/PinTuan/CalendarModal.vue
  75. 170 0
      src/components/TravelProjectDetail/PinTuan/Item.vue
  76. 108 0
      src/components/TravelProjectDetail/PinTuan/KaiTuanApplyBottomModal.vue
  77. 56 0
      src/components/TravelProjectDetail/PinTuan/KaiTuanApplyModal.vue
  78. 89 0
      src/components/TravelProjectDetail/PinTuan/ResultModal.vue
  79. 71 0
      src/components/TravelProjectDetail/PinTuan/SharedBottomBar.vue
  80. 65 0
      src/components/TravelProjectDetail/PinTuan/StepPriceCard.vue
  81. 60 0
      src/components/TravelProjectDetail/PinTuan/Users.vue
  82. 108 0
      src/components/TravelProjectDetail/PinTuan/index.vue
  83. 24 0
      src/components/TravelProjectsHome/Banner.vue
  84. 31 0
      src/components/TravelProjectsHome/Comments/Item.vue
  85. 105 0
      src/components/TravelProjectsHome/Comments/index.vue
  86. 112 0
      src/components/TravelProjectsHome/Customize.vue
  87. 43 0
      src/components/TravelProjectsHome/PinTuanProjects/Item.vue
  88. 40 0
      src/components/TravelProjectsHome/PinTuanProjects/index.vue
  89. 130 131
      src/pages/t/[id].client.vue
  90. 14 0
      src/pages/t/index.vue
  91. 4 0
      src/plugins/vue3-seamless-scroll.client.js
  92. 15 0
      src/themeVars.js
  93. 10 1
      src/utils/index.js

+ 2 - 1
.env.development

@@ -1,7 +1,8 @@
 VITE_APP_ENV=development
 
 # VITE_APP_BASE_URL=https://service.xiaoyaotravel.com/api/
-VITE_APP_BASE_URL=http://101.126.146.250:8082/
+VITE_APP_BASE_URL=http://192.168.1.204:8082
 # VITE_APP_BASE_URL=http://192.168.1.204:8082
 VITE_APP_EMOJI_API=https://v.xiaoyaotravel.com/emoji/
+VITE_APP_WEBSITE_BASE_URL=http://101.126.146.250:8086
 VITE_APP_IM_USER_SUFFIX=dev

+ 4 - 0
.env.production

@@ -2,6 +2,10 @@ VITE_APP_ENV=production
 
 # VITE_APP_BASE_URL=https://api.ztzhipin.com/api/
 VITE_APP_BASE_URL=https://service.xiaoyaotravel.com/api/
+
 VITE_APP_EMOJI_API=https://v.xiaoyaotravel.com/emoji/
+
+VITE_APP_WEBSITE_BASE_URL=https://www.xiaoyaotravel.com/
+
 VITE_APP_IM_USER_SUFFIX=''
 

+ 1 - 0
.env.staging

@@ -4,5 +4,6 @@ VITE_APP_ENV=staging
 VITE_APP_BASE_URL=http://101.126.146.250:8088/api/
 
 VITE_APP_IM_USER_SUFFIX=''
+VITE_APP_WEBSITE_BASE_URL=http://101.126.146.250:8086
 
 VITE_APP_EMOJI_API=https://t.xiaoyaotravel.com/emoji/

+ 5 - 1
nuxt.config.ts

@@ -69,7 +69,11 @@ export default defineNuxtConfig({
       // ],
     },
   },
-  css: ["@/assets/css/tailwind.css", "./src/assets/iconfont/iconfont.css"],
+  css: [
+    "@/assets/css/tailwind.css",
+    "./src/assets/iconfont/iconfont.css",
+    "@/assets/css/font.css",
+  ],
   postcss: {
     plugins: {
       tailwindcss: {},

+ 2 - 1
package.json

@@ -26,7 +26,8 @@
     "vue": "latest",
     "vue-cropper": "^1.1.4",
     "vue-draggable-plus": "^0.6.0",
-    "vue-router": "latest"
+    "vue-router": "latest",
+    "vue3-seamless-scroll": "^2.0.1"
   },
   "devDependencies": {
     "@vant/nuxt": "^1.0.6",

+ 16 - 0
pnpm-lock.yaml

@@ -50,6 +50,9 @@ importers:
       vue-router:
         specifier: latest
         version: 4.4.5(vue@3.5.10)
+      vue3-seamless-scroll:
+        specifier: ^2.0.1
+        version: 2.0.1
     devDependencies:
       '@vant/nuxt':
         specifier: ^1.0.6
@@ -3312,6 +3315,10 @@ packages:
   thenify@3.3.1:
     resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
 
+  throttle-debounce@5.0.0:
+    resolution: {integrity: sha512-2iQTSgkkc1Zyk0MeVrt/3BvuOXYPl/R8Z0U2xxo9rjwNciaHDG3R+Lm6dh4EeUci49DanvBnuqI6jshoQQRGEg==}
+    engines: {node: '>=12.22'}
+
   tiny-invariant@1.3.3:
     resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
 
@@ -3647,6 +3654,9 @@ packages:
     peerDependencies:
       vue: ^3.2.0
 
+  vue3-seamless-scroll@2.0.1:
+    resolution: {integrity: sha512-mI3BaDU3pjcPUhVSw3/xNKdfPBDABTi/OdZaZqKysx4cSdNfGRbVvGNDzzptBbJ5S7imv5T55l6x/SqgnxKreg==}
+
   vue@3.5.10:
     resolution: {integrity: sha512-Vy2kmJwHPlouC/tSnIgXVg03SG+9wSqT1xu1Vehc+ChsXsRd7jLkKgMltVEFOzUdBr3uFwBCG+41LJtfAcBRng==}
     peerDependencies:
@@ -7309,6 +7319,8 @@ snapshots:
     dependencies:
       any-promise: 1.3.0
 
+  throttle-debounce@5.0.0: {}
+
   tiny-invariant@1.3.3: {}
 
   tinyglobby@0.2.6:
@@ -7664,6 +7676,10 @@ snapshots:
     dependencies:
       vue: 3.5.10
 
+  vue3-seamless-scroll@2.0.1:
+    dependencies:
+      throttle-debounce: 5.0.0
+
   vue@3.5.10:
     dependencies:
       '@vue/compiler-dom': 3.5.10

+ 1 - 14
src/app.vue

@@ -7,18 +7,5 @@
 </template>
 
 <script setup>
-const themeVars = reactive({
-  primaryColor: "#FD9A00",
-  buttonPrimaryBackground: "#22C2A0",
-  buttonPrimaryBorderColor: "#22C2A0",
-  searchPadding: "0px",
-  dividerLineHeight: "1px",
-  dividerContentPadding: "0",
-  dividerMargin: "0",
-  dividerVerticalMargin: "0",
-  dropdownMenuTitleActiveTextColor: "#ff9300",
-  treeSelectItemActiveColor: "#ff9300",
-  sidebarSelectedBorderColor: "#ff9300",
-  pickerConfirmActionColor: "#ff9300",
-});
+import { themeVars } from "./themeVars";
 </script>

+ 5 - 0
src/assets/css/font.css

@@ -0,0 +1,5 @@
+@font-face {
+  font-family: "YouSheBiaoTiHei";
+  font-weight: "normal";
+  src: url("../font/YouSheBiaoTiHei.woff2") format("woff2");
+}

BIN=BIN
src/assets/font/YouSheBiaoTiHei.woff2


BIN=BIN
src/assets/img/travel_project_home/avatar_1.png


BIN=BIN
src/assets/img/travel_project_home/avatar_2.png


BIN=BIN
src/assets/img/travel_project_home/avatar_3.png


BIN=BIN
src/assets/img/travel_project_home/avatar_4.png


BIN=BIN
src/assets/img/travel_project_home/avatar_5.png


BIN=BIN
src/assets/img/travel_project_home/avatar_6.png


BIN=BIN
src/assets/img/travel_project_home/avatar_7.png


BIN=BIN
src/assets/img/travel_project_home/avatar_8.png


BIN=BIN
src/assets/img/travel_project_home/comments_bg.png


BIN=BIN
src/assets/img/travel_project_home/contrast.png


BIN=BIN
src/assets/img/travel_project_home/icon_tiexinxiangban.png


BIN=BIN
src/assets/img/travel_project_home/icon_wuyouhaiwan.png


BIN=BIN
src/assets/img/travel_project_home/icon_zhuanshudingzhi.png


BIN=BIN
src/assets/img/travel_project_home/icon_zhuanyezhenxuan.png


BIN=BIN
src/assets/img/travel_project_home/kefu_1.png


BIN=BIN
src/assets/img/travel_project_home/kefu_1_qrcode.png


BIN=BIN
src/assets/img/travel_project_home/kefu_2.png


BIN=BIN
src/assets/img/travel_project_home/kefu_2_qrcode.png


BIN=BIN
src/assets/img/travel_project_home/kefu_3.png


BIN=BIN
src/assets/img/travel_project_home/kefu_3_qrcode.png


BIN=BIN
src/assets/img/travel_project_home/travel_project_home_pintuan_bg.png


BIN=BIN
src/assets/img/travel_projects_detail/bg_base_info.png


BIN=BIN
src/assets/img/travel_projects_detail/bg_price.png


BIN=BIN
src/assets/img/travel_projects_detail/bg_user_display.png


BIN=BIN
src/assets/img/travel_projects_detail/calendar.png


BIN=BIN
src/assets/img/travel_projects_detail/kaituan_kefu_qrcode.png


BIN=BIN
src/assets/img/travel_projects_detail/pintuan_01.png


BIN=BIN
src/assets/img/travel_projects_detail/pintuan_02.png


BIN=BIN
src/assets/img/travel_projects_detail/pintuan_03.png


BIN=BIN
src/assets/img/travel_projects_detail/pintuan_cad_bg.png


BIN=BIN
src/assets/img/travel_projects_detail/pintuan_cad_bg_selected.png


BIN=BIN
src/assets/img/travel_projects_detail/pintuan_cad_fire.png


BIN=BIN
src/assets/img/travel_projects_detail/pintuan_cad_fire_selected.png


BIN=BIN
src/assets/img/travel_projects_detail/pintuan_icon.png


BIN=BIN
src/assets/img/travel_projects_detail/pintuan_label.png


BIN=BIN
src/assets/img/travel_projects_detail/pintuan_success.png


BIN=BIN
src/assets/img/travel_projects_detail/recommend_icon.png


BIN=BIN
src/assets/img/travel_projects_detail/travel_detail_maidian copy.png


BIN=BIN
src/assets/img/travel_projects_detail/travel_detail_tip copy.png


BIN=BIN
src/assets/img/travel_projects_detail/tuanduixinxi.png


BIN=BIN
src/assets/img/travel_projects_detail/user_empty.png


BIN=BIN
src/assets/img/travel_projects_detail/woyaokaituan.png


+ 15 - 0
src/components/BaseModal/index.vue

@@ -0,0 +1,15 @@
+<template>
+  <van-popup v-model:show="show" round destroy-on-close teleport="body">
+    <van-config-provider :theme-vars="themeVars">
+      <slot></slot>
+    </van-config-provider>
+  </van-popup>
+</template>
+
+<script setup>
+import { themeVars } from "~/themeVars";
+
+const show = defineModel("show", false);
+</script>
+
+<style lang="scss" scoped></style>

+ 1 - 2
src/components/Home/TravelNotes/index.client.vue

@@ -66,9 +66,8 @@ const travelNotesList = computed(() => data.value?.dataList ?? []);
 
 const containerRef = ref(null);
 const swiper = useSwiper(containerRef, {
-  effect: "creative",
   autoplay: {
-    delay: 5000,
+    delay: 2000,
   },
   freeMode: {
     enabled: true,

+ 86 - 28
src/components/Profile/TravelOrders/Item.vue

@@ -1,43 +1,90 @@
 <template>
   <NuxtLink
     :to="`/profile/travel-order/${itemData.id}`"
-    class="bg-white rounded-xl overflow-hidden text-black-3 text-base"
+    class="bg-white rounded-xl overflow-hidden text-black-3 text-base p-12"
   >
-    <div class="px-15 pt-15">
-      <div class="text-xl">
-        <span class="font-semibold">{{ itemData.projectTitle }}</span>
-        <span class="text-[#FD9A00]">&nbsp;{{ itemData.countTimes }}</span>
-      </div>
-      <div class="mt-5">
-        日期:{{ $dayjs(itemData.startDate).format("YYYY-MM-DD") }}
-      </div>
-      <div class="mt-8">联系人:{{ itemData.customerName }}</div>
-      <div class="flex items-center justify-between">
-        <div class="mt-5">
-          在线付:<span class="text-xl text-[#ff5555] font-semibold"
-            >{{ itemData.totalAmount }}{{ itemData.currency }}</span
-          >
-        </div>
-        <van-button
-          @click.prevent="$emit('onCancel')"
-          type="primary"
-          color="#FD9A00"
-          plain=""
-          size="small"
-          >取消订单</van-button
-        >
-      </div>
+    <div class="text-xl">
+      <span class="font-semibold">{{ itemData.projectTitle }}</span>
+      <span class="text-[#FD9A00]">&nbsp;{{ itemData.countTimes }}</span>
     </div>
+    <div class="mt-5">
+      日期:{{ $dayjs(itemData.startDate).format("YYYY-MM-DD") }}
+    </div>
+    <div class="mt-5">联系人:{{ itemData.customerName }}</div>
+    <div class="mt-5">
+      在线付:<span class="text-xl text-[#ff5555] font-semibold"
+        >{{ itemData.currency }}{{ itemData.totalAmount }}</span
+      >
+    </div>
+
     <div
-      class="bg-[#FD9A00] pl-20 text-sm mt-10 h-35 text-white flex items-center"
+      v-if="itemData.tourProjectGroupPurchase"
+      class="flex items-center mt-5"
     >
-      订单号:{{ itemData.orderNo }}
+      <span>拼团进度:</span>
+      <span
+        >{{ itemData.tourProjectGroupPurchase.nowCount }}/{{
+          itemData.tourProjectGroupPurchase.maxCount
+        }}</span
+      >
+      <div
+        class="ml-10 flex h-21 items-center justify-center rounded-full bg-[#FFF2DD] px-10 text-sm text-primary"
+      >
+        <span
+          v-if="
+            itemData.tourProjectGroupPurchase.maxCount !=
+            itemData.tourProjectGroupPurchase.nowCount
+          "
+          >再拉{{ itemData.tourProjectGroupPurchase.nextStageNum }}人,可优惠{{
+            itemData.tourProjectGroupPurchase.priceUnit
+          }}{{
+            itemData.tourProjectGroupPurchase.nowPrice -
+            itemData.tourProjectGroupPurchase.nextPrice
+          }}</span
+        >
+        <span
+          v-else-if="
+            itemData.tourProjectGroupPurchase.maxCount ==
+            itemData.tourProjectGroupPurchase.nowCount
+          "
+          >拼团成功</span
+        >
+      </div>
     </div>
+
+    <div class="mt-5 mb-10">订单号:{{ itemData.orderNo }}</div>
+    <van-divider />
+    <div class="flex items-center space-x-10 justify-end mt-10">
+      <van-button
+        v-if="
+          itemData.tourProjectGroupPurchase &&
+          itemData.tourProjectGroupPurchase.maxCount !=
+            itemData.tourProjectGroupPurchase.nowCount
+        "
+        type="primary"
+        size="small"
+        @click.prevent="handleShare"
+        >分享有返利</van-button
+      >
+      <van-button
+        @click.prevent="$emit('onCancel')"
+        type="primary"
+        color="#FD9A00"
+        plain=""
+        size="small"
+        >取消订单</van-button
+      >
+    </div>
+    <ProfileTravelOrdersShareModal
+      v-model:show="shareModalOptions.show"
+      :project-id="shareModalOptions.projectId"
+      :pin-tuan-id="shareModalOptions.pinTuanId"
+    />
   </NuxtLink>
 </template>
 
 <script setup>
-defineProps({
+const props = defineProps({
   itemData: {
     type: Object,
     default: () => {},
@@ -45,6 +92,17 @@ defineProps({
 });
 
 defineEmits(["onCancel"]);
+
+const shareModalOptions = reactive({
+  show: false,
+  projectId: "",
+  pinTuanId: "",
+});
+function handleShare() {
+  shareModalOptions.show = true;
+  shareModalOptions.projectId = props.itemData.projectId;
+  shareModalOptions.pinTuanId = props.itemData.tourProjectGroupPurchase.id;
+}
 </script>
 
 <style lang="scss" scoped></style>

+ 49 - 0
src/components/Profile/TravelOrders/ShareModal/ProjectInfo.vue

@@ -0,0 +1,49 @@
+<template>
+  <div class="px-12 flex mt-15 w-full space-x-12">
+    <van-image
+      :src="formatImgSrc(projectData.tourismUrlsAfterConvert)"
+      width="120px"
+      height="120px"
+      fit="cover"
+      class="shrink-0"
+    ></van-image>
+    <div class="flex-1">
+      <div class="text-sm">
+        <span class="text-[#FF0000]">{{ projectData.priceUnit }}</span>
+        <span class="text-[#FF0000] text-3xl">{{
+          priceToArray(projectData.adultPrice)[0]
+        }}</span>
+        <span class="text-[#FF0000]"
+          >.{{ priceToArray(projectData.adultPrice)[1] }}</span
+        >
+        <span>/人起</span>
+      </div>
+      <div class="text-xl mt-5 font-semibold">
+        {{ projectData.projectTitle }}
+      </div>
+      <div class="mt-10 flex flex-wrap items-center gap-7">
+        <div
+          v-for="item in lableList"
+          :key="item"
+          class="flex h-24 bg-[#fff5e6] items-center justify-center rounded border border-current px-10 text-sm text-primary"
+        >
+          {{ item }}
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+const props = defineProps({
+  projectData: {
+    type: Object,
+    default: () => {},
+  },
+});
+const lableList = computed(
+  () => props.projectData.projectLabel?.split("&") ?? []
+);
+</script>
+
+<style lang="scss" scoped></style>

+ 44 - 0
src/components/Profile/TravelOrders/ShareModal/Tips.vue

@@ -0,0 +1,44 @@
+<template>
+  <div class="text-sm text-black-6">
+    <span
+      >你已选择
+      <span class="text-xl font-semibold text-primary">{{
+        pinTuanData.maxCount
+      }}</span>
+      人团,再邀请</span
+    >
+    <span class="text-xl font-semibold text-primary">{{
+      pinTuanData.nextStageNum ?? 0
+    }}</span
+    ><span>人拼团,就可享受</span>
+    <span class="text-base font-semibold text-primary">{{
+      pinTuanData.priceUnit
+    }}</span>
+    <span class="text-xl font-semibold text-primary">{{
+      pinTuanData.nowPrice - pinTuanData.nextPrice
+    }}</span
+    >的优惠;
+    <span>同时,每邀请</span>
+    <span class="text-xl font-semibold text-primary">1</span>
+    <span>人拼团,</span>
+    <span>可得到</span>
+    <span class="text-base font-semibold text-primary">{{
+      pinTuanData.priceUnit
+    }}</span>
+    <span class="text-xl font-semibold text-primary">{{
+      pinTuanData.singleRebate ?? 0
+    }}</span>
+    <span>返利。</span>
+  </div>
+</template>
+
+<script setup>
+const props = defineProps({
+  pinTuanData: {
+    type: Object,
+    default: () => {},
+  },
+});
+</script>
+
+<style lang="scss" scoped></style>

+ 77 - 0
src/components/Profile/TravelOrders/ShareModal/Users.vue

@@ -0,0 +1,77 @@
+<template>
+  <div class="flex flex-col items-center mt-15">
+    <div class="text-xl text-black-3 font-semibold">邀请好友</div>
+    <div class="grid grid-cols-5 gap-y-20 mt-20 w-full">
+      <div
+        class="flex flex-col items-center relative"
+        v-for="item in displayUserListData"
+      >
+        <template v-if="!item.empty">
+          <van-image
+            :src="item.headImageUrl"
+            width="40px"
+            height="40px"
+            round
+          ></van-image>
+          <div
+            v-if="item.teamLeader == '1'"
+            class="text-sm absolute top-30 rounded-sm scale-90 h-16 w-28 flex items-center justify-center bg-[#FDE1B2] text-primary"
+          >
+            团主
+          </div>
+          <span class="text-sm mt-5 text-black-3">{{ item.showName }}</span>
+          <span class="text-sm text-black-9">
+            <template v-if="item.userId == userId">
+              (返利{{ item.priceUnit }}{{ item.rebatePrice ?? 0 }})
+            </template>
+          </span>
+        </template>
+        <template v-else="item.empty">
+          <div
+            class="h-40 w-40 bg-[#F7F8F9] rounded-full flex items-center justify-center"
+          >
+            <span class="iconfont icon-plus" style="font-size: 20px"></span>
+          </div>
+          <span class="text-sm mt-5 text-black-3">虚位以待</span>
+        </template>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+const props = defineProps({
+  userListData: {
+    type: Array,
+    default: () => [],
+  },
+  pinTuanData: {
+    type: Object,
+    default: () => {},
+  },
+});
+
+const displayUserListData = computed(() => {
+  if (props.userListData.length === props.pinTuanData.maxCount) {
+    return userListData.value;
+  } else {
+    const tmpList = [];
+    for (
+      let i = props.userListData.length;
+      i < props.pinTuanData.maxCount;
+      i++
+    ) {
+      tmpList.push({
+        empty: true,
+      });
+    }
+    return [...props.userListData, ...tmpList];
+  }
+});
+
+const userInfoStore = useUserInfoStore();
+const { userInfo } = storeToRefs(userInfoStore);
+const userId = computed(() => userInfo.value?.userId ?? "");
+</script>
+
+<style lang="scss" scoped></style>

+ 113 - 0
src/components/Profile/TravelOrders/ShareModal/index.vue

@@ -0,0 +1,113 @@
+<template>
+  <div>
+    <BaseModal
+      v-model:show="show"
+      position="bottom"
+      style="width: 100%"
+      :round="false"
+    >
+      <div
+        class="flex flex-col items-center pb-50 text-black-3 max-h-[80vh] overflow-y-auto"
+      >
+        <div
+          class="w-full h-45 bg-gradient-to-b from-[#fff0dc] flex items-center justify-center pt-15 to-white"
+        >
+          <img
+            src="~/assets/img/travel_projects_detail/tuanduixinxi.png"
+            class="h-18 object-cover"
+          />
+        </div>
+        <ProfileTravelOrdersShareModalProjectInfo :project-data="projectData" />
+        <div class="w-full h-4 bg-yellow-200 mt-20"></div>
+        <div class="px-20 pt-20">
+          <ProfileTravelOrdersShareModalTips :pin-tuan-data="pinTuanData" />
+          <ProfileTravelOrdersShareModalUsers
+            :pin-tuan-data="pinTuanData"
+            :user-list-data="userListData"
+          />
+          <div class="mt-20 bg-[#f7f8f9] p-10 text-base">
+            <div class="text-black-3">分享链接:</div>
+            <div class="underline break-all text-primary">{{ shareUrl }}</div>
+          </div>
+          <div class="text-sm mt-10 flex items-center space-x-10">
+            <span class="text-black-6">出发日期:</span>
+            <span>{{ pinTuanData.travelStartTime }}</span>
+          </div>
+          <div class="text-sm mt-10 flex items-center space-x-10">
+            <span class="text-black-6">旅游顾问:</span>
+            <span>桃子老师 13997758634</span>
+          </div>
+
+          <van-button
+            @click="handleCopy"
+            type="primary"
+            style="width: 100%; margin-top: 40px"
+            >复制并分享</van-button
+          >
+        </div>
+      </div>
+    </BaseModal>
+  </div>
+</template>
+
+<script setup>
+import { useClipboard } from "@vueuse/core";
+
+const show = defineModel("show", false);
+
+const props = defineProps({
+  projectId: String,
+  pinTuanId: String,
+});
+
+const projectData = ref({});
+async function getProjectData() {
+  const { data } = await request(
+    `website/tourism/project/detail?id=${props.projectId}`
+  );
+  projectData.value = data;
+}
+
+const pinTuanData = ref({});
+async function getPinTuanData() {
+  const { data } = await request(
+    `/website/app/tourProjectGroupPurchase/view?id=${props.pinTuanId}`
+  );
+  pinTuanData.value = data;
+}
+
+const userListData = ref([]);
+async function getUserListData(params) {
+  const { data } = await request(
+    `/website/app/tourProjectGroupPurchase/queryGroupPurchaseUser?groupPurchaseProgressId=${props.pinTuanId}`
+  );
+  userListData.value = data;
+}
+
+watch(
+  () => props.show,
+  () => {
+    if (props.show) {
+      getProjectData();
+      getPinTuanData();
+      getUserListData();
+    }
+  }
+);
+
+const userInfoStore = useUserInfoStore();
+const { userInfo } = storeToRefs(userInfoStore);
+const userId = computed(() => userInfo.value?.userId ?? "");
+
+const shareUrl = computed(() => {
+  return `${import.meta.env.VITE_APP_WEBSITE_BASE_URL}/t/${props.projectId}?pinTuanId=${props.pinTuanId}&fromUserId=${userId.value}`;
+});
+const { copy } = useClipboard({ shareUrl });
+
+function handleCopy() {
+  copy(shareUrl.value);
+  showToast("复制成功");
+}
+</script>
+
+<style lang="scss" scoped></style>

+ 11 - 2
src/components/TravelProject/Item.vue

@@ -20,8 +20,17 @@
         {{ itemData.remarks }}
       </div>
       <div class="text-[#FF2020] flex items-center space-x-5 mt-5">
-        <span>{{ itemData.price }}</span>
-        <span>{{ itemData.priceUnit }}</span>
+        <img
+          v-if="itemData.hasGroup == 1"
+          src="~/assets/img/travel_projects_detail/pintuan_label.png"
+          class="w-55 h-25 object-cover"
+          alt=""
+          srcset=""
+        />
+        <div class="flex items-center space-x-2">
+          <span>{{ itemData.priceUnit }}</span>
+          <span>{{ itemData.price }}</span>
+        </div>
       </div>
     </div>
   </NuxtLink>

+ 49 - 13
src/components/TravelProjectDetail/BaseInfo.vue

@@ -1,27 +1,61 @@
 <template>
-  <div class="p-15">
-    <div class="text-xl font-semibold text-black-3">
-      {{ detailData.projectTitle }}
+  <div class="py-12 px-15 bg-white">
+    <div class="text-xl space-x-5 font-semibold text-black-3 flex items-center">
+      <img
+        src="~/assets/img/travel_projects_detail/recommend_icon.png"
+        class="w-64 h-24"
+      />
+      <div class="flex-1 truncate">
+        {{ detailData.projectTitle }}
+      </div>
+    </div>
+    <div class="text-base mt-5">
+      <span class="text-[#FF2222]">{{ detailData.priceUnit }}</span>
+      <span class="text-[#FF2222] text-3xl font-semibold">{{
+        priceToArray(detailData.price ?? 0)[0]
+      }}</span>
+      <span class="text-[#FF2222] font-semibold"
+        >.{{ priceToArray(detailData.price ?? 0)[1] }}</span
+      >
+      <span>/人起</span>
     </div>
-    <div class="mt-5 flex flex-wrap items-center gap-7">
+    <div class="mt-10 flex flex-wrap items-center gap-7">
       <div
         v-for="item in lableList"
         :key="item"
-        class="flex h-20 items-center justify-center rounded-sm border border-current px-5 text-sm text-primary"
+        class="flex h-24 bg-[#fff5e6] items-center justify-center rounded-sm border border-current px-10 text-sm text-primary"
       >
         {{ item }}
       </div>
     </div>
-    <div class="text-black-6 text-sm mt-10">{{ detailData.remarks }}</div>
-    <div class="mt-10">
-      <span class="text-xl font-semibold text-[#FF2222]">
-        <template v-if="detailData.price"
-          >{{ detailData.price }} {{ detailData.priceUnit }}</template
+    <div class="text-black-6 text-base mt-10 leading-[25px]">
+      <div v-if="!isSellingPointExpand" class="flex items-center">
+        <div
+          v-html="detailData.sellingPoint?.replace(/\n/g, '<br/>')"
+          class="h-25 overflow-hidden flex-1 truncate"
+        ></div>
+        <div
+          @click="isSellingPointExpand = !isSellingPointExpand"
+          class="shrink-0 text-black-9"
         >
-        <template v-else>???? {{ detailData.priceUnit }}</template>
-      </span>
-      <span class="text-black-9">/人起</span>
+          <span>详情</span>
+          <span class="iconfont icon-caret-down"></span>
+        </div>
+      </div>
+      <div v-else>
+        <div v-html="detailData.sellingPoint?.replace(/\n/g, '<br/>')"></div>
+        <div
+          @click="isSellingPointExpand = !isSellingPointExpand"
+          class="flex justify-end text-black-9"
+        >
+          <span>收起</span>
+        </div>
+      </div>
     </div>
+    <!-- <div
+      class="text-black-6 text-base mt-10 leading-[26px]"
+      v-html="detailData.sellingPoint?.replace(/\n/g, '<br/>')"
+    ></div> -->
   </div>
 </template>
 
@@ -36,6 +70,8 @@ const props = defineProps({
 const lableList = computed(
   () => props.detailData.projectLabel?.split("&") ?? []
 );
+
+const isSellingPointExpand = ref(false);
 </script>
 
 <style lang="scss" scoped></style>

+ 3 - 3
src/components/TravelProjectDetail/BookInfoCalendar.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="flex flex-col">
     <div
-      class="flex h-36 w-full items-center justify-center bg-[#FFF8F2] text-sm text-[#FD9A00]"
+      class="flex h-40 w-full items-center justify-center bg-[#FFF8F2] text-sm text-[#FD9A00]"
     >
       以下价格为1成人起价 ·计价可能有延迟,请以下单时为准
     </div>
@@ -22,9 +22,9 @@
           <div class="text-base font-semibold">{{ day.day }}</div>
           <div v-if="isAvailableDate(day.id)" class="scale-90 text-sm">
             <template v-if="calendarData[day.id]?.adultPrice"
-              >¥{{ calendarData[day.id]?.adultPrice }}</template
+              >¥{{ calendarData[day.id]?.adultPrice }}</template
             >
-            <template v-else>¥????</template>
+            <template v-else>¥????</template>
           </div>
         </div>
       </template>

+ 33 - 22
src/components/TravelProjectDetail/BottomBar.vue

@@ -1,39 +1,50 @@
 <template>
   <div
-    class="fixed bottom-0 px-20 left-0 right-0 h-70 z-50 bg-white shadow-[0px_2px_14px_1px_rgba(0,0,0,0.12)] flex justify-center items-center"
+    class="fixed bottom-0 px-15 left-0 right-0 h-70 z-50 bg-white shadow-[0px_2px_14px_1px_rgba(0,0,0,0.12)] flex items-center"
   >
-    <span class="text-black-6">总价:</span>
-    <span class="text-[#FF2222] text-3xl font-semibold"
-      >{{ totalPrice }}{{ detailData.priceUnit }}</span
+    <div
+      @click="handleBook"
+      class="flex items-center rounded-l-xl justify-center h-42 bg-[#FEF4E6] text-primary flex-1"
     >
-    <van-button
-      @click="$emit('onOk')"
-      class="flex-1"
-      :loading="loading"
-      style="
-        background-color: #fd9a00;
-        color: #fff;
-        font-weight: bold;
-        margin-left: 30px;
-      "
-      >立即预定</van-button
+      单独购买
+    </div>
+    <div
+      @click="handleKaituan"
+      class="flex items-center rounded-r-xl justify-center h-42 bg-primary text-white flex-1"
     >
+      我要开团
+    </div>
+    <TravelProjectDetailNomalBookModal
+      v-model:show="nomalBookModalOptions.show"
+    />
+    <TravelProjectDetailPinTuanKaiTuanApplyBottomModal
+      v-model:show="kaiTuanApplyModalOptions.show"
+      :detail-data="detailData"
+    />
   </div>
 </template>
 
 <script setup>
-defineProps({
-  totalPrice: {
-    type: String,
-    default: "",
-  },
-  loading: Boolean,
+const props = defineProps({
   detailData: {
     type: Object,
     default: () => ({}),
   },
 });
-defineEmits(["onOk"]);
+
+const nomalBookModalOptions = reactive({
+  show: false,
+});
+function handleBook() {
+  nomalBookModalOptions.show = true;
+}
+
+const kaiTuanApplyModalOptions = reactive({
+  show: false,
+});
+function handleKaituan() {
+  kaiTuanApplyModalOptions.show = true;
+}
 </script>
 
 <style lang="scss" scoped></style>

+ 20 - 11
src/components/TravelProjectDetail/Details.vue

@@ -1,15 +1,24 @@
 <template>
-  <van-tabs color="#FD9A00">
-    <van-tab title="行程路线">
-      <div v-html="content" class="pt-10"></div>
-    </van-tab>
-    <van-tab title="费用说明">
-      <div v-html="costDescription" class="pt-10"></div>
-    </van-tab>
-    <van-tab title="预定须知">
-      <div v-html="bookingNotice" class="pt-10"></div>
-    </van-tab>
-  </van-tabs>
+  <div class="bg-white">
+    <van-tabs
+      border
+      title-active-color="#FD9A00"
+      title-inactive-color="#333"
+      line-width="16"
+      line-height="3"
+      color="#FD9A00"
+    >
+      <van-tab title="行程路线">
+        <div v-html="content" class="pt-10"></div>
+      </van-tab>
+      <van-tab title="费用说明">
+        <div v-html="costDescription" class="pt-10"></div>
+      </van-tab>
+      <van-tab title="预定须知">
+        <div v-html="bookingNotice" class="pt-10"></div>
+      </van-tab>
+    </van-tabs>
+  </div>
 </template>
 
 <script setup>

+ 281 - 0
src/components/TravelProjectDetail/NomalBookModal.vue

@@ -0,0 +1,281 @@
+<template>
+  <div>
+    <van-popup
+      title="单独购买"
+      v-model:show="show"
+      position="bottom"
+      style="height: 580px"
+      round
+      closeable
+      destroy-on-close
+      safe-area-inset-bottom
+      @closed="cleanBookInfo"
+    >
+      <div class="flex flex-col h-full">
+        <div
+          class="flex items-center justify-center text-xl text-black-3 font-semibold py-15"
+        >
+          单独购买
+        </div>
+        <van-form class="flex-1">
+          <van-cell-group>
+            <van-field name="出发日期" label="出发日期">
+              <template #input>
+                <div
+                  @click="showCalendarPicker = true"
+                  class="flex w-full items-center justify-between"
+                >
+                  <div>
+                    <span v-if="bookInfo.startDate">{{
+                      bookInfo.startDate
+                    }}</span>
+                    <span v-else="bookInfo.startDate" class="text-[#c8c9cc]"
+                      >请选择出发日期</span
+                    >
+                  </div>
+                  <span class="iconfont icon-right"></span>
+                </div>
+              </template>
+            </van-field>
+            <van-field name="成人数量" label="成人数量">
+              <template #input>
+                <van-stepper
+                  v-model="bookInfo.adultNumber"
+                  theme="round"
+                  button-size="20"
+                  min="1"
+                  max="9"
+                  disable-input
+                  style="--van-stepper-button-round-theme-color: #fd9a00"
+                />
+              </template>
+            </van-field>
+            <van-field name="儿童数量" label="儿童数量">
+              <template #input>
+                <van-stepper
+                  v-model="bookInfo.childrenNumber"
+                  theme="round"
+                  min="0"
+                  max="9"
+                  button-size="20"
+                  disable-input
+                  style="--van-stepper-button-round-theme-color: #fd9a00"
+                />
+              </template>
+            </van-field>
+            <van-field
+              v-model="bookInfo.customerName"
+              required
+              name="联系人"
+              label="联系人"
+              placeholder="请输入联系人"
+              :rules="[{ required: true, message: '请输入联系人' }]"
+            />
+            <van-field
+              v-model="bookInfo.customerMobile"
+              required
+              type="tel"
+              name="联系电话"
+              label="联系电话"
+              placeholder="请输入联系电话"
+              :rules="[{ required: true, message: '请输入联系电话' }]"
+            />
+            <van-field
+              v-model="bookInfo.customerWechat"
+              required
+              name="微信号"
+              label="微信号"
+              placeholder="请输入微信号"
+              :rules="[{ required: true, message: '请输入微信号' }]"
+            />
+            <van-field
+              v-model="bookInfo.customerMobileStandby"
+              name="备用电话"
+              label="备用电话"
+              placeholder="请输入备用联系电话"
+            />
+          </van-cell-group>
+        </van-form>
+
+        <div class="h-70 flex items-center justify-between space-x-40 px-15">
+          <div class="text-[#FF0000] w-1/3 text-sm">
+            <span>¥</span>
+            <span class="text-4xl">{{ priceToArray(totalPrice ?? 0)[0] }}</span>
+            <span>.{{ priceToArray(totalPrice ?? 0)[1] }}</span>
+          </div>
+          <van-button
+            @click="handleSubmit"
+            class="flex-1"
+            style="height: 40px"
+            type="primary"
+            :loading="submitLoading"
+            >提交</van-button
+          >
+        </div>
+      </div>
+    </van-popup>
+    <van-popup
+      v-model:show="showCalendarPicker"
+      destroy-on-close
+      round
+      position="bottom"
+      safe-area-inset-bottom
+    >
+      <TravelProjectDetailBookInfoCalendar
+        v-model:date="bookInfo.startDate"
+        :calendar-data="calendarData"
+        @on-close="showCalendarPicker = false"
+      />
+    </van-popup>
+  </div>
+</template>
+
+<script setup>
+const props = defineProps({
+  startDate: {
+    type: String,
+    default: null,
+  },
+  adultNumber: {
+    type: Number,
+    default: 1,
+  },
+  childrenNumber: {
+    type: Number,
+    default: 0,
+  },
+});
+
+const show = defineModel("show", false);
+
+const id = useRouteParam("id");
+
+const dayjs = useDayjs();
+
+const bookInfo = reactive({
+  startDate: null,
+  adultNumber: 1,
+  childrenNumber: 0,
+  customerName: "",
+  customerMobile: "",
+  customerMobileStandby: "",
+  customerWechat: "",
+});
+
+watchEffect(() => {
+  bookInfo.startDate = props.startDate;
+  bookInfo.adultNumber = props.adultNumber;
+  bookInfo.childrenNumber = props.childrenNumber;
+});
+
+function cleanBookInfo() {
+  bookInfo.startDate = null;
+  bookInfo.adultNumber = 1;
+  bookInfo.childrenNumber = 0;
+  bookInfo.customerName = "";
+  bookInfo.customerMobile = "";
+  bookInfo.customerMobileStandby = "";
+  bookInfo.customerWechat = "";
+}
+
+const showCalendarPicker = ref(false);
+
+const calendarData = ref({});
+
+async function getCalendarData() {
+  const { data } = await request("/website/tourism/project/viewDatePrice", {
+    query: {
+      projectId: id.value,
+    },
+  });
+  calendarData.value = data.tourismProjectDatePriceVos ?? {};
+  if (bookInfo.startDate) return;
+  const minDay = dayjs.min(
+    Object.keys(calendarData.value).map((e) => dayjs(e))
+  );
+  bookInfo.startDate =
+    minDay === null ? null : dayjs(minDay).format("YYYY-MM-DD");
+}
+
+// 成人总价
+const adultTotalPrice = computed(() => {
+  const calendarInfo = calendarData.value[bookInfo.startDate] ?? {
+    adultPrice: 0,
+    childrenPrice: 0,
+  };
+  return (bookInfo.adultNumber ?? 0) * calendarInfo.adultPrice;
+});
+
+// 儿童总价
+const childrenTotalPrice = computed(() => {
+  const calendarInfo = calendarData.value[bookInfo.startDate] ?? {
+    adultPrice: 0,
+    childrenPrice: 0,
+  };
+  return (bookInfo.childrenNumber ?? 0) * calendarInfo.childrenPrice;
+});
+
+// 总价
+const totalPrice = computed(
+  () => adultTotalPrice.value + childrenTotalPrice.value
+);
+
+const resultModalOptions = reactive({
+  show: false,
+  title: "",
+  subTitle: "",
+});
+
+const submitLoading = ref(false);
+function handleSubmit() {
+  if (!bookInfo.startDate) {
+    showToast("请选择日期");
+    return;
+  }
+  if (!bookInfo.customerName) {
+    showToast("请填写联系人");
+    return;
+  }
+  if (!bookInfo.customerMobile) {
+    showToast("请填写联系电话");
+    return;
+  }
+  if (!bookInfo.customerWechat) {
+    showToast("请填写微信号");
+    return;
+  }
+  submitLoading.value = true;
+  request("/website/tourism/myOrder/add", {
+    method: "post",
+    body: {
+      projectId: id.value,
+      type: "1",
+      ...bookInfo,
+    },
+  })
+    .then(({ data }) => {
+      show.value = false;
+      submitLoading.value = false;
+      showDialog({
+        title: "预定成功",
+        message:
+          "因为旅游预定涉及较多的环节,我们需要线下和您沟通,我们会尽快与你联系并为您提供最真诚的服务。",
+      }).then(() => {});
+    })
+    .catch(() => {
+      submitLoading.value = false;
+    });
+}
+
+watch(
+  show,
+  () => {
+    if (show.value) {
+      getCalendarData();
+    }
+  },
+  { immediate: true }
+);
+</script>
+
+<style lang="scss" scoped></style>

+ 0 - 0
src/components/TravelProjectDetail/BookInfo.vue → src/components/TravelProjectDetail/Old/BookInfo.vue


+ 0 - 0
src/components/TravelProjectDetail/SellingPoint.vue → src/components/TravelProjectDetail/Old/SellingPoint.vue


+ 0 - 0
src/components/TravelProjectDetail/SpecialTips.vue → src/components/TravelProjectDetail/Old/SpecialTips.vue


+ 55 - 0
src/components/TravelProjectDetail/PinTuan/AutoSwiper/AllUserModal.vue

@@ -0,0 +1,55 @@
+<template>
+  <div>
+    <BaseModal
+      v-model:show="show"
+      round
+      destroy-on-close
+      closeable
+      style="width: 80%"
+    >
+      <div class="flex flex-col">
+        <div
+          class="text-xl shrink-0 flex items-center justify-center text-black-3 font-semibold py-10 border-b"
+        >
+          参与拼单人员
+        </div>
+        <div
+          class="flex-1 overflow-auto flex flex-col space-y-12 py-12 max-h-400"
+        >
+          <div
+            v-for="item in userList"
+            :key="item.id"
+            class="flex items-center px-12"
+          >
+            <van-image
+              :src="item.headImageUrl"
+              round
+              width="40"
+              height="40"
+              fit="cover"
+            />
+            <span class="flex-1 ml-12 truncate text-base text-black-3"
+              >{{ item.showName
+              }}{{
+                item.peopleNumber > 1 ? `(${item.peopleNumber}人)` : ""
+              }}</span
+            >
+            <span class="text-base text-black-9">参与了拼团</span>
+          </div>
+        </div>
+      </div>
+    </BaseModal>
+  </div>
+</template>
+
+<script setup>
+const show = defineModel("show", false);
+defineProps({
+  userList: {
+    type: Array,
+    default: () => [],
+  },
+});
+</script>
+
+<style lang="scss" scoped></style>

+ 79 - 0
src/components/TravelProjectDetail/PinTuan/AutoSwiper/Item.vue

@@ -0,0 +1,79 @@
+<template>
+  <div class="flex items-center box-border h-65 px-10 realative">
+    <div class="relative w-60 h-32">
+      <img
+        v-for="(item, index) in avatarList"
+        :src="item"
+        class="w-32 h-32 object-cover rounded-full border border-white absolute top-0 bottom-0"
+        :style="{ left: `${index * 15}px`, zIndex: 500 - index }"
+        alt=""
+      />
+    </div>
+    <span class="text-base text-black-3 ml-10"
+      >{{ itemData.maxCount }}人成团·余{{
+        itemData.maxCount - itemData.nowCount
+      }}人</span
+    >
+    <span class="flex-1"></span>
+    <div class="text-sm w-80 flex flex-col items-center">
+      <template v-if="needShowCountDown">
+        <div class="text-black-6">剩余时间</div>
+        <van-count-down :time="time">
+          <template #default="timeData">
+            <div class="text-[#FF0000] text-sm">
+              <span>{{ timeData.days }}</span>
+              <span>:</span>
+              <span>{{ timeData.hours }}</span>
+              <span>:</span>
+              <span>{{ timeData.minutes }}</span>
+              <span>:</span>
+              <span>{{ timeData.seconds }}</span>
+            </div>
+          </template>
+        </van-count-down>
+      </template>
+      <template v-else>
+        <div class="text-sm">截止日期</div>
+        <div class="text-sm text-[#FF0000]">{{ itemData.endTime }}</div>
+      </template>
+    </div>
+    <TravelProjectDetailPinTuanButton
+      :pin-tuan-data="itemData"
+      :project-data="projectData"
+    />
+  </div>
+</template>
+
+<script setup>
+const props = defineProps({
+  itemData: {
+    type: Object,
+    default: () => ({}),
+  },
+  projectData: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+
+const dayjs = useDayjs();
+
+const avatarList = computed(() => props.itemData.usersAvatarList.slice(0, 3));
+
+const time = ref(30 * 60 * 60 * 1000);
+
+// 计算拼团截止日期与当前日期的时间差
+const timestampDiff = computed(() => {
+  const endTimeTimestamp = dayjs(props.itemData.endTime)
+    .add(1, "day")
+    .valueOf();
+  const nowTimestamp = dayjs().valueOf();
+  return endTimeTimestamp - nowTimestamp;
+});
+
+const needShowCountDown = computed(
+  () => timestampDiff.value <= 24 * 60 * 60 * 3 * 1000
+);
+</script>
+
+<style lang="scss" scoped></style>

+ 99 - 0
src/components/TravelProjectDetail/PinTuan/AutoSwiper/index.vue

@@ -0,0 +1,99 @@
+<template>
+  <div class="mx-12 flex flex-col px-5 pt-5 rounded-lg space-y-5 bg-[#FFA725]">
+    <div class="flex items-center px-12 text-white">
+      <img
+        src="~/assets/img/travel_projects_detail/pintuan_icon.png"
+        class="w-14 h-14"
+      />
+      <span class="font-youSheBiaoti font-semibold ml-5 flex-1"
+        >正在拼团...</span
+      >
+      <div @click="handleShwoMore" class="flex items-center">
+        <span class="text-sm"
+          >{{ moreUsersOptions.userList.length }}人成功拼团</span
+        >
+        <span class="iconfont icon-right" style="font-size: 12px"></span>
+      </div>
+    </div>
+    <div
+      class="bg-white rounded h-130 overflow-hidden"
+      :class="[pinTuanListData.length > 1 ? 'h-130' : 'h-75']"
+    >
+      <van-swipe
+        :autoplay="3000"
+        :show-indicators="false"
+        :touchable="false"
+        vertical
+        style="height: 130px"
+      >
+        <van-swipe-item v-for="subList in pinTuanListData">
+          <div class="flex flex-col">
+            <TravelProjectDetailPinTuanAutoSwiperItem
+              :item-data="subList[0]"
+              :project-data="projectData"
+            />
+            <TravelProjectDetailPinTuanAutoSwiperItem
+              v-if="subList.length > 1"
+              :item-data="subList[1]"
+              :project-data="projectData"
+            />
+          </div>
+        </van-swipe-item>
+      </van-swipe>
+    </div>
+    <TravelProjectDetailPinTuanAutoSwiperAllUserModal
+      v-model:show="moreUsersOptions.show"
+      :user-list="moreUsersOptions.userList"
+    />
+  </div>
+</template>
+
+<script setup>
+defineProps({
+  projectData: {
+    type: Object,
+    default: () => {},
+  },
+});
+
+const id = useRouteParam("id");
+
+const moreUsersOptions = reactive({
+  show: false,
+  userList: [],
+});
+
+function handleShwoMore() {
+  moreUsersOptions.show = true;
+}
+
+async function getUsers() {
+  const { data } = await request(
+    `/website/app/tourProjectGroupPurchase/queryGroupPurchaseUser?projectId=${id.value}`
+  );
+  moreUsersOptions.userList = data;
+}
+
+const pinTuanListData = ref([]);
+async function getPinTuanList() {
+  const { data } = await request("/website/app/tourProjectGroupPurchase/list", {
+    query: {
+      pageNum: 1,
+      projectId: id.value,
+      nowCount: 1,
+    },
+  });
+  const tmpList = data.dataList;
+  for (let i = 0; i < tmpList.length; i += 2) {
+    const group = tmpList.slice(i, i + 2);
+    pinTuanListData.value.push(group);
+  }
+}
+
+onMounted(() => {
+  getPinTuanList();
+  getUsers();
+});
+</script>
+
+<style lang="scss" scoped></style>

+ 93 - 0
src/components/TravelProjectDetail/PinTuan/BaseInfoCard.vue

@@ -0,0 +1,93 @@
+<template>
+  <div class="p-12 text-base bg-[#fefbf5]">
+    <div class="flex items-center space-x-5 text-xl font-semibold text-black-3">
+      <img
+        src="~/assets/img/travel_projects_detail/pintuan_01.png"
+        class="w-18"
+        alt=""
+        srcset=""
+      />
+      <span class="text-xl">基本信息</span>
+    </div>
+    <div v-if="infoData" class="mt-10 flex flex-col space-y-8">
+      <div>
+        <span class="label">标题:</span>
+        <span class="content">{{ infoData.title }}</span>
+      </div>
+      <div>
+        <span class="label">人数:</span>
+        <span class="content">{{ infoData.maxCount }}人团</span>
+      </div>
+      <div class="flex items-center">
+        <div class="label">进度:</div>
+        <van-progress
+          :percentage="(infoData.nowCount / infoData.maxCount) * 100"
+          stroke-width="8"
+          style="width: 100px"
+          color="#FD9A00"
+          track-color="#fee8c4"
+          pivot-text=""
+        />
+        <span class="content ml-10"
+          >{{ infoData.nowCount ?? 0 }}/{{ infoData.maxCount ?? 0 }}</span
+        >
+      </div>
+      <div>
+        <span class="label">出发时间:</span>
+        <span class="content">{{
+          $dayjs(infoData.travelStartTime).format("YYYY年MM月DD日")
+        }}</span>
+      </div>
+      <div>
+        <span class="label">拼团截止日期:</span>
+        <span class="content">{{
+          $dayjs(infoData.endTime).format("YYYY年MM月DD日")
+        }}</span>
+      </div>
+      <div>
+        <span class="label">产品原价:</span>
+        <span class="price">
+          <span class="text-sm">{{ infoData.priceUnit }}</span>
+          <span>{{ infoData.originalPrice }}</span>
+        </span>
+      </div>
+      <div>
+        <span class="label">当前价格:</span>
+        <span class="price">
+          <span class="text-sm">{{ infoData.priceUnit }}</span>
+          <span>{{ infoData.nowPrice }}</span>
+        </span>
+      </div>
+      <div>
+        <span class="label">最低价格:</span>
+        <span class="price">
+          <span class="text-sm">{{ infoData.priceUnit }}</span>
+          <span>{{ infoData.adultPrice }}</span>
+        </span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+const props = defineProps({
+  infoData: {
+    type: Object,
+    default: () => {},
+  },
+});
+</script>
+
+<style lang="scss" scoped>
+.label {
+  @apply text-base text-black-6;
+}
+
+.content {
+  @apply text-base text-black-3;
+}
+
+.price {
+  @apply text-xl font-semibold text-[#FF0000];
+}
+</style>

+ 254 - 0
src/components/TravelProjectDetail/PinTuan/BookModal.vue

@@ -0,0 +1,254 @@
+<template>
+  <div>
+    <BaseModal
+      v-model:show="show"
+      position="bottom"
+      round
+      closeable
+      safe-area-inset-bottom
+      @closed="cleanBookInfo"
+      teleport="body"
+    >
+      <div class="flex flex-col h-[550px]">
+        <div
+          class="flex items-center justify-center text-xl text-black-3 font-semibold py-15"
+        >
+          立即拼团
+        </div>
+        <van-form class="flex-1">
+          <van-cell-group>
+            <van-field name="目的地" label="目的地">
+              <template #input>{{ projectData.endPlace }}</template>
+            </van-field>
+            <van-field name="成团人数" label="成团人数">
+              <template #input>{{ pinTuanData.maxCount }}人成团</template>
+            </van-field>
+            <van-field name="出发日期" label="出发日期">
+              <template #input>{{ pinTuanData.travelStartTime }}</template>
+            </van-field>
+            <van-field name="成人数量" label="成人数量">
+              <template #input>
+                <van-stepper
+                  v-model="bookInfo.adultNumber"
+                  theme="round"
+                  button-size="20"
+                  min="1"
+                  :max="pinTuanData.maxCount - pinTuanData.nowCount"
+                  disable-input
+                  style="--van-stepper-button-round-theme-color: #fd9a00"
+                />
+              </template>
+            </van-field>
+            <van-field name="儿童数量" label="儿童数量">
+              <template #input>
+                <van-stepper
+                  v-model="bookInfo.childrenNumber"
+                  theme="round"
+                  min="0"
+                  :max="pinTuanData.maxCount - pinTuanData.nowCount"
+                  button-size="20"
+                  disable-input
+                  style="--van-stepper-button-round-theme-color: #fd9a00"
+                />
+              </template>
+            </van-field>
+            <van-field
+              v-model="bookInfo.customerName"
+              required
+              name="联系人"
+              label="联系人"
+              placeholder="请输入联系人"
+              :rules="[{ required: true, message: '请输入联系人' }]"
+            />
+            <van-field
+              v-model="bookInfo.customerMobile"
+              required
+              type="tel"
+              name="联系电话"
+              label="联系电话"
+              placeholder="请输入联系电话"
+              :rules="[{ required: true, message: '请输入联系电话' }]"
+            />
+            <van-field
+              v-model="bookInfo.customerWechat"
+              required
+              name="微信号"
+              label="微信号"
+              placeholder="请输入微信号"
+              :rules="[{ required: true, message: '请输入微信号' }]"
+            />
+            <van-field
+              v-model="bookInfo.customerMobileStandby"
+              name="备用电话"
+              label="备用电话"
+              placeholder="请输入备用联系电话"
+            />
+          </van-cell-group>
+        </van-form>
+
+        <div class="h-70 flex items-center justify-between space-x-40 px-15">
+          <div class="text-[#FF0000] w-1/3 text-sm">
+            <span>¥</span>
+            <span class="text-4xl">{{ priceToArray(totalPrice ?? 0)[0] }}</span>
+            <span>.{{ priceToArray(totalPrice ?? 0)[1] }}</span>
+          </div>
+          <van-button
+            @click="handleSubmit"
+            class="flex-1"
+            style="height: 40px"
+            type="primary"
+            :loading="submitLoading"
+            >提交</van-button
+          >
+        </div>
+      </div>
+    </BaseModal>
+    <TravelProjectDetailPinTuanKaiTuanApplyModal
+      v-model:show="applyKaiTuanModalOptions.show"
+      :title="applyKaiTuanModalOptions.title"
+    />
+    <TravelProjectDetailPinTuanResultModal
+      v-model:show="resultModalOptions.show"
+      :pin-tuan-info="resultModalOptions.data"
+    />
+  </div>
+</template>
+
+<script setup>
+const props = defineProps({
+  pinTuanData: {
+    type: Object,
+    default: () => {},
+  },
+  projectData: {
+    type: Object,
+    default: () => {},
+  },
+});
+
+const fromUserId = useRouteQuery("fromUserId");
+
+const show = defineModel("show", false);
+
+const id = useRouteParam("id");
+
+const dayjs = useDayjs();
+
+const bookInfo = reactive({
+  startDate: null,
+  adultNumber: 1,
+  childrenNumber: 0,
+  customerName: "",
+  customerMobile: "",
+  customerMobileStandby: "",
+  customerWechat: "",
+});
+
+function cleanBookInfo() {
+  bookInfo.startDate = null;
+  bookInfo.adultNumber = 1;
+  bookInfo.childrenNumber = 0;
+  bookInfo.customerName = "";
+  bookInfo.customerMobile = "";
+  bookInfo.customerMobileStandby = "";
+  bookInfo.customerWechat = "";
+}
+
+const totalPrice = ref(0);
+const loading = ref(false);
+async function calcTotalPrice() {
+  try {
+    loading.value = true;
+    const { data } = await request(
+      "/website/app/tourProjectGroupPurchase/calcTotalAmount",
+      {
+        query: {
+          id: props.pinTuanData.id,
+          adultCount: bookInfo.adultNumber ?? 0,
+          childrenCount: bookInfo.childrenNumber ?? 0,
+        },
+      }
+    );
+    totalPrice.value = data.totalPrice ?? 0;
+    loading.value = false;
+  } catch (error) {
+    loading.value = false;
+  }
+}
+
+watch(
+  [() => bookInfo.adultNumber, () => bookInfo.childrenNumber, show],
+  () => {
+    if (!show.value) return;
+    calcTotalPrice();
+  },
+  {
+    deep: true,
+  }
+);
+
+const applyKaiTuanModalOptions = reactive({
+  show: false,
+  title: "",
+});
+
+const resultModalOptions = reactive({
+  show: false,
+  data: {},
+});
+
+const submitLoading = ref(false);
+async function handleSubmit() {
+  if (!bookInfo.customerName) {
+    showToast("请填写联系人");
+    return;
+  }
+  if (!bookInfo.customerMobile) {
+    showToast("请填写联系电话");
+    return;
+  }
+  if (!bookInfo.customerWechat) {
+    showToast("请填写微信号");
+    return;
+  }
+  try {
+    submitLoading.value = true;
+    // 验证是否超过最大人数
+    const { data } = await request(
+      `/website/app/tourProjectGroupPurchase/view?id=${props.pinTuanData.id}`
+    );
+    if (
+      // 超出团购数量
+      data.nowCount +
+        (bookInfo.adultNumber ?? 0) +
+        (bookInfo.childrenNumber ?? 0) >
+      data.maxCount
+    ) {
+      applyKaiTuanModalOptions.show = true;
+      applyKaiTuanModalOptions.title = `此团库存仅剩余${data.maxCount - data.nowCount}位,若需要开团请直接联系客服为你新开拼团`;
+      submitLoading.value = false;
+      return;
+    }
+    const { data: resData } = await request("/website/tourism/myOrder/add", {
+      method: "post",
+      body: {
+        projectId: id.value,
+        type: "1",
+        ...bookInfo,
+        startDate: props.pinTuanData.travelStartTime,
+        groupId: props.pinTuanData.id,
+        childrenNumber: bookInfo.childrenNumber ?? 0,
+        shareId: fromUserId.value,
+      },
+    });
+    submitLoading.value = false;
+    show.value = false;
+    resultModalOptions.data = resData;
+    resultModalOptions.show = true;
+  } catch (error) {
+    submitLoading.value = false;
+  }
+}
+</script>
+
+<style lang="scss" scoped></style>

+ 54 - 0
src/components/TravelProjectDetail/PinTuan/Button.vue

@@ -0,0 +1,54 @@
+<template>
+  <div>
+    <van-button
+      @click="handlePinTuan"
+      type="primary"
+      size="small"
+      style="font-size: 14px"
+      >直接拼</van-button
+    >
+    <TravelProjectDetailPinTuanKaiTuanApplyModal
+      v-model:show="applyKaiTuanOptions.show"
+      :title="applyKaiTuanOptions.title"
+    />
+    <TravelProjectDetailPinTuanBookModal
+      v-model:show="bookModalOptions.show"
+      :pin-tuan-data="pinTuanData"
+      :project-data="projectData"
+    />
+  </div>
+</template>
+
+<script setup>
+const props = defineProps({
+  pinTuanData: {
+    type: Object,
+    default: () => ({}),
+  },
+  projectData: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+
+const applyKaiTuanOptions = reactive({
+  show: false,
+  title: "",
+});
+
+const bookModalOptions = reactive({
+  show: false,
+});
+function handlePinTuan() {
+  if (props.pinTuanData.nowCount == props.pinTuanData.maxCount) {
+    // 满员提示
+    applyKaiTuanOptions.show = true;
+    applyKaiTuanOptions.title =
+      "此团已满员,若需要开团请直接联系客服为你新开拼团";
+    return;
+  }
+  bookModalOptions.show = true;
+}
+</script>
+
+<style lang="scss" scoped></style>

+ 67 - 0
src/components/TravelProjectDetail/PinTuan/CalendarModal.vue

@@ -0,0 +1,67 @@
+<template>
+  <div>
+    <van-popup
+      v-model:show="show"
+      destroy-on-close
+      round
+      position="bottom"
+      safe-area-inset-bottom
+    >
+      <div class="flex flex-col">
+        <div
+          class="flex h-40 w-full items-center justify-center bg-[#FFF8F2] text-sm text-[#FD9A00]"
+        >
+          计价可能有延迟,请以下单时为准
+        </div>
+        <VCalendar expanded borderless>
+          <template #day-content="{ day }">
+            <div
+              class="flex h-60 w-full flex-col items-center rounded-md pt-10 transition-all"
+              :class="[
+                { 'text-black-c': !isAvailableDate(day.id) },
+                {
+                  'hover:bg-primary hover:text-white': isAvailableDate(day.id),
+                },
+                isAvailableDate(day.id)
+                  ? 'cursor-pointer'
+                  : 'cursor-not-allowed',
+                $dayjs(currentDate).isSame(day.date, 'day')
+                  ? 'bg-primary text-white'
+                  : 'bg-transparent text-black-3',
+              ]"
+              @click="isAvailableDate(day.id) && handleDayClick(day)"
+            >
+              <div class="text-base font-semibold">{{ day.day }}</div>
+              <div v-if="isAvailableDate(day.id)" class="scale-90 text-sm">
+                有团
+              </div>
+            </div>
+          </template>
+        </VCalendar>
+      </div>
+    </van-popup>
+  </div>
+</template>
+
+<script setup>
+const show = defineModel("show", false);
+const currentDate = defineModel("currentDate", false);
+
+const props = defineProps({
+  availableDateList: {
+    type: Array,
+    default: () => [],
+  },
+});
+
+function isAvailableDate(date) {
+  return props.availableDateList.includes(date);
+}
+
+function handleDayClick(data) {
+  currentDate.value = data.id;
+  show.value = false;
+}
+</script>
+
+<style lang="scss" scoped></style>

+ 170 - 0
src/components/TravelProjectDetail/PinTuan/Item.vue

@@ -0,0 +1,170 @@
+<template>
+  <div>
+    <div class="flex items-center">
+      <van-image
+        :src="itemData.splicingAvatars"
+        width="48px"
+        height="48px"
+        fit="cover"
+        round
+        class="shrink-0"
+      />
+      <div class="flex-1 ml-5">
+        <div class="text-base text-black-3">
+          {{ itemData.travelStartTime }}
+        </div>
+        <div class="text-sm mt-2 text-black-9">
+          {{ itemData.maxCount }}人成团·余{{
+            itemData.maxCount - (itemData.nowCount ?? 0)
+          }}人
+        </div>
+      </div>
+      <div class="text-sm text-[#FF0000] pr-10">
+        <span class="text-black-9">最低</span>
+        <span>¥</span>
+        <span class="text-2xl">{{ priceToArray(itemData.adultPrice)[0] }}</span>
+        <span>.{{ priceToArray(itemData.adultPrice)[1] }}</span>
+      </div>
+      <TravelProjectDetailPinTuanButton
+        :pin-tuan-data="itemData"
+        :project-data="projectData"
+      />
+    </div>
+    <div
+      class="h-38 mt-10 rounded bg-[#F7F7F7] flex items-center px-10 text-base text-black-3 justify-between"
+    >
+      <div v-if="!needShowCountDown" class="text-base text-black-3">
+        截止日期:{{ itemData.endTime }}
+      </div>
+      <div v-else-if="needShowCountDown" class="flex items-center space-x-5">
+        <span>剩余时间:</span>
+        <van-count-down :time="timestampDiff">
+          <template #default="timeData">
+            <div class="flex items-center text-[#FF133E]">
+              <div
+                class="w-23 h-20 bg-[#f8e2e2] rounded-sm flex items-center justify-center"
+              >
+                {{ timeData.hours + 24 * timeData.days }}
+              </div>
+              <div class="px-5">:</div>
+              <div
+                class="w-23 h-20 bg-[#f8e2e2] rounded-sm flex items-center justify-center"
+              >
+                {{ timeData.minutes }}
+              </div>
+              <div class="px-5">:</div>
+              <div
+                class="w-23 h-20 bg-[#f8e2e2] rounded-sm flex items-center justify-center"
+              >
+                {{ timeData.seconds }}
+              </div>
+            </div>
+          </template>
+        </van-count-down>
+      </div>
+      <div
+        @click="toggleExpanded"
+        class="flex items-center justify-center text-black-9"
+        :class="[expanded ? 'text-primary' : 'text-black-3']"
+      >
+        <span>详情</span>
+        <span
+          class="iconfont icon-right"
+          :class="expanded ? 'rotate-90' : ''"
+          style="font-size: 12px"
+        ></span>
+      </div>
+    </div>
+    <div v-if="expanded">
+      <TravelProjectDetailPinTuanBaseInfoCard :info-data="baseInfoData" />
+      <TravelProjectDetailPinTuanStepPriceCard
+        :data-list="stepPriceList"
+        :max-count="itemData.maxCount"
+      />
+      <TravelProjectDetailPinTuanUsers :list-data="userList" />
+    </div>
+  </div>
+</template>
+
+<script setup>
+const props = defineProps({
+  itemData: {
+    type: Object,
+    default: () => {},
+  },
+  projectData: {
+    type: Object,
+    default: () => {},
+  },
+});
+
+const dayjs = useDayjs();
+
+// 计算拼团截止日期与当前日期的时间差
+const timestampDiff = computed(() => {
+  const endTimeTimestamp = dayjs(props.itemData.endTime)
+    .add(1, "day")
+    .valueOf();
+  const nowTimestamp = dayjs().valueOf();
+  return endTimeTimestamp - nowTimestamp;
+});
+
+const needShowCountDown = computed(
+  () => timestampDiff.value <= 24 * 60 * 60 * 3 * 1000
+);
+
+// 获取基本信息
+const baseInfoData = ref({});
+async function getPinTuanBaseInfo() {
+  return await request(
+    `/website/app/tourProjectGroupPurchase/view?id=${props.itemData.id}`
+  );
+}
+
+const stepPriceList = ref([]);
+async function getStepPriceList() {
+  return await request(
+    `/website/app/tourProjectGroupPurchaseDetail/list?groupPurchaseCode=${props.itemData.code}`
+  );
+}
+
+const userList = ref([]);
+async function getUserList() {
+  return await request(
+    `/website/app/tourProjectGroupPurchase/queryGroupPurchaseUser?groupPurchaseProgressId=${props.itemData.id}`
+  );
+}
+
+async function getExpandedData() {
+  try {
+    showLoadingToast({
+      message: "加载中...",
+      duration: 0,
+    });
+    const [{ data: data1 }, { data: data2 }, { data: data3 }] =
+      await Promise.all([
+        getPinTuanBaseInfo(),
+        getStepPriceList(),
+        getUserList(),
+      ]);
+    baseInfoData.value = data1;
+    stepPriceList.value = data2.dataList;
+    userList.value = data3;
+    closeToast();
+  } catch (error) {
+    closeToast();
+  }
+}
+
+const expanded = ref(false);
+async function toggleExpanded() {
+  if (expanded.value) {
+    expanded.value = false;
+    return;
+  }
+  await getExpandedData();
+  expanded.value = !expanded.value;
+}
+</script>
+
+<style lang="scss" scoped></style>

+ 108 - 0
src/components/TravelProjectDetail/PinTuan/KaiTuanApplyBottomModal.vue

@@ -0,0 +1,108 @@
+<template>
+  <div>
+    <BaseModal
+      v-model:show="show"
+      position="bottom"
+      style="width: 100%"
+      closeable
+      teleport="body"
+      :round="false"
+    >
+      <div class="flex flex-col items-center pb-50 text-black-3">
+        <div
+          class="w-full h-45 bg-gradient-to-b from-[#fff0dc] flex items-center justify-center to-white"
+        >
+          <img
+            src="~/assets/img/travel_projects_detail/woyaokaituan.png"
+            class="h-18 object-cover"
+          />
+        </div>
+        <div class="px-12 flex mt-15 w-full space-x-12">
+          <van-image
+            :src="formatImgSrc(detailData.tourismUrlsAfterConvert)"
+            width="120px"
+            height="120px"
+            fit="cover"
+            class="shrink-0"
+          ></van-image>
+          <div class="flex-1">
+            <div class="text-sm">
+              <span class="text-[#FF0000]">{{ detailData.priceUnit }}</span>
+              <span class="text-[#FF0000] text-3xl">{{
+                priceToArray(detailData.adultPrice)[0]
+              }}</span>
+              <span class="text-[#FF0000]"
+                >.{{ priceToArray(detailData.adultPrice)[1] }}</span
+              >
+              <span>/人起</span>
+            </div>
+            <div class="text-xl mt-5 font-semibold">
+              {{ detailData.projectTitle }}
+            </div>
+            <div class="mt-10 flex flex-wrap items-center gap-7">
+              <div
+                v-for="item in lableList"
+                :key="item"
+                class="flex h-24 bg-[#fff5e6] items-center justify-center rounded-sm border border-current px-10 text-sm text-primary"
+              >
+                {{ item }}
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <div class="w-full h-4 bg-[#EDECF2] mt-20"></div>
+
+        <div class="text-base mt-30">{{ title }}</div>
+        <img
+          src="~/assets/img/travel_projects_detail/kaituan_kefu_qrcode.png"
+          class="mt-15 h-160 w-160"
+        />
+        <div class="mt-15 text-base">旅游顾问电话:188823746123</div>
+        <van-button
+          type="primary"
+          @click="handleSubmit"
+          style="margin-top: 30px; width: 90%"
+          >提交开团申请</van-button
+        >
+      </div>
+    </BaseModal>
+  </div>
+</template>
+
+<script setup>
+const show = defineModel("show", false);
+const props = defineProps({
+  title: {
+    type: String,
+    default: "若需要开团请直接联系客服,为你新开拼团",
+  },
+  detailData: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+
+const lableList = computed(
+  () => props.detailData.projectLabel?.split("&") ?? []
+);
+
+const id = useRouteParam("id");
+
+function handleSubmit() {
+  request("/website/app/tourProjectGroupPurchaseApply/add", {
+    method: "POST",
+    body: {
+      projectId: id.value,
+    },
+  }).then(() => {
+    // show.value = false;
+    showDialog({
+      title: "提交成功",
+      message: "你的开团申请已提交,请等待客服联系",
+    }).then(() => {});
+  });
+}
+</script>
+
+<style lang="scss" scoped></style>

+ 56 - 0
src/components/TravelProjectDetail/PinTuan/KaiTuanApplyModal.vue

@@ -0,0 +1,56 @@
+<template>
+  <div>
+    <BaseModal
+      v-model:show="show"
+      style="width: 80%; padding: 20px"
+      closeable
+      teleport="body"
+    >
+      <div class="flex flex-col items-center text-black-3 py-30">
+        <div class="text-base">{{ title }}</div>
+        <img
+          src="~/assets/img/travel_projects_detail/kaituan_kefu_qrcode.png"
+          class="mt-10 h-180 w-180"
+        />
+        <div class="mt-10 text-base">旅游顾问电话:188823746123</div>
+        <van-button
+          type="primary"
+          @click="handleSubmit"
+          style="margin-top: 10px; height: 38px; width: 120px"
+          >提交开团申请</van-button
+        >
+      </div>
+    </BaseModal>
+  </div>
+</template>
+
+<script setup>
+import { themeVars } from "~/themeVars";
+
+const show = defineModel("show", false);
+defineProps({
+  title: {
+    type: String,
+    default: "若需要开团请直接联系客服,为你新开拼团",
+  },
+});
+
+const id = useRouteParam("id");
+
+function handleSubmit() {
+  request("/website/app/tourProjectGroupPurchaseApply/add", {
+    method: "POST",
+    body: {
+      projectId: id.value,
+    },
+  }).then(() => {
+    show.value = false;
+    showDialog({
+      title: "提交成功",
+      message: "你的开团申请已提交,请等待客服联系",
+    }).then(() => {});
+  });
+}
+</script>
+
+<style lang="scss" scoped></style>

+ 89 - 0
src/components/TravelProjectDetail/PinTuan/ResultModal.vue

@@ -0,0 +1,89 @@
+<template>
+  <div>
+    <BaseModal
+      v-model:show="show"
+      style="width: 85%; padding: 20px"
+      closeable
+      destroy-on-close
+      round
+    >
+      <div class="flex flex-col items-center text-base text-black-3">
+        <img
+          src="~/assets/img/travel_projects_detail/pintuan_success.png"
+          class="h-80 w-80"
+        />
+        <span class="text-xl mt-20 font-semibold">拼团成功</span>
+        <div
+          v-if="pinTuanInfo.success == 0"
+          class="flex flex-col items-center mt-10"
+        >
+          <div>
+            <span>你已选择{{ pinTuanInfo.maxCount }}人团,再邀请</span>
+            <span class="text-3xl font-semibold text-primary">{{
+              pinTuanInfo.nextStageNum ?? 0
+            }}</span
+            ><span>人拼团,就可享受</span>
+            <span class="text-base font-semibold text-primary">{{
+              pinTuanInfo.priceUnit
+            }}</span>
+            <span class="text-3xl font-semibold text-primary">{{
+              pinTuanInfo.nowPrice - pinTuanInfo.nextPrice
+            }}</span
+            >的优惠;
+            <span>同时,每邀请</span>
+            <span class="text-3xl font-semibold text-primary">1</span>
+            <span>人拼团,</span>
+            <span>可得到</span>
+            <span class="text-base font-semibold text-primary">{{
+              pinTuanInfo.priceUnit
+            }}</span>
+            <span class="text-3xl font-semibold text-primary">{{
+              pinTuanInfo.singleRebate ?? 0
+            }}</span>
+            <span>返利。</span>
+          </div>
+          <div class="mt-20 w-full bg-[#f7f8f9] p-10 text-base">
+            <div>分享链接:</div>
+            <div class="text-primary underline break-all">{{ shareUrl }}</div>
+          </div>
+          <van-button
+            type="primary"
+            size="small"
+            style="margin-top: 20px"
+            @click="handleCopy"
+            >复制并分享</van-button
+          >
+        </div>
+      </div>
+    </BaseModal>
+  </div>
+</template>
+
+<script setup>
+import { useClipboard } from "@vueuse/core";
+
+const show = defineModel("show", false);
+
+const props = defineProps({
+  pinTuanInfo: {
+    type: Object,
+    default: () => {},
+  },
+});
+
+const userInfoStore = useUserInfoStore();
+const { userInfo } = storeToRefs(userInfoStore);
+const userId = computed(() => userInfo.value?.userId ?? "");
+
+const shareUrl = computed(() => {
+  return `${import.meta.env.VITE_APP_WEBSITE_BASE_URL}/t/${props.pinTuanInfo.projectId}?pinTuanId=${props.pinTuanInfo.id}&fromUserId=${userId.value}`;
+});
+const { copy } = useClipboard({ shareUrl });
+
+function handleCopy() {
+  copy(shareUrl.value);
+  showToast("复制成功");
+}
+</script>
+
+<style lang="scss" scoped></style>

+ 71 - 0
src/components/TravelProjectDetail/PinTuan/SharedBottomBar.vue

@@ -0,0 +1,71 @@
+<template>
+  <div
+    class="fixed bottom-0 px-15 left-0 right-0 h-70 z-50 bg-white shadow-[0px_2px_14px_1px_rgba(0,0,0,0.12)] flex items-center"
+  >
+    <NuxtLink
+      :to="`/t/${id}`"
+      target="_blank"
+      class="flex items-center rounded-l-xl justify-center h-42 bg-[#FEF4E6] text-primary flex-1"
+    >
+      查看其他拼团
+    </NuxtLink>
+    <div
+      @click="handlePinTuan"
+      class="flex items-center rounded-r-xl justify-center h-42 bg-primary text-white flex-1"
+    >
+      立即参团
+    </div>
+    <TravelProjectDetailPinTuanBookModal
+      v-model:show="bookModalOptions.show"
+      :pin-tuan-data="pinTuanData"
+      :project-data="projectData"
+    />
+  </div>
+</template>
+
+<script setup>
+const props = defineProps({
+  projectData: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+
+const id = useRouteParam("id");
+
+const pinTuanId = useRouteQuery("pinTuanId");
+
+const pinTuanData = ref({});
+
+async function getPinTuanBaseInfo() {
+  try {
+    const { data } = await request(
+      `/website/app/tourProjectGroupPurchase/view?id=${pinTuanId.value}`
+    );
+    pinTuanData.value = data;
+    console.log(data);
+  } catch (error) {
+    console.log(error);
+  }
+}
+
+const bookModalOptions = reactive({
+  show: false,
+});
+function handlePinTuan() {
+  if (pinTuanData.value.nowCount == pinTuanData.value.maxCount) {
+    // 满员提示
+    applyKaiTuanOptions.show = true;
+    applyKaiTuanOptions.title =
+      "此团已满员,若需要开团请直接联系客服为你新开拼团";
+    return;
+  }
+  bookModalOptions.show = true;
+}
+
+onMounted(() => {
+  getPinTuanBaseInfo();
+});
+</script>
+
+<style lang="scss" scoped></style>

+ 65 - 0
src/components/TravelProjectDetail/PinTuan/StepPriceCard.vue

@@ -0,0 +1,65 @@
+<template>
+  <div class="px-12 pb-15 text-base bg-[#fefbf5]">
+    <div class="h-1 w-full bg-[#FDE1B2]"></div>
+    <div
+      class="flex shrink-0 mt-15 items-center space-x-5 text-xl text-black-3"
+    >
+      <img
+        src="~/assets/img/travel_projects_detail/pintuan_02.png"
+        class="w-18"
+        alt=""
+        srcset=""
+      />
+      <span class="font-semibold">阶梯价格</span>
+      <div class="flex items-center text-sm">
+        <span class="text-[#FF0000]">*</span>
+        <span class="text-black-9">注:儿童价为成人价正常销售价格的一半</span>
+      </div>
+    </div>
+    <div class="mt-15 flex flex-1 flex-col space-y-10 overflow-scroll">
+      <div
+        v-for="item in dataList"
+        :key="item.id"
+        class="flex items-center text-base"
+      >
+        <span class="w-70 text-black-6">
+          <template v-if="item.minCount != item.maxCount">
+            <span>{{ item.minCount }}-{{ item.maxCount }}人</span>
+          </template>
+          <template v-else>
+            <span>({{ item.minCount }}人)</span>
+          </template>
+        </span>
+        <div class="relative">
+          <div class="flex items-center space-x-4">
+            <div v-for="_ in 10" class="h-12 w-4 rounded-sm bg-[#FFEBC8]"></div>
+          </div>
+          <div class="absolute left-0 top-0 flex items-center space-x-4">
+            <div
+              v-for="_ in Number.parseInt((item.minCount / maxCount) * 10)"
+              class="h-12 w-4 rounded-sm bg-primary"
+            ></div>
+          </div>
+        </div>
+
+        <span class="ml-30 text-black-3">成人低至</span>
+        <div class="ml-10 font-semibold text-[#FF0000]">
+          <span class="text-sm">{{ item.priceUnit ?? "¥" }}</span>
+          <span class="text-xl">{{ item.adultPrice }}</span>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+const props = defineProps({
+  maxCount: Number,
+  dataList: {
+    type: Array,
+    default: () => [],
+  },
+});
+</script>
+
+<style lang="scss" scoped></style>

+ 60 - 0
src/components/TravelProjectDetail/PinTuan/Users.vue

@@ -0,0 +1,60 @@
+<template>
+  <div class="bg-[#fefbf5] px-12 pb-15 text-base">
+    <div class="h-1 w-full bg-[#FDE1B2]"></div>
+    <div
+      class="flex items-center mt-15 space-x-5 text-xl font-semibold text-black-3"
+    >
+      <img
+        src="~/assets/img/travel_projects_detail/pintuan_03.png"
+        class="w-18"
+        alt=""
+        srcset=""
+      />
+      <span>正在拼团</span>
+    </div>
+
+    <vue3-seamless-scroll
+      v-if="listData.length"
+      class="mt-10 box-border h-230 overflow-hidden"
+      :step="0.5"
+      :list="listData"
+    >
+      <div v-for="item in listData" class="flex h-60 items-center text-base">
+        <img
+          :src="item.headImageUrl || defaultAvatar"
+          class="h-40 w-40 rounded-full object-cover"
+        />
+        <span class="ml-10 flex-1 text-black-3"
+          >{{ item.showName
+          }}{{ item.peopleNumber > 1 ? `(${item.peopleNumber}人)` : "" }}</span
+        >
+        <span class="text-black-9">参与了拼团</span>
+      </div>
+    </vue3-seamless-scroll>
+    <div
+      v-else-if="!listData.length"
+      class="flex h-full w-full flex-col items-center justify-center space-y-10"
+    >
+      <img
+        src="~/assets/img/travel_projects_detail/user_empty.png"
+        class="h-160 w-160 object-cover"
+        alt=""
+        srcset=""
+      />
+      <span class="text-sm text-black-9">暂无用户参与</span>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import defaultAvatar from "~/assets/img/default_avatar.png";
+
+const props = defineProps({
+  listData: {
+    type: Array,
+    default: () => [],
+  },
+});
+</script>
+
+<style lang="scss" scoped></style>

+ 108 - 0
src/components/TravelProjectDetail/PinTuan/index.vue

@@ -0,0 +1,108 @@
+<template>
+  <div class="bg-white">
+    <TravelProjectDetailPinTuanAutoSwiper
+      v-if="allPinTuanListData.length"
+      :project-data="detailData"
+    />
+    <div class="flex items-center mt-20 space-x-10">
+      <div class="w-3 h-16 bg-primary"></div>
+      <span class="text-xl font-semibold text-black-3">选择日期拼团</span>
+    </div>
+    <div
+      @click="calendarShow = true"
+      class="flex items-center border-primary bg-[#fefbf5] border rounded mt-15 mx-12 py-5 px-10"
+    >
+      <img
+        src="~/assets/img/travel_projects_detail/calendar.png"
+        class="w-30 h-30"
+      />
+      <span class="text-base flex-1 text-black-3 ml-10 font-semibold">{{
+        currentDate
+      }}</span>
+      <span
+        class="iconfont icon-right text-black-3"
+        style="font-size: 12px"
+      ></span>
+    </div>
+    <div class="px-12 flex flex-col space-y-20 py-20">
+      <TravelProjectDetailPinTuanItem
+        v-for="item in listData"
+        :key="item.id"
+        :itemData="item"
+        :project-data="detailData"
+      />
+    </div>
+    <TravelProjectDetailPinTuanCalendarModal
+      v-model:show="calendarShow"
+      :available-date-list="availableDateList"
+      v-model:current-date="currentDate"
+    />
+  </div>
+</template>
+
+<script setup>
+defineProps({
+  detailData: {
+    type: Object,
+    default: () => {},
+  },
+});
+
+const id = useRouteParam("id");
+
+const dayjs = useDayjs();
+
+const currentDate = ref();
+
+// 所选时间的拼团
+const listData = ref([]);
+async function getList() {
+  const { data } = await request("/website/app/tourProjectGroupPurchase/list", {
+    query: {
+      pageNum: 1,
+      pageSize: 8,
+      travelStartTimeStart: currentDate.value,
+      projectId: id.value,
+    },
+  });
+  listData.value = data.dataList;
+}
+
+// 获取所有的有人参团的拼团
+const allPinTuanListData = ref([]);
+async function getAllPinTuanList() {
+  const { data } = await request("/website/app/tourProjectGroupPurchase/list", {
+    query: {
+      pageNum: 1,
+      projectId: id.value,
+      nowCount: 1,
+    },
+  });
+  allPinTuanListData.value = data.dataList;
+}
+
+// 有拼团的日期
+const availableDateList = ref([]);
+async function getAvailableDate() {
+  const { data } = await request(
+    `/website/app/tourProjectGroupPurchase/queryGroupPurchaseDate?projectId=${id.value}`
+  );
+  availableDateList.value = data;
+  currentDate.value =
+    availableDateList.value.length > 0 ? availableDateList.value[0] : null;
+}
+
+watch(currentDate, () => {
+  getList();
+});
+
+const calendarShow = ref(false);
+
+onMounted(() => {
+  getAvailableDate();
+  getAllPinTuanList();
+  // getList();
+});
+</script>
+
+<style lang="scss" scoped></style>

+ 24 - 0
src/components/TravelProjectsHome/Banner.vue

@@ -0,0 +1,24 @@
+<template>
+  <van-swipe :interval="5000" style="height: calc(100vw * 0.43)">
+    <van-swipe-item
+      v-for="item in bannerList"
+      :key="item.id"
+      style="height: calc(100vw * 0.43)"
+    >
+      <NuxtLink :to="`/t/${item.projectId}`" target="_blank">
+        <img
+          :src="item.imgUrlAfterConvert"
+          class="h-full w-full object-cover"
+        />
+      </NuxtLink>
+    </van-swipe-item>
+  </van-swipe>
+</template>
+
+<script setup>
+const { data: bannerList } = await useMyFetch(
+  `/website/app/tourProjectGroupPurchaseBanner/list`
+);
+</script>
+
+<style lang="scss" scoped></style>

+ 31 - 0
src/components/TravelProjectsHome/Comments/Item.vue

@@ -0,0 +1,31 @@
+<template>
+  <div class="h-127 rounded-xl bg-white px-15 pt-15 text-sm text-black-3">
+    <div class="flex items-center">
+      <img :src="itemData.avatar" class="h-28 w-28 rounded-full" alt="" />
+      <div class="ml-8 flex-1">
+        <div class="font-bold">{{ itemData.name }}</div>
+        <div class="text-black-9">{{ itemData.createDate }}</div>
+      </div>
+      <div class="ml-10 flex items-center">
+        <span class="text-black-9">综合评价:</span>
+        <span
+          v-for="i in itemData.rate"
+          class="iconfont icon-star-fill text-[#ffc927]"
+          style="font-size: 12px"
+        ></span>
+      </div>
+    </div>
+    <div class="mt-10 leading-[20px]">{{ itemData.content }}</div>
+  </div>
+</template>
+
+<script setup>
+defineProps({
+  itemData: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+</script>
+
+<style lang="scss" scoped></style>

+ 105 - 0
src/components/TravelProjectsHome/Comments/index.vue

@@ -0,0 +1,105 @@
+<template>
+  <div class="">
+    <div class="text-xl font-semibold">真实反馈,只为更好</div>
+    <div
+      class="mt-15 bg-[url('~/assets/img/travel_project_home/comments_bg.png')] bg-cover bg-no-repeat"
+    >
+      <van-swipe indicator-color="#FD9A00">
+        <van-swipe-item v-for="item in commentsList" :key="item.id">
+          <div class="mb-40 mt-30 px-30 flex flex-col items-center space-y-20">
+            <TravelProjectsHomeCommentsItem :item-data="item[0]" />
+            <TravelProjectsHomeCommentsItem :item-data="item[1]" />
+          </div>
+        </van-swipe-item>
+      </van-swipe>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import avatar_1 from "~/assets/img/travel_project_home/avatar_1.png";
+import avatar_2 from "~/assets/img/travel_project_home/avatar_2.png";
+import avatar_3 from "~/assets/img/travel_project_home/avatar_3.png";
+import avatar_4 from "~/assets/img/travel_project_home/avatar_4.png";
+import avatar_5 from "~/assets/img/travel_project_home/avatar_5.png";
+import avatar_6 from "~/assets/img/travel_project_home/avatar_6.png";
+import avatar_7 from "~/assets/img/travel_project_home/avatar_7.png";
+import avatar_8 from "~/assets/img/travel_project_home/avatar_8.png";
+
+const commentsList = [
+  [
+    {
+      avatar: avatar_1,
+      name: "刘女士",
+      createDate: "2024-08-10",
+      rate: 5,
+      content:
+        "感谢导游,我带的药品在进黑山的时候差点被拦下来了,导游小哥哥叽叽咕咕的和那个警察说了一会就解决了,还是好贵的药。",
+    },
+    {
+      avatar: avatar_2,
+      name: "王先生",
+      createDate: "2024-09-10",
+      rate: 5,
+      content:
+        "塞尔维亚滑雪太划算了啊啊啊!司导接送我们去雪场,住的雪山小屋,雪具都可以在山上租,好吃的也很多。",
+    },
+  ],
+  [
+    {
+      avatar: avatar_3,
+      name: "赵先生",
+      createDate: "2024-08-16",
+      rate: 5,
+      content:
+        "弱弱的问,那个黑湖如果下次来能否安排时间更长点,我就想到那里去晒一天太阳。",
+    },
+    {
+      avatar: avatar_4,
+      name: "李先生",
+      createDate: "2024-10-12",
+      rate: 5,
+      content:
+        "哈哈哈,我和一个巴基斯坦的小朋友在小火车上穿隧道的时候装狼叫,永生难忘啊。",
+    },
+  ],
+  [
+    {
+      avatar: avatar_5,
+      name: "陈先生",
+      createDate: "2024-08-08",
+      rate: 5,
+      content:
+        "有一说一出海真的很好玩,中午十二点那一趟人比较多,海风巨大,建议穿厚点的外套。",
+    },
+    {
+      avatar: avatar_6,
+      name: "王先生",
+      createDate: "2024-08-22",
+      rate: 5,
+      content:
+        "阿尔巴尼亚物价是希腊的三分之一,但是海景却毫不逊色!上一次见这么好看的海水还是在兰卡威!",
+    },
+  ],
+  [
+    {
+      avatar: avatar_7,
+      name: "上官先生",
+      createDate: "2024-08-30",
+      rate: 5,
+      content:
+        "整个行程安排非常轻松悠闲,人文历史与自然风光结合,黑湖璀璨妩媚,多瑙河畔白天鹅乖巧可爱.",
+    },
+    {
+      avatar: avatar_8,
+      name: "刘女士",
+      createDate: "2024-11-10",
+      rate: 5,
+      content:
+        "去塞尔维亚旅行就是方便,买张机票,带上护照,换点欧元就可以直接出发,别的什么材料都不要准备。",
+    },
+  ],
+];
+</script>
+
+<style lang="scss" scoped></style>

+ 112 - 0
src/components/TravelProjectsHome/Customize.vue

@@ -0,0 +1,112 @@
+<template>
+  <div class="text-black-3">
+    <div>
+      <span class="text-xl font-semibold">私人定制</span>
+    </div>
+    <div class="mt-15 grid grid-cols-3 gap-x-7">
+      <div
+        v-for="item in kefuList"
+        class="flex flex-col items-center space-y-5 overflow-hidden rounded-xl bg-white pb-10"
+      >
+        <img :src="item.img" class="w-full" alt="" />
+        <span class="pt-5 text-sm font-bold">{{ item.title }}</span>
+        <span class="text-sm text-black-9">{{ item.description }}</span>
+        <van-popover placement="top" v-model:show="item.showPopover">
+          <template #reference>
+            <div
+              class="flex h-26 w-64 cursor-pointer items-center justify-center rounded-full bg-gradient-to-b from-[#FD9A00] to-[#FFC160] text-sm text-white"
+            >
+              加好友
+            </div>
+          </template>
+          <div class="flex w-120 p-5 h-130 flex-col items-center">
+            <div class="text-sm font-bold">{{ item.title }}</div>
+            <img :src="item.qrCode" class="mt-5 h-90 w-90" alt="" srcset="" />
+          </div>
+        </van-popover>
+      </div>
+    </div>
+
+    <div class="mt-15">
+      <div class="text-xl font-semibold">私人定制我们是专业的</div>
+      <div class="mt-15 grid grid-cols-2 gap-x-16 gap-y-15">
+        <div v-for="item in introList" class="flex flex-col items-center">
+          <img
+            :src="item.img"
+            class="h-58 w-58 rounded-full transition-all hover:shadow-card"
+            alt=""
+          />
+          <span class="mt-10 text-base font-bold">{{ item.title }}</span>
+          <span class="mt-5 text-sm text-black-9">{{ item.description }}</span>
+        </div>
+      </div>
+    </div>
+
+    <img :src="contrast" class="mt-20 w-full" alt="" srcset="" />
+  </div>
+</template>
+
+<script setup>
+import contrast from "~/assets/img/travel_project_home/contrast.png";
+import icon_tiexinxiangban from "~/assets/img/travel_project_home/icon_tiexinxiangban.png";
+import icon_wuyouhaiwan from "~/assets/img/travel_project_home/icon_wuyouhaiwan.png";
+import icon_zhuanshudingzhi from "~/assets/img/travel_project_home/icon_zhuanshudingzhi.png";
+import icon_zhuanyezhenxuan from "~/assets/img/travel_project_home/icon_zhuanyezhenxuan.png";
+import kefu1 from "~/assets/img/travel_project_home/kefu_1.png";
+import kefu2 from "~/assets/img/travel_project_home/kefu_2.png";
+import kefu3 from "~/assets/img/travel_project_home/kefu_3.png";
+import kefu_1_qrcode from "~/assets/img/travel_project_home/kefu_1_qrcode.png";
+import kefu_2_qrcode from "~/assets/img/travel_project_home/kefu_2_qrcode.png";
+import kefu_3_qrcode from "~/assets/img/travel_project_home/kefu_3_qrcode.png";
+
+const kefuList = reactive([
+  {
+    img: kefu1,
+    title: "资深顾问:西瓜老师",
+    description: "擅长:欧洲",
+    qrCode: kefu_1_qrcode,
+    showPopover: false,
+  },
+  {
+    img: kefu2,
+    title: "资深顾问:桃子老师",
+    description: "擅长:欧洲",
+    qrCode: kefu_2_qrcode,
+    showPopover: false,
+  },
+  {
+    img: kefu3,
+    title: "资深顾问:芒果老师",
+    description: "擅长:欧洲",
+    qrCode: kefu_3_qrcode,
+    showPopover: false,
+  },
+]);
+
+// const showPopover = ref(false);
+
+const introList = [
+  {
+    img: icon_zhuanshudingzhi,
+    title: "专属定制",
+    description: "依您喜好,定制专属旅程",
+  },
+  {
+    img: icon_zhuanyezhenxuan,
+    title: "专业甄选",
+    description: "优资精选,简化预订流程",
+  },
+  {
+    img: icon_wuyouhaiwan,
+    title: "无忧嗨玩",
+    description: "精选景点,畅享自由旅行",
+  },
+  {
+    img: icon_tiexinxiangban,
+    title: "贴心相伴",
+    description: "导游包车,体验贴心服务",
+  },
+];
+</script>
+
+<style lang="scss" scoped></style>

+ 43 - 0
src/components/TravelProjectsHome/PinTuanProjects/Item.vue

@@ -0,0 +1,43 @@
+<template>
+  <NuxtLink
+    :to="`/t/${itemData.projectId}`"
+    target="_blank"
+    class="relative h-180 shrink-0 w-130 box-border block aspect-[130/180] overflow-hidden rounded-xl text-white"
+  >
+    <img
+      :src="itemData.image"
+      class="absolute bottom-0 left-0 right-0 top-0 h-full w-full object-cover"
+    />
+    <div
+      class="absolute right-5 top-5 flex h-20 items-center justify-center rounded-full bg-[#00000033] px-5 text-sm font-semibold"
+    >
+      {{ itemData.diffDaysToEnd }}
+    </div>
+    <div class="absolute bottom-4 left-4 right-4">
+      <div class="truncate text-base font-bold">
+        {{ itemData.title }}
+      </div>
+      <div class="mt-3 flex items-center text-sm justify-between">
+        <div
+          class="flex h-20 items-center justify-center rounded-full bg-[#00000033] px-5 font-semibold text-white"
+        >
+          {{ itemData.nowCount }}/{{ itemData.maxCount }}人团
+        </div>
+        <div class="font-bold text-[#FF1616]">
+          {{ itemData.priceUnit }}{{ itemData.adultPrice }}
+        </div>
+      </div>
+    </div>
+  </NuxtLink>
+</template>
+
+<script setup>
+defineProps({
+  itemData: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+</script>
+
+<style lang="scss" scoped></style>

+ 40 - 0
src/components/TravelProjectsHome/PinTuanProjects/index.vue

@@ -0,0 +1,40 @@
+<template>
+  <div class="">
+    <div class="text-black-3 font-semibold text-xl">拼团旅游</div>
+    <div class="flex space-x-10 overflow-scroll w-full mt-15">
+      <TravelProjectsHomePinTuanProjectsItem
+        v-for="item in listData"
+        :item-data="item"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup>
+const { data } = await useMyFetch(
+  `/website/app/tourProjectGroupPurchase/list?pageNum=1&pageSize=10`
+);
+
+const listData = computed(() => data.value?.dataList ?? []);
+
+// const containerRef = ref(null);
+// const swiper = useSwiper(containerRef, {
+//   effect: "creative",
+//   loop: true,
+//   autoplay: {
+//     delay: 5000,
+//   },
+//   slidesPerView: 4,
+//   spaceBetween: 20,
+// });
+
+// function handlePre() {
+//   swiper.prev();
+// }
+
+// function handleNext() {
+//   swiper.next();
+// }
+</script>
+
+<style lang="scss" scoped></style>

+ 130 - 131
src/pages/t/[id].client.vue

@@ -1,35 +1,28 @@
 <template>
-  <div class="pb-120">
+  <div class="pb-120 bg-[#F8F8F8]">
     <TravelProjectDetailBanner
       :banner-list="detailData?.tourismFile?.fileUrlsAfterConvert"
     />
     <TravelProjectDetailBaseInfo :detail-data="detailData" />
-    <div class="h-10 bg-[#E6E6E6]"></div>
-    <div class="px-15">
-      <TravelProjectDetailBookInfo
-        v-model:startDate="bookInfo.startDate"
-        v-model:adultNumber="bookInfo.adultNumber"
-        v-model:childrenNumber="bookInfo.childrenNumber"
-        v-model:customerName="bookInfo.customerName"
-        v-model:customerMobile="bookInfo.customerMobile"
-        v-model:customerMobileStandby="bookInfo.customerMobileStandby"
-        v-model:customerWechat="bookInfo.customerWechat"
-        :calendar-data="calendarData"
-        :detail-data="detailData"
-      />
-      <TravelProjectDetailSellingPoint
-        v-if="detailData?.sellingPoint"
-        :detail-data="detailData"
-        class="mt-20"
-      />
-      <TravelProjectDetailSpecialTips class="mt-20" />
-      <TravelProjectDetailDetails :detail-data="detailData" class="mt-20" />
-    </div>
+    <TravelProjectDetailPinTuan
+      v-if="detailData?.hasGroup == 1 && !fromUserId"
+      :detail-data="detailData"
+    />
+
+    <TravelProjectDetailDetails :detail-data="detailData" class="mt-20" />
+
     <TravelProjectDetailBottomBar
-      :total-price="totalPrice"
+      v-if="!fromUserId"
       :detail-data="detailData"
-      :loading="submitLoading"
-      @onOk="handleSubmit"
+      @onOk="nomalBookModalOptions.show = true"
+    />
+    <TravelProjectDetailPinTuanSharedBottomBar
+      v-if="fromUserId"
+      :project-data="detailData"
+    />
+
+    <TravelProjectDetailNomalBookModal
+      v-model:show="nomalBookModalOptions.show"
     />
   </div>
 </template>
@@ -37,6 +30,8 @@
 <script setup>
 const id = useRouteParam("id");
 
+const fromUserId = useRouteQuery("fromUserId");
+
 const bookInfo = reactive({
   startDate: null,
   adultNumber: 1,
@@ -51,114 +46,118 @@ const { data: detailData, status } = await useMyFetch(
   `website/tourism/project/detail?id=${id.value}`
 );
 
-const dayjs = useDayjs();
-const calendarData = ref({});
-async function getCalendarData() {
-  const { data } = await request("/website/tourism/project/viewDatePrice", {
-    query: {
-      projectId: id.value,
-    },
-  });
-  calendarData.value = data.tourismProjectDatePriceVos ?? {};
-  const minDay = dayjs.min(
-    Object.keys(calendarData.value).map((e) => dayjs(e))
-  );
-  bookInfo.startDate =
-    minDay === null ? null : dayjs(minDay).format("YYYY-MM-DD");
-}
-
-const totalPrice = ref("");
-
-watch(
-  bookInfo,
-  () => {
-    // 计算总价
-    const calendarInfo = calendarData.value[bookInfo.startDate] ?? {
-      adultPrice: 0,
-      childrenPrice: 0,
-    };
-    totalPrice.value = `${
-      bookInfo.adultNumber * calendarInfo.adultPrice +
-      bookInfo.childrenNumber * calendarInfo.childrenPrice
-    }`;
-  },
-  { deep: true }
-);
-
-const route = useRoute();
-
-const useAuth = useAuthStore();
-
-const { token } = storeToRefs(useAuth);
-
-async function handleSubmit() {
-  if (!token.value) {
-    await navigateTo({
-      path: "/login",
-      replace: true,
-      query: {
-        redirect: route.fullPath,
-      },
-    });
-
-    return;
-  }
-  handleSubmitInfo();
-}
-
-const submitLoading = ref(false);
-function handleSubmitInfo() {
-  if (!bookInfo.customerName) {
-    showToast("请输入联系人姓名");
-    return;
-  }
-  if (!bookInfo.customerMobile) {
-    showToast("请输入手机号");
-    return;
-  }
-
-  if (!bookInfo.customerWechat) {
-    showToast("请输入微信号");
-    return;
-  }
-  submitLoading.value = true;
-  request("/website/tourism/myOrder/add", {
-    method: "post",
-    body: {
-      tourBookInfoDto: {
-        projectId: id.value,
-        type: "1",
-        ...bookInfo,
-      },
-    },
-  })
-    .then(({ data }) => {
-      submitLoading.value = false;
-      if (data === 1) {
-        showDialog({
-          title: "预定成功",
-          message:
-            "因为旅游预定涉及较多的环节,我们需要线下和您沟通,我们会尽快与你联系并为您提供最真诚的服务。",
-        }).then(() => {});
-      } else if (data === 2) {
-        showDialog({
-          title: "您已预定",
-          message:
-            "因为旅游预定涉及较多的环节,我们需要线下和您沟通,我们会尽快与你联系并为您提供最真诚的服务。",
-        }).then(() => {
-          // on close
-        });
-      }
-    })
-    .catch(() => {
-      submitLoading.value = false;
-    });
-}
-
-onMounted(() => {
-  getCalendarData();
+const nomalBookModalOptions = reactive({
+  show: false,
 });
 
+// const dayjs = useDayjs();
+// const calendarData = ref({});
+// async function getCalendarData() {
+//   const { data } = await request("/website/tourism/project/viewDatePrice", {
+//     query: {
+//       projectId: id.value,
+//     },
+//   });
+//   calendarData.value = data.tourismProjectDatePriceVos ?? {};
+//   const minDay = dayjs.min(
+//     Object.keys(calendarData.value).map((e) => dayjs(e))
+//   );
+//   bookInfo.startDate =
+//     minDay === null ? null : dayjs(minDay).format("YYYY-MM-DD");
+// }
+
+// const totalPrice = ref("");
+
+// watch(
+//   bookInfo,
+//   () => {
+//     // 计算总价
+//     const calendarInfo = calendarData.value[bookInfo.startDate] ?? {
+//       adultPrice: 0,
+//       childrenPrice: 0,
+//     };
+//     totalPrice.value = `${
+//       bookInfo.adultNumber * calendarInfo.adultPrice +
+//       bookInfo.childrenNumber * calendarInfo.childrenPrice
+//     }`;
+//   },
+//   { deep: true }
+// );
+
+// const route = useRoute();
+
+// const useAuth = useAuthStore();
+
+// const { token } = storeToRefs(useAuth);
+
+// async function handleSubmit() {
+//   if (!token.value) {
+//     await navigateTo({
+//       path: "/login",
+//       replace: true,
+//       query: {
+//         redirect: route.fullPath,
+//       },
+//     });
+
+//     return;
+//   }
+//   handleSubmitInfo();
+// }
+
+// const submitLoading = ref(false);
+// function handleSubmitInfo() {
+//   if (!bookInfo.customerName) {
+//     showToast("请输入联系人姓名");
+//     return;
+//   }
+//   if (!bookInfo.customerMobile) {
+//     showToast("请输入手机号");
+//     return;
+//   }
+
+//   if (!bookInfo.customerWechat) {
+//     showToast("请输入微信号");
+//     return;
+//   }
+//   submitLoading.value = true;
+//   request("/website/tourism/myOrder/add", {
+//     method: "post",
+//     body: {
+//       tourBookInfoDto: {
+//         projectId: id.value,
+//         type: "1",
+//         ...bookInfo,
+//       },
+//     },
+//   })
+//     .then(({ data }) => {
+//       submitLoading.value = false;
+//       if (data === 1) {
+//         showDialog({
+//           title: "预定成功",
+//           message:
+//             "因为旅游预定涉及较多的环节,我们需要线下和您沟通,我们会尽快与你联系并为您提供最真诚的服务。",
+//         }).then(() => {});
+//       } else if (data === 2) {
+//         showDialog({
+//           title: "您已预定",
+//           message:
+//             "因为旅游预定涉及较多的环节,我们需要线下和您沟通,我们会尽快与你联系并为您提供最真诚的服务。",
+//         }).then(() => {
+//           // on close
+//         });
+//       }
+//     })
+//     .catch(() => {
+//       submitLoading.value = false;
+//     });
+// }
+
+// onMounted(() => {
+//   getCalendarData();
+// });
+
 // watch(
 //   bookInfo,
 //   () => {

+ 14 - 0
src/pages/t/index.vue

@@ -0,0 +1,14 @@
+<template>
+  <div class="bg-[#F3F3F3] pb-50">
+    <TravelProjectsHomeBanner />
+    <div class="px-15">
+      <TravelProjectsHomePinTuanProjects class="mt-15" />
+      <TravelProjectsHomeCustomize class="mt-15" />
+      <TravelProjectsHomeComments class="mt-15" />
+    </div>
+  </div>
+</template>
+
+<script setup></script>
+
+<style lang="scss" scoped></style>

+ 4 - 0
src/plugins/vue3-seamless-scroll.client.js

@@ -0,0 +1,4 @@
+import Vue3SeamlessScroll from "vue3-seamless-scroll";
+export default defineNuxtPlugin((nuxtApp) => {
+  nuxtApp.vueApp.use(Vue3SeamlessScroll);
+});

+ 15 - 0
src/themeVars.js

@@ -0,0 +1,15 @@
+export const themeVars = {
+  primaryColor: "#FD9A00",
+  buttonPrimaryBackground: "#FF9300",
+  buttonPrimaryBackground: "#FF9300",
+  buttonPrimaryBorderColor: "#FF9300",
+  searchPadding: "0px",
+  dividerLineHeight: "1px",
+  dividerContentPadding: "0",
+  dividerMargin: "0",
+  dividerVerticalMargin: "0",
+  dropdownMenuTitleActiveTextColor: "#ff9300",
+  treeSelectItemActiveColor: "#ff9300",
+  sidebarSelectedBorderColor: "#ff9300",
+  pickerConfirmActionColor: "#ff9300",
+};

+ 10 - 1
src/utils/index.js

@@ -7,4 +7,13 @@ function formatImgSrc(srcArr) {
   }
   return "";
 }
-export { setIntervalImmediately, formatImgSrc };
+
+function priceToArray(price) {
+  if (!price) return ["0", "00"];
+  const priceStr = price.toString();
+  if (!priceStr.includes(".")) {
+    return [priceStr, "00"];
+  }
+  return [priceStr.split(".")[0], priceStr.split(".")[1]];
+}
+export { setIntervalImmediately, formatImgSrc, priceToArray };