9. vue3-project-trip

144 阅读3分钟

一、初始化项目

1.1 创建项目

npm init vue@latest

1.2 修改配置

  • 修改项目名称和图标

image.png

  • 配置jsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "module": "esnext",
    "baseUrl": "./",
    "moduleResolution": "node",
    "paths": {
      "@/*": [
        "src/*"
      ]
    },
    "jsx": "preserve",
    "lib": [
      "esnext",
      "dom",
      "dom.iterable",
      "scripthost"
    ]
  }
}

1.3 划分目录结构

image.png

1.4 CSS样式的重置

  • 安装normalize.css
yarn add normalize.css
  • 在assets文件对css中reset.css和common.css统一导出index.css

image.png

  • 在main.js中引入
import "normalize.css"
import "./assets/css/index.css"

二、路由和状态配置

2.1 配置代码片段

snippet generator动态生成JSON片段,然后配置在vscode中即可

image.png

  • 在文件中输入vuesetup即可提高效率

2.2 路由配置

  • 路由表配置
import { createRouter, createWebHashHistory } from 'vue-router'

const router = createRouter({
  history: createWebHashHistory(),
  routes: [
    {
      path: "/",
      redirect: "/home"
    },
    {
      path: "/home",
      component: () => import("@/views/home/home.vue")
    },
    {
      path: "/favor",
      component: () => import("@/views/favor/favor.vue")
    },
    {
      path: "/message",
      component: () => import("@/views/message/message.vue")
    },
    {
      path: "/order",
      component: () => import("@/views/order/order.vue")
    },
  ]
})

export default router

  • 在App.vue中展示
<script setup>

</script>

<template>
  <div class="app">
    <router-view></router-view>
    <router-link to="/home">首页</router-link>
    <router-link to="/favor">收藏</router-link>
    <router-link to="/order">订单</router-link>
    <router-link to="/message">消息</router-link>
  </div>
</template>

<style lang="less" scoped>

</style>

2.3 状态管理

  • store配置默认文件
import { createPinia } from 'pinia'

const pinia = createPinia()

export default pinia
  • 全局引入
import { createApp } from 'vue'
import pinia from './stores'
import App from './App.vue'
import router from './router'

// 对样式的重置
import "normalize.css"
import "./assets/css/index.css"

const app = createApp(App)

app.use(pinia)
app.use(router)

app.mount('#app')
  • 模块的store放到modules文件夹里面

image.png

三、开发tabbar

3.1 tabbar的基本搭建

image.png

<script setup>
const tabbarData = [
  {
    text: "首页",
    image: "@/assets/img/tabbar/tab_home.png",
    imageActive: "@/assets/img/tabbar/tab_home_active.png",
    path: "/home"
  },
  {
    text: "收藏",
    image: "@/assets/img/tabbar/tab_favor.png",
    imageActive: "@/assets/img/tabbar/tab_favor_active.png",
    path: "/favor"
  },
  {
    text: "订单",
    image: "@/assets/img/tabbar/tab_order.png",
    imageActive: "@/assets/img/tabbar/tab_order_active.png",
    path: "/order"
  },
  {
    text: "消息",
    image: "@/assets/img/tabbar/tab_message.png",
    imageActive: "@/assets/img/tabbar/tab_message.png",
    path: "/message"
  }
]
</script>

<template>
  <div class="tab-bar">
    <div class="tab-bar-item">
      <img src="@/assets/img/tabbar/tab_home.png" alt="">
      <span class="text">首页</span>
    </div>
    <div class="tab-bar-item">
      <img src="@/assets/img/tabbar/tab_favor.png" alt="">
      <span class="text">收藏</span>
    </div>
    <div class="tab-bar-item">
      <img src="@/assets/img/tabbar/tab_order.png" alt="">
      <span class="text">订单</span>
    </div>
    <div class="tab-bar-item">
      <img src="@/assets/img/tabbar/tab_message.png" alt="">
      <span class="text">消息</span>
    </div>
  </div>
</template>

<style lang="less" scoped>
.tab-bar {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  height: 50px;
  display: flex;
  border-top: 1px solid #ddd;

  .tab-bar-item {
    flex: 1;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;

    img {
      width: 36px;
    }

    .text {
      margin-top: 2px;
      font-size: 12px;
    }


  }
}
</style>
  • 在App.vue中引用组件
<script setup>
import TabBar from "@/components/tabbar/index.vue"


</script>

<template>
  <div class="app">
    <router-view></router-view>
    
    <tab-bar></tab-bar>
  </div>
</template>

<style lang="less" scoped>

</style>

3.2 tabbar的数据抽取

image.png

  • assets/localData/tabbar.js
const tabbarData = [
  {
    text: "首页",
    image: "tabbar/tab_home.png",
    imageActive: "tabbar/tab_home_active.png",
    path: "/home"
  },
  {
    text: "收藏",
    image: "tabbar/tab_favor.png",
    imageActive: "tabbar/tab_favor_active.png",
    path: "/favor"
  },
  {
    text: "订单",
    image: "tabbar/tab_order.png",
    imageActive: "tabbar/tab_order_active.png",
    path: "/order"
  },
  {
    text: "消息",
    image: "tabbar/tab_message.png",
    imageActive: "tabbar/tab_message.png",
    path: "/message"
  }
]
export default tabbarData
  • utils/load_assets.js
// 对vite动态图片加载的处理
export const getAssetURL = (image) => {
  // 参数一:相对路径
  // 参数二:当前路径的url
  return new URL(`../assets/img/${image}`, import.meta.url).href
}

  • tabbar组件
<script setup>
import tabbarData from '@/assets/localData/tabbar'
import { getAssetURL } from '@/utils/load_assets'

</script>

<template>
  <div class="tab-bar">
    <template v-for="(item, index) in tabbarData" :key="item.text">
      <div class="tab-bar-item">
        <img :src="getAssetURL(item.image)" alt="">
        <span class="text">{{ item.text }}</span>
      </div>
    </template>

  </div>
</template>

<style lang="less" scoped>
.tab-bar {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  height: 50px;
  display: flex;
  border-top: 1px solid #ddd;

  .tab-bar-item {
    flex: 1;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;

    img {
      width: 36px;
    }

    .text {
      margin-top: 2px;
      font-size: 12px;
    }


  }
}
</style>

3.3 tabbar的功能完善

image.png

  • common.css 组件用到了全局颜色
:root {
  --primary-color: #ff9645;
}
  • tabbar
<script setup>
import tabbarData from '@/assets/localData/tabbar'
import { getAssetURL } from '@/utils/load_assets'
import { ref } from 'vue';
import { useRouter } from 'vue-router';

const currentIndex = ref(0)
const router = useRouter()
const itemClick = (index, item) => {
  currentIndex.value = index
  router.push(item.path)
}

</script>

<template>
  <div class="tab-bar">
    <template v-for="(item, index) in tabbarData" :key="item.text">
      <div class="tab-bar-item" 
        :class="{ active: currentIndex === index }" 
        @click="itemClick(index, item)"
      >
        <img v-if="currentIndex !== index" :src="getAssetURL(item.image)" alt="">
        <img v-else :src="getAssetURL(item.imageActive)" alt="">
        <span class="text">{{ item.text }}</span>
      </div>
    </template>

  </div>
</template>

<style lang="less" scoped>
.tab-bar {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  height: 50px;
  display: flex;
  border-top: 1px solid #ddd;

  .tab-bar-item {
    flex: 1;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;

    &.active {
      color: var(--primary-color);
    }

    img {
      width: 36px;
    }

    .text {
      margin-top: 2px;
      font-size: 12px;
    }
  }
}
</style>

四、vant组件库的使用

4.1 引入和使用vant

  • 安装vant
yarn add vant
  • 按需引入-安装插件
# 通过 npm 安装 
npm i unplugin-vue-components -D 

# 通过 yarn 安装 
yarn add unplugin-vue-components -D 

# 通过 pnpm 安装 
pnpm add unplugin-vue-components -D
  • 如果是基于 vite 的项目,在 vite.config.js 文件中配置插件:
import vue from '@vitejs/plugin-vue'; 
import Components from 'unplugin-vue-components/vite'; 
import { VantResolver } from 'unplugin-vue-components/resolvers'; 
export default { 
    plugins: [ 
        vue(), 
        Components({ 
            resolvers: [VantResolver()], 
        }), 
    ], 
};
  • 使用组件

image.png

<template>
  <div class="home">
    <h2>This is an home page</h2>
    <van-button type="primary">按钮</van-button>
    <van-button type="primary">按钮</van-button>
    <van-button type="primary">按钮</van-button>
  </div>
</template>

4.2 修改UI组件库的样式方案


#1.用插槽,插入自己的元素
在自己的作用域中直接修改这个元素

#2.修改默认样式
● 全局定义一个变量,覆盖它默认变量的值
  缺点:全局修改
> :root{--van-tabbar-item-icon-size: 30px !important;}
  
● 局部定义一个变量,覆盖它默认变量的值
  优点:局部修改
>  --van-tabbar-item-icon-size: 30px !important;
  
● 直接查找对应的子组件选择器,进行修改,这样是直接修改css
>  :deep(.van-tabbar-item__icon) {
     font-style: 50px;
  }

4.3 使用vant封装tabbar组件

<script setup>
import tabbarData from '@/assets/localData/tabbar'
import { getAssetURL } from '@/utils/load_assets'
import { ref, watch } from 'vue';
import { useRoute } from 'vue-router';

const route = useRoute()
const currentIndex = ref(0)
// 在路由表中匹配查找索引
watch(route, (newRoute) => {
  const index = tabbarData.findIndex(item => item.path === newRoute.path)
  // 比如进入城市等页面
  if(index === -1) return 
  currentIndex.value = index
})

</script>

<template>
  <div class="tab-bar">

    <!-- route开启路由匹配,在地址栏输入地址可以对应item激活 -->
    <van-tabbar 
      v-model="currentIndex" 
      active-color="#ff9645" 
      route
    >
      <template v-for="(item, index) in tabbarData" :key="item.text">
        <!-- 点击对应item,进入对应地址,地址栏改变 -->
        <van-tabbar-item :to="item.path">
          <span>{{ item.text }}</span>
          <template #icon>
            <img v-if="currentIndex !== index" :src="getAssetURL(item.image)" alt="">
            <img v-else :src="getAssetURL(item.imageActive)" alt="">
          </template>
        </van-tabbar-item>
      </template>
    </van-tabbar>

  </div>
</template>

<style lang="less" scoped>
.tab-bar {
  img {
    height: 26px;
  }
}
</style>

五、首页home-nav-bar、banner和地址的展示

image.png

5.1 home-nav-bar.vue

<script setup>

</script>

<template>
  <div class="nav-bar">
    <div class="title">Hi旅途</div>
  </div>
</template>

<style lang="less" scoped>
.nav-bar {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 46px;
  border-bottom: 1px solid #f2f2f2;
  background-color: #fff;
  
  .title {
    color: var(--primary-color);
    font-size: 16px;
    font-weight: 700;
  }
}
</style>

5.2 首页中banners和地址

<script setup>
import HomeNavBar from './components/home-nav-bar.vue'
</script>

<template>
  <div class="home">
    <home-nav-bar></home-nav-bar>

    <div class="banner">
      <img src="@/assets/img/home/banner.webp" alt="">
    </div>

    <div class="location">
      <div class="city">宁夏</div>
      <div class="position">
        <span class="text">我的位置</span>
        <img src="@/assets/img/home/icon_location.png" alt="">
      </div>
    </div>
  </div>
</template>

<style lang="less" scoped>
.banner {
  img {
    width: 100%;
  }
}

