一、初始化项目
1.1 创建项目
npm init vue@latest
1.2 修改配置
- 修改项目名称和图标
- 配置jsconfig.json
{
"compilerOptions": {
"target": "es5",
"module": "esnext",
"baseUrl": "./",
"moduleResolution": "node",
"paths": {
"@/*": [
"src/*"
]
},
"jsx": "preserve",
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
}
}
1.3 划分目录结构
1.4 CSS样式的重置
- 安装normalize.css
yarn add normalize.css
- 在assets文件对css中reset.css和common.css统一导出index.css
- 在main.js中引入
import "normalize.css"
import "./assets/css/index.css"
二、路由和状态配置
2.1 配置代码片段
用snippet generator动态生成JSON片段,然后配置在vscode中即可
- 在文件中输入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文件夹里面
三、开发tabbar
3.1 tabbar的基本搭建
<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的数据抽取
- 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的功能完善
- 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()],
}),
],
};
- 使用组件
<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和地址的展示
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页面跳转
<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
- 隐藏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 城市页面的搜索框开发
- 样式修改
: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开发
<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
<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管理城市数据网络请求
- 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的数据
<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 城市分组列表数据展示
- 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 热门展示、城市列表的索引动态映射和添加热门索引的展示
- 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 选择城市回退和回显效果
- 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 默认显示
- 页面
<!--日期范围 -->
<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 日期的动态显示及动态计算天数
- 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>
九、价格人数和关键字基本展示
- 公共样式
: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>
十、热门建议
- 子组件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()
十一、搜索功能搭建与点击跳转搜索页面
- 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 分类菜单的展示过程
- 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 首页内容标题展示
- 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数据请求与展示
- 请求封装
// 首页列表数据
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 不同类型的展示
- 此处搜索框还没有开发
- 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 搜索框的展示
- 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的展示
- 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跳转到详情页
- 配置路由表
{
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 导航栏搭建和返回功能
<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 轮播图组件
- 详情页使用轮播图组件
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
- 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组件
- 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房屋设施功能完善
- 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 房东介绍
- 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 房客点评
- 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 预定须知
- 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>