.location {
  display: flex;
  align-items: center;
  height: 44px;
  padding: 0 20px;

  .city {
    flex: 1;
  }

  .position {
    display: flex;
    width: 74px;
    align-items: center;
    justify-content: center;

    .text {
      position: relative;
      top: 1px;
      font-size: 12px;
    }

    img {
      margin-left: 5px;
      width: 18px;
      height: 18px;
    }
  }
}
</style>

六、封装home-search-box组件

6.1 组件获取地址和city页面跳转

image.png

<script setup>
import { useRouter } from 'vue-router';

const router = useRouter()

const cityClick = () => {
  router.push('/city')
}

// 获取位置信息
const positionClick = () => {
  navigator.geolocation.getCurrentPosition(res => {
    console.log("获取位置成功:", res);
  }, err => {
    console.log("获取位置失败:", err);
  }, {
    enableHighAccuracy: true,
    timeout: 5000,
    maximumAge: 0
  })
}


</script>

<template>
  <div class="search">
    <div class="location">
      <div class="city" @click="cityClick">宁夏</div>
      <div class="position" @click="positionClick">
        <span class="text">我的位置</span>
        <img src="@/assets/img/home/icon_location.png" alt="">
      </div>
    </div>
  </div>
</template>

<style lang="less" scoped>
.location {
  display: flex;
  align-items: center;
  height: 44px;
  padding: 0 20px;

  .city {
    flex: 1;
  }

  .position {
    display: flex;
    width: 74px;
    align-items: center;
    justify-content: center;

    .text {
      position: relative;
      top: 1px;
      font-size: 12px;
    }

    img {
      margin-left: 5px;
      width: 18px;
      height: 18px;
    }
  }
}
</style>
  • home组件使用
<script setup>
import HomeNavBar from './components/home-nav-bar.vue'
import homeSearchBox from './components/home-search-box.vue';
</script>

<template>
  <div class="home">
    <home-nav-bar></home-nav-bar>

    <div class="banner">
      <img src="@/assets/img/home/banner.webp" alt="">
    </div>

    <home-search-box></home-search-box>
  </div>
</template>

<style lang="less" scoped>
.banner {
  img {
    width: 100%;
  }
}
</style>

七、city页面开发

7.1 跳转到city页面隐藏Tabbar

image.png

  • 隐藏Tabbar的方案
方案一:
+ 需要隐藏的页面在路由配置表中配置meta:{hideTabBar: true}

+ 然后在App.vue中通过v-show控制显示隐藏
import TabBar from "@/components/tabbar/index.vue"
import { useRoute } from "vue-router";
const route = useRoute()

</script>

<template>
  <div class="app">
    <router-view></router-view>
    
    <tab-bar v-if="!route.meta.hideTabBar"></tab-bar>
  </div>
</template>

<style lang="less" scoped>

</style>
方案二:
+ 在公共样式中添加隐藏tabbar的样式,局部组件使用即可
/* 隐藏tabbar,在一个页面中滚动 */
.top-page {
  position: relative;
  z-index: 9;
  height: 100vh;
  background-color: #fff;
  overflow-y: auto;
}

<div class="city top-page">
    <h2>This is an city page</h2>
</div>

7.2 城市页面的搜索框开发

image.png

  • 样式修改
:root {
  /* tabbar颜色 */
  --primary-color: #ff9645;
  /* 搜索框icon颜色 */
  --van-search-left-icon-color: var(--primary-color) !important;
}
  • city搜索框搭建
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';

const router = useRouter()

// 搜索框的功能
const searchValue = ref("")

// 点击取消返回上一个页面
const cancelClick = () => {
  router.back()
}
</script>

<template>
  <div class="city top-page">
    <van-search 
      v-model="searchValue" 
      placeholder="城市/区域/位置" shape="round" 
      show-action
      @cancel="cancelClick"
    ></van-search>
  </div>
</template>

<style lang="less" scoped>

</style>

7.3 tab开发

image.png

<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';

const router = useRouter()

// 1.搜索框的功能
const searchValue = ref("")

// 点击取消返回上一个页面
const cancelClick = () => {
  router.back()
}

// 2.tab的切换
// 技巧:刚开始不知道类型绑定一个空值,然后在dev-tools中查看绑定的是什么再修改默认值
const tabActive = ref(0)


</script>

<template>
  <div class="city top-page">
    <!-- 1.搜索框 -->
    <van-search 
      v-model="searchValue" 
      placeholder="城市/区域/位置" shape="round" 
      show-action
      @cancel="cancelClick"
    ></van-search>

    <!-- 2.tab的切换 -->
    <van-tabs v-model:active="tabActive" color="#ff9854">
      <van-tab title="国内·港澳台"></van-tab>
      <van-tab title="海外"></van-tab>
    </van-tabs>
  </div>
</template>

<style lang="less" scoped>

</style>

7.4 网络封装-模块的API抽取

  • axios封装-HYRequest
1.request/config.js
// 获取城市数据的url
export const BASE_URL = "http://123.207.32.32:1888/api"
// export const BASE_URL = "http://codercba.com:1888/api"
export const TIMEOUT = 1000

2.导入到封装的库里面,然后运用
import { BASE_URL, TIMEOUT } from './config'

export default new HYRequest(BASE_URL, TIMEOUT)

  • 模块modules/city.js
city模块请求数据即可

import hyRequest from '../request'

// 请求city城市的数据
export function getCityAll() {
  return hyRequest.get({
    url: "/city/all"
  })
}
  • city页面调用封装的api,拿到数据
// 网络请求:请求城市的数据
getCityAll().then(res => {
  console.log(res);
})

7.5 city页面动态获取tab

image.png

<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { getCityAll } from '@/services'

const router = useRouter()

// 1.搜索框的功能
const searchValue = ref("")

// 点击取消返回上一个页面
const cancelClick = () => {
  router.back()
}

// 2.tab的切换
// 技巧:刚开始不知道类型绑定一个空值,然后在dev-tools中查看绑定的是什么再修改默认值
const tabActive = ref(0)


/**
 * 组件发送网络请求的缺点:
 *  + 如果网络请求太多,那么页面中包含大量的网络请求和数据的处理逻辑
 *  + 如果页面封装了很多的子组件,子组件需要这些数据,我们必须一步步将数据传过去
 */
// 网络请求:请求城市的数据
const allCity = ref({})
getCityAll().then(res => {
  allCity.value = res.data
})


</script>

<template>
  <div class="city top-page">
    <!-- 1.搜索框 -->
    <van-search 
      v-model="searchValue" 
      placeholder="城市/区域/位置" shape="round" 
      show-action
      @cancel="cancelClick"
    ></van-search>

    <!-- 2.tab的切换 -->
    <van-tabs v-model:active="tabActive" color="#ff9854">
      <!-- 
        方法一:
        <van-tab :title="allCity?.cityGroup?.title"></van-tab>
        <van-tab :title="allCity?.cityGroupOverSea?.title"></van-tab>
       -->
       <template v-for="(value, key, index) in allCity" :key="key">
         <van-tab :title="value.title"></van-tab>
       </template>
    </van-tabs>
  </div>
</template>

<style lang="less" scoped>

</style>

7.6 store管理城市数据网络请求

image.png

  • stores/modules/city.js
import { getCityAll } from "@/services";
import { defineStore } from "pinia";

const useCity = defineStore("city", {
  state: () => ({
    allCities: {}
  }),
  actions: {
    async fetchAllCitiesData(){
      const res = await getCityAll()
      this.allCities = res.data
    }
  }
})

export default useCity
  • city页面应用
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { getCityAll } from '@/services'
import useCity from '@/stores/modules/city'
import { storeToRefs } from 'pinia';

const router = useRouter()

// 1.搜索框的功能
const searchValue = ref("")

// 点击取消返回上一个页面
const cancelClick = () => {
  router.back()
}

// 2.tab的切换
// 技巧:刚开始不知道类型绑定一个空值,然后在dev-tools中查看绑定的是什么再修改默认值
const tabActive = ref(0)


/**
 * 组件发送网络请求的缺点:
 *  + 如果网络请求太多,那么页面中包含大量的网络请求和数据的处理逻辑
 *  + 如果页面封装了很多的子组件,子组件需要这些数据,我们必须一步步将数据传过去
 */
// 网络请求:请求城市的数据
// const allCity = ref({})
// getCityAll().then(res => {
//   allCity.value = res.data
// })

// 从store中获取数据
const cityStore = useCity()
cityStore.fetchAllCitiesData()
const { allCities } = storeToRefs(cityStore)


</script>

<template>
  <div class="city top-page">
    <!-- 1.搜索框 -->
    <van-search 
      v-model="searchValue" 
      placeholder="城市/区域/位置" shape="round" 
      show-action
      @cancel="cancelClick"
    ></van-search>

    <!-- 2.tab的切换 -->
    <van-tabs v-model:active="tabActive" color="#ff9854">
      <!-- 
        方法一:
        <van-tab :title="allCity?.cityGroup?.title"></van-tab>
        <van-tab :title="allCity?.cityGroupOverSea?.title"></van-tab>
       -->
       <template v-for="(value, key, index) in allCities" :key="key">
         <van-tab :title="value.title"></van-tab>
       </template>
    </van-tabs>
  </div>
</template>

<style lang="less" scoped>

</style>

7.7 动态获取tab的数据

image.png

<script setup>
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import useCity from '@/stores/modules/city'
import { storeToRefs } from 'pinia';

const router = useRouter()

// 1.搜索框的功能
const searchValue = ref("")

// 点击取消返回上一个页面
const cancelClick = () => {
  router.back()
}

// 2.tab的切换
const tabActive = ref(0)

// 从store中获取数据
const cityStore = useCity()
cityStore.fetchAllCitiesData()
const { allCities } = storeToRefs(cityStore)

/**
 * 获取选中标签后的数据
 * 1. 获取正确的key:将tabs中绑定的tabAction正确的绑定
 *   + 绑定name属性后tabActive.value就不是默认的索引,而是对应name绑定的对象属性
 * 
 * 2.根据可以从allCities获取数据,默认直接获取的数据不是响应式的,要包裹computed
 */
const currentGroup = computed(() => allCities.value[tabActive.value])

</script>

<template>
  <div class="city top-page">
    <div class="top">
      <!-- 1.搜索框 -->
      <van-search 
        v-model="searchValue" 
        placeholder="城市/区域/位置" 
        shape="round" 
        show-action
        @cancel="cancelClick"
      ></van-search>

      <!-- 2.tab的切换 -->
      <van-tabs v-model:active="tabActive" color="#ff9854">
        <template v-for="(value, key, index) in allCities" :key="key">
          <van-tab :title="value.title" :name="key"></van-tab>
        </template>
      </van-tabs>
    </div>
    <div class="content">
      <template v-for="item in currentGroup?.cities">
        <div>{{ item }}</div>
      </template>
    </div>
</div>
</template>

<style lang="less" scoped>
.city {

  // 方案一:整个页面滚动,top固定
  // .top {
  //   position: fixed;
  //   top: 0;
  //   left: 0;
  //   right: 0;
  // }
  // .content {
  //   margin-top: 98px;
  // }

  // 方案二:局部滚动-固定高度
  .content {
    height: calc(100vh - 98px);
    overflow-y: auto;
  }

}
</style>

7.8 城市分组列表数据展示

image.png

  • city页面
<script setup>
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import useCity from '@/stores/modules/city'
import { storeToRefs } from 'pinia';

import CityGroup from './components/city-group.vue'

const router = useRouter()

// 1.搜索框的功能
const searchValue = ref("")

// 点击取消返回上一个页面
const cancelClick = () => {
  router.back()
}

// 2.tab的切换
const tabActive = ref(0)

// 从store中获取数据
const cityStore = useCity()
cityStore.fetchAllCitiesData()
const { allCities } = storeToRefs(cityStore)

/**
 * 获取选中标签后的数据
 * 1. 获取正确的key:将tabs中绑定的tabAction正确的绑定
 *   + 绑定name属性后tabActive.value就不是默认的索引,而是对应name绑定的对象属性
 * 
 * 2.根据可以从allCities获取数据,默认直接获取的数据不是响应式的,要包裹computed
 */
const currentGroup = computed(() => allCities.value[tabActive.value])

</script>

<template>
  <div class="city top-page">
    <div class="top">
      <!-- 1.搜索框 -->
      <van-search 
        v-model="searchValue" 
        placeholder="城市/区域/位置" 
        shape="round" 
        show-action
        @cancel="cancelClick"
      ></van-search>

      <!-- 2.tab的切换 -->
      <van-tabs v-model:active="tabActive" color="#ff9854">
        <template v-for="(value, key, index) in allCities" :key="key">
          <van-tab :title="value.title" :name="key"></van-tab>
        </template>
      </van-tabs>
    </div>
    <div class="content">
      <!-- 
        <city-group :group-data="currentGroup"></city-group>
        由于数据较多,切换速度较慢,所以使用v-show提高切换速度
       -->
      <template v-for="(value, key, index) in allCities">
        <city-group v-show="tabActive === key" :group-data="value"></city-group>
      </template>
    </div>
</div>
</template>

<style lang="less" scoped>
.city {
  // index-bar组件是fixed定位,往上滚动的时候会覆盖非定位元素
  .top {
    position: relative;
    z-index: 9;
  }

  // 方案一:整个页面滚动,top固定
  // .top {
  //   position: fixed;
  //   top: 0;
  //   left: 0;
  //   right: 0;
  // }
  // .content {
  //   margin-top: 98px;
  // }

  // 方案二:局部滚动-固定高度
  .content {
    height: calc(100vh - 98px);
    overflow-y: auto;
  }

}
</style>
  • city-group.vue
<script setup>

defineProps({
  groupData: {
    type: Object,
    default: () => ({})
  }
})
</script>

<template>
  <div class="city-group">
    <van-index-bar>
      <template v-for="(group, index) in groupData.cities" :key="index">
        <van-index-anchor :index="group.group" />
        <template v-for="(city, cityIndex) in group.cities" :key="cityIndex">
          <van-cell :title="city.cityName" />
        </template>
      </template>
    </van-index-bar>

    <!-- 
    原有组件用vant封装
    <template v-for="(group, index) in groupData.cities" :key="index">
      <h2 class="title">标题:{{ group.group }}</h2>
      <div class="list">
        <template v-for="(city, cityIndex) in group.cities" :key="cityIndex">
          <div class="city">{{ city.cityName }}</div>
        </template>
      </div>
    </template>
    -->
  </div>
</template>

<style lang="less" scoped></style>

7.9 热门展示、城市列表的索引动态映射和添加热门索引的展示

image.png

  • city-group.vue
<script setup>
import { computed } from 'vue'

const props = defineProps({
  groupData: {
    type: Object,
    default: () => ({})
  }
})

const indexList = computed(() => {
  // 城市列表的索引动态映射
  const list = props.groupData.cities.map(item => item.group)
  // 添加热门索引的展示
  list.unshift('#')
  return list
})
</script>

<template>
  <div class="city-group">
    <van-index-bar :sticky="false" :index-list="indexList">
      <!-- 热门展示 -->
      <van-index-anchor index="热门" />
      <div class="list">
        <template v-for="(city, index) in groupData.hotCities">
          <div class="city">{{ city.cityName }}</div>
        </template>
      </div>

      <!-- 城市列表展示 -->
      <template v-for="(group, index) in groupData.cities" :key="index">
        <van-index-anchor :index="group.group" />
        <template v-for="(city, cityIndex) in group.cities" :key="cityIndex">
          <van-cell :title="city.cityName" />
        </template>
      </template>
    </van-index-bar>
  </div>
</template>

<style lang="less" scoped>
.city-group {
  --van-index-anchor-background: #f1f3f6;

  .list {
    display: flex;
    flex-wrap: wrap;
    justify-content: space-around;
    padding: 10px;
    padding-right: 25px;

    .city {
      width: 70px;
      height: 28px;
      border-radius: 14px;
      font-size: 12px;
      color: #000;
      text-align: center;
      line-height: 28px;
      background-color: #fff4ec;
      margin: 6px 0;
    }
  }

}
</style>

7.10 选择城市回退和回显效果

image.png

  • city-group.vue 监听城市的点击
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router';
import useCity from '@/stores/modules/city'
// 定义props
const props = defineProps({
  groupData: {
    type: Object,
    default: () => ({})
  }
})

// 动态的索引
const indexList = computed(() => {
  // 城市列表的索引动态映射
  const list = props.groupData.cities.map(item => item.group)
  // 添加热门索引的展示
  list.unshift('#')
  return list
})

// 监听城市的点击
const router = useRouter()
const cityStore = useCity()
const cityClick = (city) => {
  // 选中当前城市
  cityStore.currentCity = city

  // 返回上一级
  router.back()
}
</script>

<template>
  <div class="city-group">
    <van-index-bar :sticky="false" :index-list="indexList">
      <!-- 热门展示 -->
      <van-index-anchor index="热门" />
      <div class="list">
        <template v-for="(city, index) in groupData.hotCities">
          <div class="city" @click="cityClick(city)">{{ city.cityName }}</div>
        </template>
      </div>

      <!-- 城市列表展示 -->
      <template v-for="(group, index) in groupData.cities" :key="index">
        <van-index-anchor :index="group.group" />
        <template v-for="(city, cityIndex) in group.cities" :key="cityIndex">
          <van-cell :title="city.cityName" @click="cityClick(city)"/>
        </template>
      </template>
    </van-index-bar>
  </div>
</template>

<style lang="less" scoped>
.city-group {
  --van-index-anchor-background: #f1f3f6;

  .list {
    display: flex;
    flex-wrap: wrap;
    justify-content: space-around;
    padding: 10px;
    padding-right: 25px;

    .city {
      width: 70px;
      height: 28px;
      border-radius: 14px;
      font-size: 12px;
      color: #000;
      text-align: center;
      line-height: 28px;
      background-color: #fff4ec;
      margin: 6px 0;
    }
  }

}
</style>
  • 返回home-search-box.vue页面,回显
<script setup>
import { useRouter } from 'vue-router';
import useCity from '@/stores/modules/city';
import { storeToRefs } from 'pinia';

const router = useRouter()

const cityClick = () => {
  router.push('/city')
}

// 获取位置信息
const positionClick = () => {
  navigator.geolocation.getCurrentPosition(res => {
    console.log("获取位置成功:", res);
  }, err => {
    console.log("获取位置失败:", err);
  }, {
    enableHighAccuracy: true,
    timeout: 5000,
    maximumAge: 0
  })
}

// 当前城市
const cityStore = useCity()
const { currentCity } = storeToRefs(cityStore)


</script>

<template>
  <div class="sea">
    <div class="location">
      <div class="city" @click="cityClick">{{ currentCity.cityName }}</div>
      <div class="position" @click="positionClick">
        <span class="text">我的位置</span>
        <img src="@/assets/img/home/icon_location.png" alt="">
      </div>
    </div>
  </div>
</template>

<style lang="less" scoped>
.location {
  display: flex;
  align-items: center;
  height: 44px;
  padding: 0 20px;

  .city {
    flex: 1;
  }

  .position {
    display: flex;
    width: 74px;
    align-items: center;
    justify-content: center;

    .text {
      position: relative;
      top: 1px;
      font-size: 12px;
    }

    img {
      margin-left: 5px;
      width: 18px;
      height: 18px;
    }
  }
}
</style>

八、首页日期范围

8.1 默认显示

image.png

  • 页面
<!--日期范围  -->
<div class="data-range">
  <div class="start">
    <div class="date">
      <span class="tip">入住</span>
      <span class="time">{{ startDate }}</span>
    </div>
  </div>
  <div class="stay">共一晚</div>
  <div class="end">
    <div class="date">
      <span class="tip">离店</span>
      <span class="time">{{ endDate }}</span>
    </div>
  </div>
</div>

import { formatMonthDay } from '@/utils/format_data'

// 日期范围的处理
const nowDate = new Date()
const startDate = ref(formatMonthDay(nowDate))
const newDate = nowDate.setDate(nowDate.getDate() + 1)
const endDate = ref(formatMonthDay(newDate))

.data-range {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  padding: 0 20px;

  .stay {
    display: flex;
    flex: 1;
    height: 44px;
    justify-content: center;
    align-items: center;
    font-size: 12px;
    color: #666;
  }

  .end {
    min-width: 30%;
  }
  .date {
    display: flex;
    height: 44px;
    flex-direction: column;
    justify-content: center;
    align-items: flex-start;
    line-height: 16px;

    .tip {
      line-height: 16px;
      font-size: 10px;
      color: #999;
    }
    .time {
      font-weight: 500;
      font-size: 15px;
      color: #333;
    }
  }

}
  • 用到的工具函数 formatMonthDay
    npm i dayjs
import dayjs from 'dayjs'

// xx月xx日
export function formatMonthDay(date, formatStr="MM月DD日"){
  return dayjs(date).format(formatStr)
}

8.2 日期的动态显示及动态计算天数

image.png

  • dayjs计算差多少天
// 计算时间差多少天
export function getDiffDays(startDate, endDate){
  return dayjs(endDate).diff(startDate, "day")
}
  • home-search-box组件页面
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router';
import useCity from '@/stores/modules/city';
import { storeToRefs } from 'pinia';
import { formatMonthDay, getDiffDays } from '@/utils/format_data'

const router = useRouter()

const cityClick = () => {
  router.push('/city')
}

// 获取位置信息
const positionClick = () => {
  navigator.geolocation.getCurrentPosition(res => {
    console.log("获取位置成功:", res);
  }, err => {
    console.log("获取位置失败:", err);
  }, {
    enableHighAccuracy: true,
    timeout: 5000,
    maximumAge: 0
  })
}

// 当前城市
const cityStore = useCity()
const { currentCity } = storeToRefs(cityStore)

// 日期范围的处理
const nowDate = new Date()
const newDate = new Date()
newDate.setDate(nowDate.getDate() + 1)

const startDate = ref(formatMonthDay(nowDate))
const endDate = ref(formatMonthDay(newDate))

// 天数处理
const stayCount = ref(getDiffDays(nowDate, newDate))

// 日历显示隐藏
const showCalendar = ref(false)
const onConfirm = (value) => {
  // 1.设置日期
  const selectStartData = value[0]
  const selectEndData = value[1]
  startDate.value = formatMonthDay(selectStartData)
  endDate.value = formatMonthDay(selectEndData)
  stayCount.value = getDiffDays( selectStartData, selectEndData)

  // 2.隐藏日历
  showCalendar.value = false
}


</script>

<template>
  <div class="search-box">
    <!-- 位置信息 -->
    <div class="location">
      <div class="city" @click="cityClick">{{ currentCity.cityName }}</div>
      <div class="position" @click="positionClick">
        <span class="text">我的位置</span>
        <img src="@/assets/img/home/icon_location.png" alt="">
      </div>
    </div>

    <!--日期范围  -->
    <div class="data-range" @click="showCalendar = true">
      <div class="start">
        <div class="date">
          <span class="tip">入住</span>
          <span class="time">{{ startDate }}</span>
        </div>
      </div>
      <div class="stay">共{{ stayCount }}晚</div>
      <div class="end">
        <div class="date">
          <span class="tip">离店</span>
          <span class="time">{{ endDate }}</span>
        </div>
      </div>
    </div>

    <!-- 日历组件 -->
    <van-calendar 
      v-model:show="showCalendar" 
      type="range"
      color="#ff9854"
      :round="false"
      :show-confirm="false"
      @confirm="onConfirm" />
  </div>
</template>

<style lang="less" scoped>
.search-box {
  --van-calendar-popup-height: 100%;
  background-color: #fff;
}

.location {
  display: flex;
  align-items: center;
  height: 44px;
  padding: 0 20px;

  .city {
    flex: 1;
  }

  .position {
    display: flex;
    width: 74px;
    align-items: center;
    justify-content: center;

    .text {
      position: relative;
      top: 1px;
      font-size: 12px;
    }

    img {
      margin-left: 5px;
      width: 18px;
      height: 18px;
    }
  }
}
.data-range {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  padding: 0 20px;

  .stay {
    display: flex;
    flex: 1;
    height: 44px;
    justify-content: center;
    align-items: center;
    font-size: 12px;
    color: #666;
  }

  .end {
    min-width: 30%;
  }
  .date {
    display: flex;
    height: 44px;
    flex-direction: column;
    justify-content: center;
    align-items: flex-start;
    line-height: 16px;

    .tip {
      line-height: 16px;
      font-size: 10px;
      color: #999;
    }
    .time {
      font-weight: 500;
      font-size: 15px;
      color: #333;
    }
  }

}
</style>

九、价格人数和关键字基本展示

image.png

  • 公共样式
:root {
  /* 自定义 */
  /* tabbar颜色 */
  --primary-color: #ff9645;
  --line-color: #eef2f2;

  /* 全局修改 */
  /* 搜索框icon颜色 */
  --van-search-left-icon-color: var(--primary-color) !important;
}
.bottom-gray-line {
  border-bottom: 1px solid var(--line-color);
}
  • 页面展示-整理了css
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router';
import useCity from '@/stores/modules/city';
import { storeToRefs } from 'pinia';
import { formatMonthDay, getDiffDays } from '@/utils/format_data'

const router = useRouter()

const cityClick = () => {
  router.push('/city')
}

// 获取位置信息
const positionClick = () => {
  navigator.geolocation.getCurrentPosition(res => {
    console.log("获取位置成功:", res);
  }, err => {
    console.log("获取位置失败:", err);
  }, {
    enableHighAccuracy: true,
    timeout: 5000,
    maximumAge: 0
  })
}

// 当前城市
const cityStore = useCity()
const { currentCity } = storeToRefs(cityStore)

// 日期范围的处理
const nowDate = new Date()
const newDate = new Date()
newDate.setDate(nowDate.getDate() + 1)

const startDate = ref(formatMonthDay(nowDate))
const endDate = ref(formatMonthDay(newDate))

// 天数处理
const stayCount = ref(getDiffDays(nowDate, newDate))

// 日历显示隐藏
const showCalendar = ref(false)
const onConfirm = (value) => {
  // 1.设置日期
  const selectStartData = value[0]
  const selectEndData = value[1]
  startDate.value = formatMonthDay(selectStartData)
  endDate.value = formatMonthDay(selectEndData)
  stayCount.value = getDiffDays( selectStartData, selectEndData)

  // 2.隐藏日历
  showCalendar.value = false
}


</script>

<template>
  <div class="search-box">
    <!-- 位置信息 -->
    <div class="location bottom-gray-line">
      <div class="city" @click="cityClick">{{ currentCity.cityName }}</div>
      <div class="position" @click="positionClick">
        <span class="text">我的位置</span>
        <img src="@/assets/img/home/icon_location.png" alt="">
      </div>
    </div>

    <!--日期范围  -->
    <div class="item date-range bottom-gray-line" @click="showCalendar = true">
      <div class="start">
        <div class="date">
          <span class="tip">入住</span>
          <span class="time">{{ startDate }}</span>
        </div>
      </div>
      <div class="stay">共{{ stayCount }}晚</div>
      <div class="end">
        <div class="date">
          <span class="tip">离店</span>
          <span class="time">{{ endDate }}</span>
        </div>
      </div>
    </div>

    <!-- 日历组件 -->
    <van-calendar 
      v-model:show="showCalendar" 
      type="range"
      color="#ff9854"
      :round="false"
      :show-confirm="false"
      @confirm="onConfirm" />

    <div class="item price-counter bottom-gray-line">
      <div class="start">价格不限</div>
      <div class="end">人数不限</div>
    </div>
    <div class="item keyword bottom-gray-line">关键字/位置/民宿名</div>
  </div>

  
</template>

<style lang="less" scoped>
.search-box {
  --van-calendar-popup-height: 100%;
  background-color: #fff;
}

.location {
  display: flex;
  align-items: center;
  height: 44px;
  padding: 0 20px;

  .city {
    flex: 1;
    font-style: 15px;
    color: #333;
  }

  .position {
    display: flex;
    width: 74px;
    align-items: center;
    justify-content: center;

    .text {
      position: relative;
      top: 1px;
      font-size: 12px;
      color: #666;
    }

    img {
      margin-left: 5px;
      width: 18px;
      height: 18px;
    }
  }
}

.date-range {
  // height: 44px;
  .stay {
    flex: 1;
    text-align: center;
    font-size: 12px;
    color: #666;
  }
}

.price-counter {
  .start {
    border-right: 1px solid var(--line-color);
  }
}


.item {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  height: 44px;
  padding: 0 20px;
  color: #999;

  .start {
    flex: 1;
    display: flex;
    height: 44px;
    align-items: center;
  }

  .end {
    min-width: 30%;
    padding-left: 20px;
  }

  .date {
    display: flex;
    flex-direction: column;

    .tip {
      font-size: 12px;
      color: #999;
    }

    .time {
      margin-top: 3px;
      color: #333;
      font-size: 15px;
      font-weight: 500;
    }
  }
}
</style>

十、热门建议

image.png

  • 子组件home-search-box
<!-- 热门建议 -->
<div class="hot-suggests">
  <template v-for="(item, index) in hotSuggests" :key="index">
    <div 
      class="item"
      :style="{color: item.tagText.color,background: item.tagText.background.color}"
    >
      {{ item.tagText.text }}
    </div>
  </template>
</div>

.hot-suggests {
  display: flex;
  flex-wrap: wrap;
  padding: 10px 12px 6px 16px;
  .item {
    height: 22px;
    padding: 4px 8px;
    margin: 4px;
    border-radius: 14px;
    font-size: 12px;
    line-height: 14px;
  }
}


import { storeToRefs } from 'pinia';
import useHome from '@/stores/modules/home'

// 热门建议
const homeStore = useHome()
const { hotSuggests } = storeToRefs(homeStore)
  • home热门推荐网络请求
import hyRequest from "../request"

export function getHomeHotSuggests(){
  return hyRequest.get({
    url: "/home/hotSuggests"
  })
}
  • store管理home中的网络请求
import { getHomeHotSuggests } from '@/services';
import { defineStore } from 'pinia'


const useHome = defineStore("home", {
  state: () => ({
    hotSuggests: [],
    categories: []
  }),
  actions: {
    async fetchHotSuggestsData() {
      const res = await getHomeHotSuggests()
      this.hotSuggests = res.data
    }
  }
})

export default useHome
  • 父组件调用actions发送网络请求
// 发送网络请求
const homeStore= useHome()
homeStore.fetchHotSuggestsData()

十一、搜索功能搭建与点击跳转搜索页面

image.png

  • home-search-box页面
<!-- 搜索按钮 -->
<div class="item search-btn" @click="searchBtnClick">
  <div class="btn">开始搜索</div>
</div>

<style lang="less" scoped>
.search-btn {
  .btn {
    width: 342px;
    height: 38px;
    max-height: 50px;
    font-weight: 500;
    font-size: 18px;
    line-height: 38px;
    text-align: center;
    border-radius: 20px;
    color: #fff;
    background-image: var(--theme-linear-gradient);
  }
}
</style>

<script>
// 搜索并且跳转到search页面
const searchBtnClick = () => {
  router.push({
    path: "/search",
    query: {
      startDate: startDate.value,
      endDate: endDate.value
    }
  })
}
</script>
  • 添加路由配置
{
  path: "/search",
  component: () => import("@/views/search/search.vue")
},
  • 点击跳转到search页面
<template>
  <div class="search top-page">
    <h2>开始时间:{{ $route.query.startDate }}</h2>
    <h2>结束时间:{{ $route.query.endDate }}</h2>
  </div>
</template>

十二、分类组件

12.1 分类菜单的展示过程

image.png

  • services/home.js api封装
// 分类
export function getHomeCategories(){
  return hyRequest.get({
    url: "/home/categories"
  })
}
  • stores/home.js 管理状态
import { getHomeHotSuggests, getHomeCategories } from '@/services';
import { defineStore } from 'pinia'


const useHome = defineStore("home", {
  state: () => ({
    hotSuggests: [],
    categories: []
  }),
  actions: {
    async fetchHotSuggestsData() {
      const res = await getHomeHotSuggests()
      this.hotSuggests = res.data
    },
    async fetchCategoriesData(){
      const res = await getHomeCategories()
      this.categories = res.data
    }
  }
})

export default useHome
  • home-categories.vue 拿到数据展示
<script setup>
import useHome from '@/stores/modules/home';
import { storeToRefs } from 'pinia';

const homeStore = useHome()
const { categories } = storeToRefs(homeStore)
</script>

<template>
  <div class="categories">
    <template v-for="(item, index) in categories" :key="item.id">
      <div class="item">
        <img :src="item.pictureUrl" alt="">
        <div class="text">{{ item.title }}</div>
      </div>
    </template>
  </div>
</template>

<style lang="less" scoped>
.categories {
  display: flex;
  align-items: center;
  overflow-x: auto;
  height: 80px;
  padding: 0 10px;
  // 隐藏滚动条
  &::-webkit-scrollbar {
    display: none;
  }
  background-color: #fff;
  
  .item {
    display: flex;
    flex-direction: column;
    flex-shrink: 0;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    width: 70px;
    
    img {
      width: 32px;
      height: 32px;
    }

    .text {
      font-style: 12px;
      margin-top: 8px;
    }
  }
  
}
</style>
  • home页面引用组件
<home-categories></home-categories>

<script>
import HomeCategories from './components/home-categories.vue';

// 发送网络请求
const homeStore= useHome()
homeStore.fetchHotSuggestsData()
homeStore.fetchCategoriesData()
</script>

十三、首页内容

13.1 首页内容标题展示

image.png

  • home-content组件简单封装
<script setup>

</script>

<template>
  <div class="content">
    <div class="title">热门精选</div>
  </div>
</template>

<style lang="less" scoped>
.content {
  display: flex;
  height: 48px;
  padding: 10px 16px;
  line-height: 22px;
  background-color: #fff;
  margin-top: 10px;
  border-radius: 10px 10px 0 0;

  .title {
    display: flex;
    align-items: center;
    font-size: 18px;
    color: #333;
    background: url('@/assets/img/home/header_bg.png') no-repeat ;
    background-size: 50% 50%;
    background-position: center 6px;
  }
}
</style>
  • home使用组件
import HomeContent from './components/home-content.vue';

<home-content></home-content>

13.2 首页houselist数据请求与展示

image.png

  • 请求封装
// 首页列表数据
export function getHomeHouselist(currentPage){
  return hyRequest.get({
    url: "/home/houselist",
    params: {
      page: currentPage
    }
  })
}
  • home中调用actions
homeStore.fetchHouselistData()
  • store中houselist获取到数据
import { getHomeHotSuggests, getHomeCategories, getHomeHouselist } from '@/services';
import { defineStore } from 'pinia'


const useHome = defineStore("home", {
  state: () => ({
    hotSuggests: [],
    categories: [],
    // 下一次请求的页码,不用的值请求不同页的数据
    currentPage: 1,
    houselist: []
  }),
  actions: {
    async fetchHotSuggestsData() {
      const res = await getHomeHotSuggests()
      this.hotSuggests = res.data
    },
    async fetchCategoriesData(){
      const res = await getHomeCategories()
      this.categories = res.data
    },
    async fetchHouselistData(){
      const res = await getHomeHouselist(this.currentPage)
      this.houselist.push(...res.data)
      this.currentPage++
    }
  }
})

export default useHome
  • home-content页面列表数据的不同类型展示
<script setup>
import useHome from '@/stores/modules/home';
import { storeToRefs } from 'pinia';

const homeStore = useHome()
const { houselist } = storeToRefs(homeStore)
</script>

<template>
  <div class="content">
    <div class="title">
      <span class="title-tab">热门精选</span>
    </div>
    <div class="list">
      <template v-for="(item, index) in houselist" :key="item.data.houseId">
        <h3 v-if="item.discoveryContentType === 9">type-9: {{ item.data.houseName }}</h3>
        <h3 v-if="item.discoveryContentType === 3">type-3: {{ item.data.houseName }}</h3>
      </template>
    </div>
  </div>
</template>

<style lang="less" scoped>
.content {
  display: flex;
  flex-direction: column;
  padding: 10px 16px;
  background-color: #fff;
  margin-top: 10px;
  border-radius: 10px 10px 0 0;

  .title {
    display: flex;
    align-items: center;
    height: 48px;
    font-size: 18px;
    color: #333;
    line-height: 22px;

    .title-tab {
      padding: 10px 0;
      background: url('@/assets/img/home/header_bg.png') no-repeat ;
      background-size: 50% 50%;
      background-position: center 6px;
    }
  }
}
</style>

13.3 不同类型的展示

  • 此处搜索框还没有开发

image.png

- v3的不同展示

image.png

  • v9
<script setup>
import { computed } from 'vue'

const props = defineProps({
  itemData: {
    type: Object,
    default: () => ({})
  }
})

const itemScore = computed(() => {
  return Number(props.itemData.commentScore)
})
</script>

<template>
  <div class="house-item">
    <div class="item-inner">
      <div class="cover">
        <img :src="itemData.image.url" alt="">
      </div>
      <div class="info">
        <div class="summary">{{ itemData.summaryText }}</div>
        <div class="name">{{ itemData.houseName }}</div>
        <div class="price">
          <van-rate 
            :model-value="itemScore" 
            :size="15"
            color="#fff"
            readonly
            allow-half
          />
          <div class="new">¥{{ itemData.finalPrice }}</div>
        </div>
      </div>
    </div>
  </div>
</template>

<style lang="less" scoped>
.house-item {
  width: 50%;

  .item-inner {
    position: relative;
    margin: 5px;
    background: #fff;
    border-radius: 6px;
    overflow: hidden;

    .cover {
      img {
        width: 100%;
      }
    }

    .info {
      position: absolute;
      bottom: 0;
      padding: 8px 10px;
      color: #fff;

      .summary {
        font-size: 12px;
      }

      .name {
        margin: 5px 0;
        overflow: hidden;
        text-overflow: ellipsis;
        display: -webkit-box;
        -webkit-line-clamp: 2;
        -webkit-box-orient: vertical;
      }

      .price {
        display: flex;
        justify-content: space-between;
        margin-top: 10px;
      }
    }
  }
}
</style>
  • v3
<script setup>
import { computed } from 'vue'

const props = defineProps({
  itemData: {
    type: Object,
    default: () => ({})
  }
})

const itemScore = computed(() => {
  return Number(props.itemData.commentScore)
})
</script>

<template>
  <div class="house-item">
    <div class="item-inner">
      <div class="cover">
        <img :src="itemData?.image?.url" alt="">
      </div>
      <div class="info">
        <div class="location">
          <img src="@/assets/img/home/location.png" alt="">
          <span>{{ itemData.location }}</span>
        </div>
        <div class="summary">{{ itemData.summaryText }}</div>
        <div class="name">{{ itemData.houseName }}</div>
        <div class="price">
          <div class="new">¥{{ itemData.finalPrice }}</div>
          <div class="old">¥{{ itemData.productPrice }}</div>
          <!-- 防止为undefined,也可以?可选链解决 -->
          <div class="tip" v-if="itemData.priceTipBadge">{{ itemData.priceTipBadge.text }}</div>
        </div>
      </div>
    </div>
  </div>
</template>

<style lang="less" scoped>
.house-item {
    width: 50%;

    .item-inner {
      margin: 5px;
      background: #fff;
      border-radius: 6px;
      overflow: hidden;

      .cover {
        img {
          width: 100%;
        }
      }

      .info {
        padding: 8px 10px;
        color: #666;
        font-size: 12px;
      }

      .location {
        display: flex;
        align-items: center;
        img {
          width: 12px;
          height: 12px;
        }

        .text {
          margin-left: 2px;
          font-size: 12px;
          color: #666;
        }
      }

      .name {
        margin: 5px 0;
        font-size: 14px;
        color: #333;

        overflow: hidden;
        text-overflow: ellipsis;
        display: -webkit-box;
        -webkit-line-clamp: 2;
        -webkit-box-orient: vertical;
      }

      .price {
        display: flex;
        align-items: baseline;

        margin: 8px 0;
        .new {
          color: #ff9645;
          font-size: 14px;
        }

        .old {
          margin: 0 3px;
          color: #999;
          font-size: 12px;
          text-decoration: line-through;
        }

        .tip {
          background-image: linear-gradient(270deg,#f66,#ff9f9f);
          color: #fff;
          padding: 0 6px;
          border-radius: 8px;
        }
      }
    }
  }
</style>
  • home-content中展示
<script setup>
import useHome from '@/stores/modules/home';
import { storeToRefs } from 'pinia';
import HouseItemV9 from "@/components/house-item-v9/index.vue"
import HouseItemV3 from "@/components/house-item-v3/index.vue"

const homeStore = useHome()
const { houselist } = storeToRefs(homeStore)
</script>

<template>
  <div class="content">
    <div class="title">
      <span class="title-tab">热门精选</span>
    </div>
    <div class="list">
      <template v-for="(item, index) in houselist" :key="item.data.houseId">
        <house-item-v9 
          v-if="item.discoveryContentType === 9" 
          :item-data="item.data"
        />
        <house-item-v3 
          v-else-if="item.discoveryContentType === 3" 
          :item-data="item.data"
        />
      </template>
    </div>
  </div>
</template>

<style lang="less" scoped>
.content {
  display: flex;
  flex-direction: column;
  padding: 10px 16px;
  background-color: #fff;
  margin-top: 10px;
  border-radius: 10px 10px 0 0;

  .title {
    display: flex;
    align-items: center;
    height: 48px;
    font-size: 18px;
    color: #333;
    line-height: 22px;

    .title-tab {
      padding: 10px 0;
      background: url('@/assets/img/home/header_bg.png') no-repeat ;
      background-size: 50% 50%;
      background-position: center 6px;
    }
  }

  .list {
    display: flex;
    flex-wrap: wrap;
  }
}
</style>

13.4 首页加载更多

// 监听window窗口的滚动,加载更多
// 1.离开页面移除监听
// 2.如果别的页码也进行类似的监听,会编写重复的代码
const scrollListenerHandler = () => {
  const clientHeight = document.documentElement.clientHeight
  const scrollTop = document.documentElement.scrollTop
  const scrollHeight = document.documentElement.scrollHeight
  // 滚动到底部,自动加载更多
  if (scrollTop + clientHeight >= scrollHeight) {
    homeStore.fetchHouselistData()
  }
}
onMounted(()=>{
  window.addEventListener("scroll", scrollListenerHandler)
})

onUnmounted(() => {
  window.removeEventListener("scroll", scrollListenerHandler)
})

13.5 监听滚动的hooks函数抽取的两种方法

方法一:传入回调

传入多个回调实现不同的功能

  • hooks/useScroll
import { onMounted, onUnmounted } from "vue"

export default function useScroll(reachBottomCB) {
  const scrollListenerHandler = () => {
    const clientHeight = document.documentElement.clientHeight
    const scrollTop = document.documentElement.scrollTop
    const scrollHeight = document.documentElement.scrollHeight
    // 滚动到底部,自动加载更多
    if (scrollTop + clientHeight >= scrollHeight) {
      // 滚动到底部,执行回调,首页重新发送网络请求
      reachBottomCB && reachBottomCB()
    }
  }
  onMounted(() => {
    window.addEventListener("scroll", scrollListenerHandler)
  })

  onUnmounted(() => {
    window.removeEventListener("scroll", scrollListenerHandler)
  })
}
  • 首页使用
import useScroll from '@/hooks/useScroll'

// 监听window窗口的滚动,加载更多
useScroll(()=>{
 homeStore.fetchHouselistData()
})

方法二:设置变量

变量方便管理

  • onScroll
import { onMounted, onUnmounted, ref } from "vue"

export default function useScroll() {
  const isReachBottom = ref(false)

  const scrollListenerHandler = () => {
    const clientHeight = document.documentElement.clientHeight
    const scrollTop = document.documentElement.scrollTop
    const scrollHeight = document.documentElement.scrollHeight
    // 滚动到底部,自动加载更多
    if (scrollTop + clientHeight >= scrollHeight) {
      // 滚动到底部
      isReachBottom.value = true
    }
  }
  onMounted(() => {
    window.addEventListener("scroll", scrollListenerHandler)
  })

  onUnmounted(() => {
    window.removeEventListener("scroll", scrollListenerHandler)
  })

  return { isReachBottom }
}
  • home使用hooks
const { isReachBottom } = useScroll()
watch(isReachBottom, (newValue) => {
  homeStore.fetchHouselistData().then(() => {
    isReachBottom.value = false
  })
})

13.6 监听显示搜索框computed和onscroll节流优化

const { isReachBottom, scrollTop } = useScroll()

// 搜索框显示的控制
// const isShowSearchBar = ref(false)
// watch(scrollTop, (newTop) => {
//   isShowSearchBar.value = newTop > 100 ? true : false
// })

// 优化:定义的可响应式数据依赖另一个可响应式数据,可以使用计算属性
const isShowSearchBar = computed(() => {
  return scrollTop.value >= 100
})

<h2 v-if="isShowSearchBar">我是搜索框</h2>
import { onMounted, onUnmounted, ref } from "vue"
import { throttle } from 'underscore'

export default function useScroll() {
  const isReachBottom = ref(false)
  const scrollTop = ref(0)
  const scrollHeight = ref(0)
  const clientHeight = ref(0)

  // 节流
  const scrollListenerHandler = throttle(() => {
    clientHeight.value = document.documentElement.clientHeight
    scrollTop.value = document.documentElement.scrollTop
    scrollHeight.value = document.documentElement.scrollHeight

    // 滚动到底部,自动加载更多
    // console.log("监听到滚动");
    if (Math.ceil(scrollTop.value + clientHeight.value) >= scrollHeight.value) {
      // 滚动到底部
      console.log("滚动到底部了");
      isReachBottom.value = true
    }
  }, 100)

  onMounted(() => {
    window.addEventListener("scroll", scrollListenerHandler)
  })

  onUnmounted(() => {
    window.removeEventListener("scroll", scrollListenerHandler)
  })

  return { isReachBottom, scrollTop, scrollHeight, clientHeight }
}

13.7 mainStore 共享数据

stores/main

import { defineStore } from "pinia";

const startDate = new Date()
const endDate = new Date()
endDate.setDate(startDate.getDate() + 1)

const useMain = defineStore("main", {
  state: () => ({
    token: "",
    startDate: startDate,
    endDate: endDate
  }),
  actions: {
    
  }
})

export default useMain

home-search-box中改变时间的计算与获取

<!--日期范围  -->
<div class="item date-range bottom-gray-line" @click="showCalendar = true">
  <div class="start">
    <div class="date">
      <span class="tip">入住</span>
      <span class="time">{{ startDateStr }}</span>
    </div>
  </div>
  <div class="stay">共{{ stayCount }}晚</div>
  <div class="end">
    <div class="date">
      <span class="tip">离店</span>
      <span class="time">{{ endDateStr }}</span>
    </div>
  </div>
</div>

<script>
// 日期范围的处理
const mainStore = useMain()
const { startDate, endDate} = storeToRefs(mainStore)
computed(() => formatMonthDay(startDate))
const startDateStr = computed(() => formatMonthDay(startDate.value))
const endDateStr = computed(() => formatMonthDay(endDate.value))

// 天数处理
const stayCount = ref(getDiffDays(startDate.value, endDate.value))

// 日历显示隐藏
const showCalendar = ref(false)
const onConfirm = (value) => {
  // 1.设置日期
  const selectStartData = value[0]
  const selectEndData = value[1]
  mainStore.startDate = selectStartData
  mainStore.endDate = selectEndData
  stayCount.value = getDiffDays( selectStartData, selectEndData)

  // 2.隐藏日历
  showCalendar.value = false
}
</script>

13.8 搜索框的展示

image.png

  • search-bar组件 开始结束时间同步
<script setup>
import useMain from '@/stores/modules/main';
import { storeToRefs } from 'pinia';
import { formatMonthDay } from '@/utils/format_data'
import { computed } from 'vue';

const mainStore = useMain()
const { startDate, endDate} = storeToRefs(mainStore)
const startDateStr = computed(() => formatMonthDay(startDate.value, "MM.DD"))
const endDateStr = computed(() => formatMonthDay(endDate.value, "MM.DD"))
</script>

<template>
  <div class="search">
    <div class="select-time">
      <div class="start">
        <div class="name"></div>
        <div class="date">{{ startDateStr }}</div>
      </div>
      <div class="end">
        <div class="name"></div>
        <div class="date">{{ endDateStr }}</div>
        <i class="icon-search-arrow"></i>
      </div>
    </div>
    <div class="search">
      <div class="keyword">关键字/位置/民宿名</div>
      <i class="icon-search"></i>
    </div>
  </div>
</template>

<style lang="less" scoped>

.search {
  display: flex;
  height: 45px;
  border-radius: 6px;
  background-color: #f2f4f6;
  color: #999;

  .select-time {
    display: flex;
    flex-direction: column;
    border-right: 1px solid #fff;
    align-items: flex-start;
    justify-content: center;
    font-size: 10px;
    padding: 5px 0 5px 10px;

    .start, .end {
      display: flex;
      margin: 0 2px; 
      font-weight: 500;
      justify-content: center;
      align-items: flex-end;

      .date {
        margin: 0 2px;
        color: #333;
      }
    }
    .end {
      .icon-search-arrow {
        position: relative;
        top: -2px;
        display: inline-block;
        width: 5px;
        height: 5px;
        background-image: url(@/assets/img/home/home-sprite.png);
        background-size: 207px 192px;
        background-position: -201px -157px;
      }
    }
  }

  .search {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 0 10px;
    flex: 1;
    font-size: 14px;

    .keyword {
      font-size: 14px;
      font-weight: 500;
    }

    .icon-search {
      display: inline-block;
      width: 24px;
      height: 24px;
      background-image: url(@/assets/img/home/home-sprite.png);
      background-size: 207px 192px;
      background-position: -29px -151px;
    }
  }
}
</style>
  • home首页使用search-bar组件
<script setup>
import { watch, ref, computed } from 'vue';
import useHome from '@/stores/modules/home';
import HomeNavBar from './components/home-nav-bar.vue'
import HomeSearchBox from './components/home-search-box.vue';
import HomeCategories from './components/home-categories.vue';
import HomeContent from './components/home-content.vue';
import useScroll from '@/hooks/useScroll'
import SearchBar from '@/components/search-bar/index.vue'

// 发送网络请求
const homeStore = useHome()
homeStore.fetchHotSuggestsData()
homeStore.fetchCategoriesData()
homeStore.fetchHouselistData()

// 监听window窗口的滚动,加载更多
const { isReachBottom, scrollTop } = useScroll()
watch(isReachBottom, (newValue) => {
  homeStore.fetchHouselistData().then(() => {
    isReachBottom.value = false
  })
})

// 搜索框显示的控制
// 定义的可响应式数据依赖另一个可响应式数据,可以使用计算属性
const isShowSearchBar = computed(() => {
  return scrollTop.value >= 350
})

</script>

<template>
  <div class="home">
    <home-nav-bar></home-nav-bar>

    <div class="banner">
      <img src="@/assets/img/home/banner.webp" alt="">
    </div>

    <home-search-box></home-search-box>

    <home-categories></home-categories>

    <h2 class="search-bar" v-if="isShowSearchBar">
      <search-bar></search-bar>
    </h2>

    <home-content></home-content>
  </div>
</template>

<style lang="less" scoped>
.home {
  padding-bottom: 60px;
}

.banner {
  img {
    width: 100%;
  }
}

.search-bar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 45px;
  padding: 16px 16px 10px;
  z-index: 9;
  background-color: #fff;
  box-shadow: 0 2px 30px 0 rgb(5 17 40 / 15%);
}
</style>

十四、loading组件的界面封装

14.1 loading的展示

image.png

  • loading
<script setup>

</script>

<template>
  <div class="loading">
    <div class="bg">
      <img src="@/assets/img/home/full-screen-loading.gif" alt="">
    </div>
  </div>
</template>

<style lang="less" scoped>
.loading {
  position: fixed;
  z-index: 999;
  left: 0;
  right: 0;
  top: 0;
  bottom: 0;
  display: flex;
  justify-content: center;
  align-items: center;

  background-color: rgba(0, 0, 0, .2);

  .bg{
    display: flex;
    justify-content: center;
    align-items: center;
    width: 104px;
    height: 104px;
    background: url(@/assets/img/home/loading-bg.png) 0 0 / 100% 100%;
  }

  img {
    width: 70px;
    height: 70px;
    margin-bottom: 10px;
  }
}
</style>
  • App中使用组件
import Loading from '@/components/loading/loading.vue'

<loading></loading>

14.2 loading的状态控制

  • main.js store设置状态isLoading: false
  • 动态控制显示隐藏
<script setup>
import useMain from '@/stores/modules/main';
import { storeToRefs } from 'pinia';

const mainStore = useMain()
const { isLoading } = storeToRefs(mainStore)

// 点击蒙版隐藏loading
const loadingClick = () => {
  mainStore.isLoading = false
}
</script>

<template>
  <div 
    class="loading" 
    v-if="isLoading"
    @click="loadingClick"
  >
    <div class="bg">
      <img src="@/assets/img/home/full-screen-loading.gif" alt="">
    </div>
  </div>
</template>

<style lang="less" scoped>
.loading {
  position: fixed;
  z-index: 999;
  left: 0;
  right: 0;
  top: 0;
  bottom: 0;
  display: flex;
  justify-content: center;
  align-items: center;

  background-color: rgba(0, 0, 0, .2);

  .bg{
    display: flex;
    justify-content: center;
    align-items: center;
    width: 104px;
    height: 104px;
    background: url(@/assets/img/home/loading-bg.png) 0 0 / 100% 100%;
  }

  img {
    width: 70px;
    height: 70px;
    margin-bottom: 10px;
  }
}
</style>
  • 每个请求都要用loading,所以写在封装的axios中
import useMain from '@/stores/modules/main'

const mainStore = useMain()
// 这样写虽然可以实现,但是写在拦截器中更合理
equest(config) {
    mainStore.isLoading = true
    return new Promise((resolve, reject) => {
      this.instance.request(config).then(res => {
        resolve(res.data)
        mainStore.isLoading = false
      }).catch(err => {
        reject(err)
        mainStore.isLoading = false
      })
    })
  }
import axios from 'axios'
import { BASE_URL, TIMEOUT } from './config'
import useMain from '@/stores/modules/main'

const mainStore = useMain()

class HYRequest {
  constructor(baseURL, timeout = 1000) {
    this.instance = axios.create({
      baseURL,
      timeout
    })

    this.instance.interceptors.request.use((config)=>{
      mainStore.isLoading = true
      return config
    }, err => {
      return err
    })

    this.instance.interceptors.response.use(res => {
      mainStore.isLoading = false
      return res
    }, err => {
      mainStore.isLoading = false
      return err
    })
  }

  request(config) {
    // mainStore.isLoading = true
    return new Promise((resolve, reject) => {
      this.instance.request(config).then(res => {
        resolve(res.data)
        // mainStore.isLoading = false
      }).catch(err => {
        reject(err)
        // mainStore.isLoading = false
      })
    })
  }

  get(config) {
    return this.request({ ...config, methods: "get" })
  }
  post(config) {
    return this.request({ ...config, methods: "post" })
  }
}

// const instance1 = new HYRequest("http://www.httpbin.org")
// const instance2 = new HYRequest("http://www.httpbins.org")

export default new HYRequest(BASE_URL, TIMEOUT)

十五、点击item跳转到详情页

image.png

  • 配置路由表
{
  path: "/detail/:id",
  component: () => import("@/views/detail/detail.vue")
},
  • 首页点击item跳转
<script setup>
import useHome from '@/stores/modules/home';
import { storeToRefs } from 'pinia';
import HouseItemV9 from "@/components/house-item-v9/index.vue"
import HouseItemV3 from "@/components/house-item-v3/index.vue"
import { useRouter } from 'vue-router';

const homeStore = useHome()
const { houselist } = storeToRefs(homeStore)

// 1.监听item点击,在组件绑定事件和属性默认绑定在子组件根元素上
const router = useRouter()
const itemClick = (item) => {
  // 2.跳转到detail页面 
  router.push("/detail/" + item.houseId)
}
</script>

<template>
  <div class="content">
    <div class="title">
      <span class="title-tab">热门精选</span>
    </div>
    <div class="list">
      <template v-for="(item, index) in houselist" :key="item.data.houseId">
        <house-item-v9 
          v-if="item.discoveryContentType === 9" 
          :item-data="item.data"
          @click="itemClick(item.data)"
        />
        <house-item-v3 
          v-else-if="item.discoveryContentType === 3" 
          :item-data="item.data"
          @click="itemClick(item.data)"
        />
      </template>
    </div>
  </div>
</template>

<style lang="less" scoped>
.content {
  display: flex;
  flex-direction: column;
  padding: 10px 16px;
  background-color: #fff;
  margin-top: 10px;
  border-radius: 10px 10px 0 0;

  .title {
    display: flex;
    align-items: center;
    height: 48px;
    font-size: 18px;
    color: #333;
    line-height: 22px;

    .title-tab {
      padding: 10px 0;
      background: url('@/assets/img/home/header_bg.png') no-repeat ;
      background-size: 50% 50%;
      background-position: center 6px;
    }
  }

  .list {
    display: flex;
    flex-wrap: wrap;
  }
}
</style>
  • detail页面
<template>
  <div class="detail top-page">
    <h2>id:{{ $route.params.id }}</h2>
  </div>
</template>

十六、详情页开发

16.1 导航栏搭建和返回功能

image.png

<script setup>
import { useRouter } from 'vue-router';

const router = useRouter()

// 监听返回按钮的点击
const onClickLeft = () => {
  router.back()
}

</script>

<template>
  <div class="detail top-page">
    <van-nav-bar
      title="房屋详情"
      left-text="旅途"
      left-arrow
      @click-left="onClickLeft"
    />
  </div>
</template>

<style lang="less" scoped>
</style>
/* 详情标题颜色 */
全局common.css
--van-primary-color: var(--primary-color)

16.2 发送网络请求详情页数据

  • services/detail
import hyRequest from '../request'

export function getDetailInfos(houseId){
  return hyRequest.get({
    url: "/detail/infos",
    params: {
      houseId
    }
  })
}
  • 详情页保存数据
import { useRouter, useRoute } from 'vue-router';
import { getDetailInfos } from '@/services'
import { ref, computed } from 'vue';

const router = useRouter()
const route = useRoute()

// 监听返回按钮的点击
const onClickLeft = () => {
  router.back()
}

// 发送网络请求
const houseId = route.params.id
const detailInfos = ref({})
const mainPart = computed(() => detailInfos.value.mainPart)

getDetailInfos(houseId).then( res => {
  detailInfos.value = res.data
})

16.3 轮播图组件

image.png

  • 详情页使用轮播图组件
import DetailSwipe from './components/detail_01-swipe.vue'

<!-- 轮播图 -->
<div class="main" v-if="mainPart">
  <detail-swipe :swipe-data="mainPart.topModule.housePicture.housePics"></detail-swipe>
</div>
  • detail-swipe轮播图组件
<script setup>

const props = defineProps({
  swipeData: {
    type: Array,
    default: () => ([])
  }
})

// 对数据进行转换
/* 
// 思路一:两次循环
const swipeGroup = {}
for(const item of props.swipeData){
  swipeGroup[item.enumPictureCategory] = []
}
for(const item of props.swipeData){
  const valueArray = swipeGroup[item.enumPictureCategory]
  valueArray.push(item)
}
*/
// 思路二:一次循环
const swipeGroup = {}
for (const item of props.swipeData) {
  let valueArray = swipeGroup[item.enumPictureCategory]
  if (!valueArray) {
    valueArray = []
    swipeGroup[item.enumPictureCategory] = valueArray
  }

  valueArray.push(item)
}

// 标题转换方法
// const getName = (name) => {
//   return name.replace(":", "").replace("【", "").replace("】", "")
// }
const getName = (name) => {
  return name.replace(/\【(.*?)\】\:/g, (node, key) => {
    return key
  })
}

const getCategoryIndex = (item) => {
  const valueArray = swipeGroup[item.enumPictureCategory]
  return valueArray.findIndex(data=> data === item) + 1
}


</script>

<template>
  <div class="swipe">
    <van-swipe class="swipe-list" :autoplay="3000" indicator-color="white">
      <template v-for="(item, index) in swipeData">
        <van-swipe-item class="item">
          <img :src="item.url" alt="">
        </van-swipe-item>
      </template>
      <template #indicator="{ active, total }">
        <div class="indicator">
          <template v-for="(value, key, index) in swipeGroup" :key="key">
            <div class="item" 
              :class="{ active: swipeData[active]?.enumPictureCategory == key }"
            >
              <div class="title">
                {{ getName(value[0].title) }}
              </div>         
              <div class="item-index" v-if="swipeData[active]?.enumPictureCategory == key">
                {{ getCategoryIndex(swipeData[active]) }}/{{ value.length }}
              </div>
            </div>
          </template>
        </div>
      </template>
    </van-swipe>
  </div>
</template>

<style lang="less" scoped>
.swipe {
  .swipe-list {
    .item {
      img {
        width: 100%;
      }
    }

    .indicator {
      position: absolute;
      right: 5px;
      bottom: 5px;
      display: flex;
      padding: 3px 4px;
      font-size: 10px;
      background: rgba(0, 0, 0, 0.5);
      border-radius: 11px;
      line-height: 12px;
      color: #fff;

      .item {
        display: flex;
        margin: 0 3px;

        &.active {
          color: #333;
          background-color: #fff;
          border-radius: 9px;
          padding: 1px 4px;
          font-size: 10px;
        }
      }
    }
  }
}
</style>

16.4 顶部房屋信息详情infos

image.png

  • detail-infos组件
<script setup>

defineProps({
  topInfos: {
    type: Object,
    default: () => ({})
  }
})

</script>

<template>
  <div class="infos">
    <div class="name">{{ topInfos.houseName }}</div>
    <div class="tags">
      <template v-for="(item, index) in topInfos.houseTags" :key="index">
        <span class="item" 
          v-if="item.tagText"
          :style="{ 
            color: item.tagText.color,
            background: item.tagText.background.color
          }"
        >
          {{ item.tagText.text }}
        </span>
      </template>
    </div>
    <div class="comment">
      <div class="left">
        <span class="score">{{ topInfos.commentBrief?.overall }}</span>
        <span class="title">{{ topInfos.commentBrief?.scoreTitle }}</span>
        <span class="brief">{{ topInfos.commentBrief?.commentBrief }}</span>
      </div>
      <div class="right">
        <span class="count">
          {{ topInfos.commentBrief?.totalCount }}条评论
          <van-icon name="arrow" />
        </span>
      </div>
    </div>
    <div class="location">
      <div class="left">
        {{ topInfos.nearByPosition?.address }}
      </div>
      <div class="right">
        地图·周边
        <van-icon name="arrow" />
      </div>
    </div>
  </div> 
</template>

<style lang="less" scoped>
.infos {
  padding: 12px 16px;
  overflow: hidden;
  background-color: #fff;

  .name {
    font-weight: 700;
    font-size: 20px;
    color: #333;
    text-align: justify;
    line-height: 24px;
    overflow: hidden;
    margin-bottom: 6px;
  }

  .tags {
    display: flex;
    flex-wrap: wrap;
    max-height: 40px;
    overflow: hidden;
    margin-bottom: 12px;

    .item {
      padding: 2px 4px;
      margin: 4px 4px 0 0;
      -webkit-border-radius: 2px;
      border-radius: 2px;
      font-size: 10px;
      color: rgb(255, 150, 69);
      background: rgba(255, 150, 69, 0.15);
    }
  }

  .comment {
    display: flex;
    height: 30px;
    align-items: center;
    justify-content: space-between;
    padding: 0 12px;
    margin-bottom: 8px;
    background-color: #f5f7fa;

    .left {
      display: flex;
      align-items: center;
      .score {
        color: #333;
        z-index: 10;
        font-weight: 700;
        font-size: 18px;
        margin-right: 2px;
      }

      .title {
        position: relative;
        top: -1px;
        margin-right: 4px;
        font-size: 12px;
        color: #333;
        font-weight: 700;
      }

      .brief {
        font-size: 12px;
        color: #666;
        line-height: 14px;
      }
    }

    .right {
      margin-left: 4px;
      font-size: 10px;
      color: #ff9645
    }
  }

  .location {
    display: flex;
    height: 30px;
    align-items: center;
    justify-content: space-between;
    padding: 0 12px;
    margin-bottom: 8px;
    background-color: #f5f7fa;

    .left {
      font-weight: 700;
      font-size: 14px;
      color: #333;
      line-height: 16px;
      -o-text-overflow: ellipsis;
      text-overflow: ellipsis;
      overflow: hidden;
      white-space: nowrap;
    }

    .right {
      margin-left: 4px;
      font-size: 10px;
      color: #ff9645
    }
  }
}
</style>
  • detail页面使用组件
import DetailInfos from './components/detail_02-infos.vue'

<detail-infos :top-infos="mainPart?.topModule"></detail-infos>

16.5 详情页公共区域detail-section组件

传递信息和插槽即可使用

<script setup>

defineProps({
  title: {
    type: String,
    default: "默认标题"
  },
  more: {
    type: String,
    //default: "默认更多" 为了判断是否加footer
    default: ""
  }
})
</script>

<template>
  <div class="section">
    <div class="header bottom-gray-line">
      <h2 class="title">{{ title }}</h2>
    </div>
    <div class="content">
      <slot class="slot-content">
        <h3>我是默认内容</h3>
      </slot>
    </div>
    <div class="footer" v-if="more">
      <span class="more">{{ more }}</span>
      <van-icon name="arrow" />
    </div>
  </div>
</template>

<style lang="less" scoped>
.section {
  background-color: #fff;
  border-top: 10px solid #f2f3f4;

  .header {
    padding: 14px 16px;
    font-size: 20px;
    color: #333;
    line-height: 22px;
    font-weight: 600;

    .title {
      font-size: 20px;
      color: #333;
    }
  }

  .content {
    padding: 15px 16px 0 16px;
  }

  .footer {
    display: flex;
    justify-content: flex-end;
    align-items: center;
    padding: 15px 16px;
    font-size: 14px;
    color: #ff9645;
    font-weight: 600;
  }
}
</style>

16.6 房屋设施使用detail-section组件

image.png

  • detail-facility组件使用公共组件
<script setup>
import DetailSection from '@/components/detail-section/detail-section.vue'
</script>

<template>
  <div class="facility">
    <detail-section title="房屋设施" more="查看全部设施">
      <button>哈哈哈</button>
    </detail-section>
  </div>
</template>
  • detail页面使用detail-facility组件
import DetailFacility from './components/detail_03-facility.vue'

<!-- 房屋设施 -->
<detail-facility></detail-facility>

detail-facility房屋设施功能完善

image.png

  • detail使用组件并产地数据给子组件
<!-- 房屋设施 -->
<detail-facility :house-facility="mainPart?.dynamicModule?.facilityModule?.houseFacility"></detail-facility>
  • detail-facility组件使用公共组件,完善插槽
<script setup>
import DetailSection from '@/components/detail-section/detail-section.vue'

defineProps({
  houseFacility: {
    type: Object,
    defalult: () => ({})
  }
})
</script>

<template>
  <div class="facility">
    <detail-section title="房屋设施" more="查看全部设施">
      <template v-for="(item, index) in houseFacility?.houseFacilitys" :key="index">
        <div class="item" v-if="houseFacility?.facilitySort?.includes(index)">
          <div class="header">
            <img :src="item.icon" alt="">
            <div class="text">{{ item.groupName }}</div>
          </div>
          <div class="list">
            <template v-for="(iten, index) in item.facilitys.slice(0, 4)" :key="index">
              <div class="iten">
                <span class="icon">
                  <van-icon name="passed" />
                </span>
                <span class="text">{{ iten.name }}</span>
              </div>
            </template>
          </div>
        </div>
      </template>
    </detail-section>
  </div>
</template>

<style lang="less" scoped>
.facility {
  margin-top: -6px;
  padding: 5px 0;
  font-size: 12px;
  border-radius: 6px;

  .item {
    display: flex;
    padding: 10px 0;
    background-color: #f7f9fb;

    .header {
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      width: 90px;


      img {
        width: 20px;
        height: 20px;
      }

      .text {
        margin-top: 5px;
        color: #000;
      }
    }

    .list {
      display: flex;
      flex-wrap: wrap;
      flex: 1;
      align-items: center;

      .iten {
        display: flex;
        align-items: center;
        width: 50%;
        margin: 4px 0;

        .icon {
          width: 12px;
          height: 12px;
          margin-right: 3px;
          color: #17d2bc;
        }

        .text {
          color: #333;
        }
      }
    }
  }
}
</style>

16.7 房东介绍

image.png

  • detail页面使用detail-landlord组件
import DetailLandlord from './components/detail_04-landlord.vue'

<!-- 房东介绍 -->
<detail-landlord :landlord="mainPart?.dynamicModule?.landlordModule"></detail-landlord>
  • detail-landlord组件
<script setup>
import DetailSection from '@/components/detail-section/detail-section.vue'

const props = defineProps({
  landlord: {
    type: Object,
    default: () => ({})
  }
})

const getTip = (item) => {
  const res = item.replace(/\d+/, ",").split(",")
  return res
}

</script>

<template>
  <div class="landlord">
    <detail-section title="房东介绍" more="查看房东主页">
      <div class="intro">
        <div class="top">
          <img :src="landlord.topScroll" alt="">
        </div>
        <div class="header">
          <div class="left">
            <div class="hotelLogo">
              <img :src="landlord.hotelLogo" alt="">
            </div>
            <div class="hotelInfos">
              <div class="hotelName">{{ landlord.hotelName }}</div>
              <div class="hotelTags">
                <template v-for="(item, index) in landlord.hotelTags">
                  <div class="item" :style="{ color: item.tagText.color }">{{ item.tagText.text }}</div>
                </template>
              </div>
            </div>
          </div>
          <div class="right">
            <div class="contact">联系房东</div>
          </div>
        </div>
        <div class="summary">
          <template v-for="(iten, index) in landlord.hotelSummary" :key="index">
            <div class="item">
              <div class="title">{{ iten.title }}</div>
              <div class="introduction">{{ iten.introduction }}</div>
              <div class="tip">
                <template v-if="iten.highLight">
                  <div class="prev">{{ getTip(iten.tip)[0] }}</div>
                  <div class="highLight">{{ iten.highLight }}</div>
                  <div class="next">{{ getTip(iten.tip)[1] }}</div>
                </template>
                <template v-else>
                  <div class="one">{{ iten.tip }}</div>
                </template>
              </div>
            </div>
          </template>
        </div>
      </div>
    </detail-section>
  </div>
</template>

<style lang="less" scoped>
.landlord {
  background: #fff;

  .intro {
    display: flex;
    flex-direction: column;

    .top {
      img {
        width: 100%;
        border-radius: 6px;
      }
    }

    .header {
      display: flex;
      justify-content: space-between;
      padding: 16px 0;

      .left {
        display: flex;

        .hotelLogo {
          width: 54px;
          height: 54px;
          border-radius: 50%;

          img {
            width: 100%;
          }
        }

        .hotelInfos {
          display: flex;
          flex-direction: column;
          margin: 0 8px 0 12px;

          .hotelName {
            overflow: hidden;
            font-size: 16px;
            color: #333;
            font-weight: 600;
          }

          .hotelTags {
            display: flex;
            font-size: 10px;
            margin-top: 5px;
            white-space: nowrap;
            text-overflow: ellipsis;
            overflow: hidden;

            .item {
              color: rgb(155, 155, 155);

              &:not(:last-child)::after {
                display: inline-block;
                content: "|";
                height: 12px;
                margin: 0 4px;
                color: #9b9b9b;
              }
            }
          }
        }
      }

      .right {
        height: 24px;
        padding-left: 12px;
        padding-right: 12px;
        line-height: 24px;
        background: var(--theme-linear-gradient);
        border-radius: 4px;
        color: #fff;
        text-align: center;
        font-size: 12px;
        margin-top: 2px;
      }
    }

    .summary {
      display: flex;
      padding: 22px 0 12px 0;

      .item {
        display: flex;
        flex-direction: column;
        flex: 1;

        .title {
          font-size: 10px;
          color: #999;
        }

        .introduction {
          margin: 4px 0 2px;
          font-size: 18px;
          color: #333;
          font-weight: 600;
        }

        .tip {
          display: flex;
          font-size: 11px;
          color: #666;

          .highLight {
            margin: 0 2px;
            color: var(--primary-color);
          }
        }
      }
    }
  }
}
</style>

16.8 房客点评

image.png

  • detail使用并传递数据
import DetailComment from './components/detail_05-comment.vue'

<!-- 房客点评 -->
<detail-comment :comment="mainPart?.dynamicModule?.commentModule"></detail-comment>
  • detail-comment组件
<script setup>
import detailSection from '@/components/detail-section/detail-section.vue';

defineProps({
  comment: {
    type: Object,
    default: () => ({})
  }
})
</script>

<template>
  <div class="comment">
    <detail-section title="房客点评" :more="`查看全部${comment.totalCount}条评论`">
      <div class="commentInner">
        <div class="header">
          <div class="left">
            <div class="overall">{{ Number(comment.overall).toFixed(1) }}</div>
            <div class="overallBottom"></div>
            <div class="all">
              <div class="title">{{ comment.scoreTitle }}</div>
              <div class="count">{{ comment.totalCount }}条评论</div>
              <template v-if="comment.overall">
                <van-rate v-model="comment.overall" color="#ff9645" size="12" readonly allow-half />
              </template>
            </div>
          </div>
          <div class="right">
            <template v-for="(item, index) in comment.subScoresFocus" :key="index">
              <div class="rightItem">
                <div class="text">{{ item.text.split(" ")[0] }}</div>
                <div class="score">{{ item.focusText }}</div>
              </div>
            </template>
          </div>
        </div>
        <div class="tags">
          <template v-for="(item, index) in comment.commentTagVo" :key="index">
            <div class="item" :style="{ backgroundColor: item.backgroundColor, color: item.color }">{{ item.text }}</div>
          </template>
        </div>

        <div class="userComment">
          <div class="header">
            <div class="left">
              <div class="avatars">
                <img :src="comment.comment?.userAvatars" alt="">
              </div>
              <div class="info">
                <div class="name">{{ comment.comment?.userName }}</div>
                <div class="infoItem">
                  <div class="date">{{ comment.comment?.checkInDate }}入住</div>
                  <div class="location">{{ comment.comment?.location }}</div>
                </div>
              </div>
            </div>
            <div class="right">
              <div class="overall">{{ Number(comment.comment.overall).toFixed(1) }}</div>
              <van-rate v-model="comment.comment.overall" color="#ff9645" size="12" readonly allow-half />
            </div>
          </div>
          <div class="commentDetail">{{ comment.comment?.commentDetail }}</div>
        </div>
      </div>
    </detail-section>
  </div>
</template>

<style lang="less" scoped>
.commentInner {
  font-size: 10px;
  color: #999;

  .header {
    display: flex;
    padding: 16px 0 13px;
    justify-content: space-between;
    align-items: center;

    .left {
      position: relative;
      display: flex;
      margin-right: 4px;

      .overall {
        position: relative;
        font-size: 48px;
        font-weight: 700;
        color: #333;
        z-index: 9;
      }

      .overallBottom {
        width: 66px;
        height: 6px;
        background: var(--theme-linear-gradient);
        border-radius: 3px;
        position: absolute;
        bottom: 10px;
        z-index: 5;
      }

      .all {
        display: flex;
        flex-direction: column;
        margin-left: 10px;
        justify-content: space-between;

        .count {
          margin-top: -1px;
        }
      }
    }

    .right {
      display: flex;
      flex-wrap: wrap;
      align-items: center;
      justify-content: right;
      max-height: 49px;
      max-width: 150px;
      text-align: right;
      overflow: hidden;
      flex: 1;

      .rightItem {
        display: flex;
        align-items: center;
        margin: 2px 4px 0 0;

        .text {
          padding-right: 4px;
        }

        .score {
          font-size: 11px;
          color: #333;
          font-weight: 600;
        }
      }
    }
  }

  .tags {
    display: flex;
    flex-wrap: wrap;
    margin: 3px 0 0;
    max-height: 62px;
    overflow: hidden;

    .item {
      height: 18px;
      border-radius: 12px;
      font-size: 11px;
      padding: 4px 8px;
      margin: 0 4px 4px 0;
    }
  }

  .userComment {
    padding: 12px;
    margin-top: 8px;
    background: #f7f9fb;
    border-radius: 6px;

    .header {
      display: flex;
      justify-content: space-between;

      .left {
        display: flex;

        .avatars {
          width: 32px;
          height: 32px;
          margin-right: 4px;
          background: #f7f9fb;

          img {
            width: 100%;
            border-radius: 16px;
          }
        }

        .info {

          .name {
            color: #333;
            font-weight: 600;
            margin-bottom: 3px;
          }

          .infoItem {
            display: flex;

            .date {
              margin-right: 5px;
            }
          }
        }
      }

      .right {
        display: flex;
        flex-direction: column;
        justify-content: center;
        align-items: end;
        text-align: right;

        .overall {
          font-size: 18px;
          color: #333;
          font-weight: 600;
        }
      }
    }

    .commentDetail {
      font-size: 12px;
      color: #333;
      line-height: 16px;
      overflow: hidden;
    }
  }
}
</style>

16.9 预定须知

image.png

  • detail使用
import DetailNotice from './components/detail_06-notice.vue'

<!-- 预定须知 -->
<detail-notice :notice="mainPart?.dynamicModule?.rulesModule"></detail-notice>
  • detail-notice组件
<script setup>
import detailSection from '@/components/detail-section/detail-section.vue';

defineProps({
  notice: {
    type: Object,
    default: () => ({})
  }
})
</script>

<template>
  <div class="notice">
    <detail-section title="预定须知">
      <div class="header">
        <template v-for="(item, index) in notice.orderRules" :key="index">
          <div class="item">
            <div class="title">{{ item.title }}</div>
            <template v-if="item.introduction">
              <div class="intro">{{ item.introduction }}</div>
            </template>
            <template v-else-if="item.icon">
              <div class="intro">{{ item.icon }}</div>
            </template>
          </div>
        </template>
      </div>


      <div class="steps">
        <van-steps direction="vertical">
          <template v-for="(item, index) in notice.cancelRules" :key="index">
            <van-step>
              <div class="intro">{{ item.introduction }}</div>
              <div class="tip" :style="{ backgroundColor: item.backColor, color: item.tipColor }">{{ item.tip }}</div>
            </van-step>
          </template>
        </van-steps>
      </div>


      <template v-for="(item, index) in notice.checkInRules" :key="index">
        <div class="footer">
          <div class="title">{{ item.title }}</div>
          <div class="info">
            <div class="ruleInfo">
              <template v-for="(iten, index) in item.items">
                <div class="infoItem">
                  <div class="icon">
                    <van-icon name="close" v-if="iten.isDeleted" color="#f66" />
                    <van-icon name="passed" color="#17d2bc" v-else />
                  </div>
                  <div class="intro">{{ iten.introduction }}</div>
                </div>
              </template>
            </div>
            <div class="otherInfo">
              <div class="otherIntro">{{ notice.checkinOtherInfo[0].items[0].introduction }}</div>
            </div>
          </div>
        </div>
        <div class="rule">
          <div class="ruleItem">
            <van-icon name="info-o" />
            遵守入住规则有助于获得更愉快的入住体验
          </div>
        </div>
      </template>

    </detail-section>
  </div>
</template>

<style lang="less" scoped>
.notice {
  padding: 16px;
  background-color: #fff;
  font-size: 12px;

  .item {
    display: flex;
    align-items: center;
    justify-content: flex-start;
    line-height: 15px;

    &:not(:first-child) {
      margin-top: 20px;
    }

    .title {
      width: 64px;
      color: #666;
    }

    .intro {
      display: flex;
      align-items: flex-start;
      justify-content: flex-start;
      flex: 1;
      color: #333;

    }
  }

  .steps {
    display: flex;
    flex-direction: column;
    position: relative;
    padding-left: 52px;
    overflow: hidden;

    div {
      font-size: 12px;
    }

    .intro {
      color: #333;
    }

    .tip {
      display: inline-block;
      max-width: 100%;
      padding: 2px 4px;
      border-radius: 2px;
      line-height: 12px;
      overflow: hidden;
    }
  }

  .footer {
    display: flex;
    margin-top: 20px;

    .title {
      width: 83px;
      color: #666;
      line-height: 15px;
    }

    .info {
      display: flex;
      color: #333;
      flex-direction: column;
      margin-left: 15px;

      .ruleInfo {
        display: flex;
        flex-wrap: wrap;

        .infoItem {
          display: flex;
          width: 50%;
          margin-bottom: 8px;

          .icon {
            width: 12px;
            height: 12px;
            margin-right: 4px;
          }

          .intro {
            color: #333;
            overflow: hidden;
          }
        }
      }
    }
  }

  .rule {
    display: flex;
    align-items: center;
    justify-content: center;
    line-height: 24px;
    margin: 10px 0;

    color: #bfbfbf;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 9px;

    .ruleItem {
      padding: 2px 6px;
      border-radius: 6px;
      background-color: #f2f2f2;
    }
  }
}
</style>