涉及技术
vue3+sass+typescript+pinia+uniapp+微信小程序基础
编写工具:Vscode、微信小程序开发
项目介绍
这是一个基于瑞幸咖啡点单小程序创建的一个仅用于自己学习uniapp技术的项目。此项目不涉及服务器、网络等知识,数据内容以及数据类型均由自己编写。
此项目共分为三大块(即三个tabbar页面):index页面、order页面、my页面。
项目制作
项目创建与配置基础
项目创建
- 命令行创建uni-app官网 (dcloud.net.cn)
- 选择vue3+ts
- 安装node包 npm i
创配置Vscode
- 安装uni-app插件:uni-create-view、uni-helper、uniapp小程序拓展
- 安装类型声明文件
- manifest.json配置weixin-appid
- 运行
npm run dev:mp-weixin
打包,生成dist文件夹dist/dev/mp-weixin
- 打开微信开发小程序,引入dist/dev/mp-weiixn,vscode更改自动更新微信小程序
项目基础配置
- 安装类型声明文件
pnpm i -D @types/wechat-miniprogram @uni-helper/uni-app-types
- 配置tsconfig.json
{ "extends": "@vue/tsconfig/tsconfig.json", "compilerOptions": { "sourceMap": true, "ignoreDeprecations": "5.0", "baseUrl": ".", "paths": { "@/*": [ "./src/*" ] }, "lib": [ "esnext", "dom" ], "types": [ "@dcloudio/types", // uni-app API 类型 "miniprogram-api-typings", // 原生微信小程序类型 "@uni-helper/uni-app-types" // uni-app 组件类型 ] }, // vue 编译器类型,校验标签类型 "vueCompilerOptions": { "nativeTags": [ "block", "component", "template", "slot" ], }, "include": [ "src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue" ] }
- 安装sass和sass-loader
- 安装uni-ui和配置easycom,在项目中使用uni前缀的标签时,会自动导入uni-ui中对应的标签。
//安装uni-ui cnpm i @dcloudio/uni-ui //pages.json { "easycom": { "autoscan": true, "custom": { // uni-ui 规则如下配置 "^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue" } }, }
项目基本框架
tabbar页面配置
在pages.json中配置所有页面
- pages中,配置所有页面包括tabbar页面和非tabbar页面,当前需要创建三个tabbar页面(index、order、my)
- 在src/pages/下创建页面文件夹,在此文件夹下创建对应页面vue文件(src/pages/index/index.vue)(src/pages/orders/orders.vue)...
- 创建好三个页面并在pages中配置好后,在tabBar中配置所有tabbar页面,tabbar页面数量区间【2-5】。只有配置了tabbar后中才能显示底部导航栏
{
"pages": [ //pages数组中第一项表示应用启动页
{
"path": "pages/index/index",//相对src下页面vue文件位置
"style": {
"navigationBarTitleText": "uni-app", //此页面顶部栏标题
"navigationStyle": "custom" //自定义此页面导航栏(顶部栏样式)
}
},
{
"path": "pages/my/my",
"style": {
"navigationBarTitleText": "my",
"navigationStyle": "custom"
}
},
{
"path": "pages/order/order",
"style": {
"navigationBarTitleText": "order",
"navigationStyle": "custom"
}
}
],
// 设置 TabBar
"tabBar": {
"color": "#333",
"selectedColor": "#27ba9b",
"backgroundColor": "#fff",
"borderStyle": "white",
"list": [
{
"text": "首页",
"pagePath": "pages/index/index",
"iconPath": "static/tabbarICON/home_default.png",
"selectedIconPath": "static/tabbarICON/home_selected.png"
},
{
"text": "点单",
"pagePath": "pages/order/order",
//默认状态下此导航栏的图标
"iconPath": "static/tabbarICON/category_default.png",
//点击后的图标
"selectedIconPath": "static/tabbarICON/category_selected.png"
},
{
"text": "我的",
"pagePath": "pages/my/my",
"iconPath": "static/tabbarICON/user_default.png",
"selectedIconPath": "static/tabbarICON/user_selected.png"
}
]
},
"globalStyle": { //全局样式
"navigationBarTextStyle": "black",
"navigationBarTitleText": "uni-app",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8",
"enablePullDownRefresh": true
},
"subPackages": [ //分包配置
。。。
],
"preloadRule": {
// 配置哪个分页预加载哪些子包
。。。
}
}
项目页面制作
index页面
样式
- 首先将index页面分为三大块:顶部栏轮播图,中部主要内容区,和中部各个卡片内容区。
- 拆分组件,减轻单个页面代码量。在index文件夹中创建components文件夹,存放有关index页面的所有组件,(LkSwiper:顶部广告轮播、OperateCard:操作栏、AdSwiper:广告栏、WelfareCard:福利中心、SaleRankCard:好喝榜),其中AdSwiper卡片可共用于其它页面,AdSwiper组件放在src下的components文件夹中,作为全局组件(src/components/AdSwiper.vue)。
- 中部主要内容区宽度占比为96%,并垂直居中。
逻辑
- index作为进入小程序后的最先显示的页面,在生命周期onReady中获取到所有数据后,将所有数据传分配给所有子组件,子组件接收数据后并渲染。而不是每个子组件都请求一次数据。父组件在子组件标签中绑定数据属性,并传递数据,子组件使用defineProps接收数据。
父组件 <AdSwiper :list="bannerList" /> 其它父组件 <AdSwiper :list="otherBannerList" /> 子组件 defineProps<{ list:BannerItem[] //BannerItem为bannerList中成员的数据类型 }>()
- 对于全局组件,如AdSwiper组件,所接收的数据来自不同的父组件,在子组件标签中添绑定的属性需一致,如上list属性,属性值如bannerList则可以是不同的父组件数据。
- 所有可点击区域(如图标、文字链接)都可用navigator标签包裹,直接将对应跳转页面路径写入navigator标签url属性中。
<navigator url="/pages/my/my">....</navigator> 跳转到my页面
- 到店取/幸运送功能,点击到店取或幸运送,跳转到pages/order/order页面,传入自取或外送信息标识参数,order页面接收标识,显示自取或外送。
order页面
基本样式
- 首先分为三大块:顶部栏、中部内容栏、底部点单详细栏,前两大块分别对应两个组件OroderTopBanner、OrderTabCard。
- order设置了自定义顶部栏样式,"navigationStyle": "custom",需要设置一个安全距离,避免顶部栏内容渗入到手机顶部任务栏中。
<view class="viewport" :style="{ paddingTop: safeAreaInsets?.top + 'px' }" > .... </view> //获取安全距离,作为order页面padding-top const { safeAreaInsets } = uni.getSystemInfoSync();
- 自提和外送选择器,没有在uni-ui找到合适的模板,自己写
- order设置了自定义顶部栏样式,"navigationStyle": "custom",需要设置一个安全距离,避免顶部栏内容渗入到手机顶部任务栏中。
-
<template> ... <view class="pick-method" @tap="switchMethod"> <view :class="isTakeOut ? '' : 'active'">自提</view> <view :class="isTakeOut ? 'active' : ''">外送</view> </view> ... </template> <script setup lang="ts"> import { ref } from "vue"; //通过isTakeOut决定是否为该view添加acitve类名,拥有active类名背景样式为蓝色 let isTakeOut = ref(false); //true为自提,false为外送 const switchMethod = () => { //点击pick-method切换 isTakeOut.value = !isTakeOut.value; }; const changeTakeout = (param?: string) => { //根据index页面跳转过来传递的参数标识,切换isTakeOut console.log(param); if (param === "0") { isTakeOut.value = false; } else if (param === "1") { isTakeOut.value = true; } }; //将方法暴露出去,供此子组件的父组件order使用 defineExpose({ changeTakeout, }); </script> <style lang="scss"> .pick-method { width: 170rpx; height: 50rpx; display: flex; background-color: rgba(161, 156, 156, 0.507); border-radius: 13px; align-items: center; margin-right: 40rpx; view { width: 50%; height: 50rpx; line-height: 50rpx; border-radius: 16px; text-align: center; } .active { background-color: blue; color: white; border-radius: 13px; } } </style>
中部内容区
- 中部内容分为两大区域:经典菜单和会员卡,通过左右滑动中部或直接点击tab栏可切换经典菜单和会员卡 。siwper具有两个swiper-item项,分别为经典菜单、会员卡。scroll-view和swiper的宽度限制在与屏幕宽度同宽,并且两个swiper-item项宽度继承swiper同宽。
- swiper添加@change事件(current 改变时会触发 change 事件,event.detail = {current: current, source: source}),并绑定一个current属性,为swiper子项目siwper-item设置唯一标识item-goodsId,当前显示的swiper-item与swiper-item中的item-goodsId值和当前swiper属性current的值是否一致有关。
.tab-swiper { width: 100%; height: calc(100vh - 245rpx); .menu-card { background-color: rgba(198, 189, 189, 0.42); } .vip-card { width: 100%; background-color: rgb(43, 133, 79); } }
- tab-bar中放置两个导航,为两个导航添加点击事件,并传入对应TABID,在点击经典菜单时调用switchTab方法并传入参数0,将参数0赋值给targetIndex。swiper中current属性获取最新的targetIndex为0,则显示item-goodsId为0的swiper-item项。自此,通过点击导航栏项切换swiper-item项 完成。
<view class="tab-bar"> <view class="tab-item" v-for="item in tabs" :key="item.TABID" :class="item.TABID === targetIndex ? ' active' : ''" @tap="switchTab(item.TABID)" > {{ item.title }} </view> </view> </view> <swiper class="tab-swiper" @change="onChange" :current="targetIndex"> <swiper-item class="menu-card" item-goodsId="0">...</swiper-item> <swiper-item class="vip-card" item-goodsId="1">...</swiper-item> </scroll-view> // const tabs = [ { TABID: 0, title: "经典菜单" }, { TABID: 1, title: "会员卡" }, ]; let targetIndex = ref<number>(0); //tab切换目标组件 //tab点击事件 const switchTab = (goodsId: number) => { targetIndex.value = goodsId; console.log(targetIndex.value); }; //swiper的@change事件,current的值发生改变后, const onChange = (e: any) => { targetIndex.value = e.detail.current; console.log(targetIndex.value); console.log(e.detail.current); };
经典菜单点单
- 在经典菜单中,分为左右两个不一致的可滑动区域。左侧内容为各种不同款的商品归类(导航),每个导航包含多中款的商品,在点击不同导航时,右侧滑动到对应商品种类区域。在滑动右侧商品时,某个种类商品区域到达swiper顶部边界,左侧跟随切换到对应导航(高亮)。
- 左侧每个导航都有对应的tabId(要求tabId不为纯数字,而应是字符串如‘tab1’),通过tabId判断当前导航是否active高亮显示,右侧scroll-view中,绑定scroll-into-view属性,当scroll-into-view所绑定的值与scroll-view中的某个子项view的id值一致时,则自动滑动到该子项。即点击左侧某个导航时,触发switchMenuTab方法并传入当前导航的tabId,在switchMenuTab方法中,将参数值赋值给targetMenuIndex,从而改变右侧scroll-view中scroll-into-view的值,继而直接自动滑动到右侧scroll-view所有子项view中id与scroll-into-view的值一致的子项。点击左侧导航栏显示右侧对应区域就完成了。
-
<scroll-view scroll-y class="left-tab"> <view class="tab-item-title" v-for="item in allMenu" :key="item.tabid" @tap="switchMenuTab(item.tabid)" :class="item.tabid == targetMenuIndex ? 'active' : ''" > {{ item.categoryList[0] }} //导航名称 </view> </scroll-view> <scroll-view scroll-y class="right-tab" :scroll-into-view="targetMenuIndex" @scroll="rightScroll" > <view class="tab-container" v-for="item in allMenu" :key="item.tabid" :id="item.tabid" > ... </view> </scroll-view> // let targetMenuIndex = ref("tab1"); //默认显示右侧第一个子项 const switchMenuTab = (goodsId: any) => { targetMenuIndex.value = goodsId; console.log(targetMenuIndex.value, goodsId); };
- 滑动右侧,左侧对应导航也同步高亮,计算右侧每个子项目view盒子顶部距离父盒子scroll-view,将所有处理后的距离存储到toTopDistance数组中。同时右侧scroll-view中添加@scroll事件,当用户滚动右侧scroll-view时,则触发rightScroll方法。
-
let toTopDistance: any = []; const getRightTabInfor = () => { const instance = getCurrentInstance(); const query = uni.createSelectorQuery().in(instance); // 获取右侧所有格子的信息 query .selectAll(".tab-container") .boundingClientRect((data) => { const formatData = JSON.parse(JSON.stringify(data)); for (let i = 0; i < formatData.length; i++) { console.log(formatData[i].top ) //获取的是每个子项顶部距离屏幕顶部的距离(px) // 获取所有子盒子距离父盒子顶部距离时 let temp = (formatData[i].top - 260).toFixed(0); //需要减去大概整个顶部栏高度 toTopDistance.push(temp); //将所有子项目的距离存储到toTopDistance数组中 } }) .exec(); }; const rightScroll = (e: any) => { //scroll事件可以获取当前一些信息 let SCROLL_TOP = e.detail.scrollTop; //当前滚动的距离(从scroll-view框架顶部到内容超出框架顶部的距离) //当前超出框架顶部的距离大于等于0并且小于第二个盒子顶部距离框架顶部的距离时,左侧导航显示的是tab1导航 if (0 <= SCROLL_TOP && SCROLL_TOP < toTopDistance[1]) { targetMenuIndex.value = `tab1`; } else if (toTopDistance[1] <= SCROLL_TOP && SCROLL_TOP < toTopDistance[2]) { targetMenuIndex.value = `tab2`; } else if (toTopDistance[2] <= SCROLL_TOP && SCROLL_TOP < toTopDistance[3]) { targetMenuIndex.value = `tab3`; } else if (toTopDistance[3] <= SCROLL_TOP && SCROLL_TOP < toTopDistance[4]) { targetMenuIndex.value = `tab4`; } }; onMounted(() => { getRightTabInfor(); });
自提外送选择器逻辑
- 在OrderTopBanner子组件中的自提外送选择器,通过isTaKeOut的value布尔值来决定谁高亮蓝色背景。点击选择框调用switchMehtod方法,改变isTakeOut值来切换选项。
- 同时定义changeTakeOut方法,到店取跳转到order页面传入参数为1,幸运送传入参数为2,此处并不是真正意义上跳转传参,而是在点击到店取/幸运送时,执行跳转并将参数保存到storage中,跳转至order页面后,在onShow中获取当前storage中的参数值,并立即清理该storage。
- 获取到参数后,将参数传给子组件OrderTopBanner暴露的changeTakeOut方法,此方法获取参数判断是自取还是外送,从而改变isTakeOut的value值,继而改变选项框的高亮背景选项。
-
为幸运送和到店取添加tap事件 uni.setStorageSync("order-key", '1'); //保存不同的参数到storage中 uni.setStorageSync("order-key", '0'); uni.switchTab({ //跳转到order页面 url: /pages/order/order, }); //OrderTopBanner ... const changeTakeout = (param?: string) => { console.log(param); //参数为0,说明是从幸运送跳转来的 if (param === "0") { isTakeOut.value = false; } else if (param === "1") { isTakeOut.value = true; } }; defineExpose({ //将changeTakeout方法暴露给父组件,因为参数只能从父组件获取 changeTakeout, }); ... // order/order 获取子组件 <OrderTopBanner ref="RefChild" /> const RefChild = ref(); //获取子组件实例对象 const callChildFn = () => { //创建callChildFn方法调用子组件方法 let orderKey = uni.getStorageSync("order-key"); //从storage中获取参数 uni.removeStorageSync("order-key"); //获取后就移除该参数 RefChild.value.changeTakeout(orderKey); //调用子组件的chagneTakeout方法,并传入参数 }; onShow(()=>{ callChildFn() //在跳转到order页面后立即执行此方法 })
订单栏
- 订单栏分为有订单情况和没订单情况(isCollapsed为true),另一部分是弹出框(不使用自带弹出框)部分和弹出框遮罩层部分,当存在订单时,点击order-card会弹出弹出框,显示所有加入购物车的订单。
- 在有订单情况下点击触发onTapUncollpasedBanner,弹出弹出框,显示遮罩层。通过动态style控制display的值。遮罩层就是低于弹出层层级一级的半透明灰黑背景,点击背景后关闭遮罩层和弹出层。
-
const popup = ref(); const popupOverlay = ref(); let isPopupOverlay: boolean = false; let isShowPopup: boolean = false; let customPopupStyle = ref({ display: "none", transform: "translateY(400rpx)", }); let customPopupOverlayStyle = ref({ display: "none", }); const onTapUncollpasedBanner = () => { // 有订单,默认展开显示 订单,点击打开popup展示所有订单 isShowPopup = !isShowPopup; isPopupOverlay = !isPopupOverlay; if (isShowPopup === false) { customPopupStyle.value.display = "none"; customPopupOverlayStyle.value.display = "none"; } else if (isShowPopup === true) { customPopupStyle.value.display = "flex"; customPopupOverlayStyle.value.display = "block"; } };
添加订单(加入购物车)
- 新建商品详情分包src/pagesOrder,分包下创建两个页面GoodsDetail页面和Purchase页面,在pages.json中配置分包,在进入order页面时加载分包pagesOrder。
-
"subPackages": [ { // 进入order页后预加载一下分包 "root": "pagesOrder", "pages": [ { "path": "Purchase/Purchase", "style": { "navigationBarTitleText": "Purchase" } }, { "path": "GoodsDetail/GoodsDetail", "style": { "navigationBarTitleText": "GoodsDetail", "navigationStyle": "custom" } } ] }, { ....其它分包 } ], "preloadRule": { // 配置哪个页面预加载哪些分包 "pages/order/order": { "network": "all", "packages": [ "pagesOrder" ] }, { ... } }
- 点击order页右侧商品,跳转到商品详情页面GoodsDetail并传入此商品编号,GoodsDetail页面接收商品编号,获取此商品数据。选择商品类型和数量,将此商品所有类型及数量的商品存放在currGoodsOrder数组中。
- 点击+图标,新增一个此商品到currGoodsOrder中(currGoodsOrder.push()),点击➖图标减去最新添加的商品(currGoodsOrder.pop())。
- 点击加入购物车,将所有商品currGoodsOrder存储到本地存储中,order获取后清除。
-
//GoodsDetail页面 const onAddShoppingCar = () => { ... // 添加入购物车 // 跳转到order界面,并将当前订单商品传递给order uni.setStorageSync( "banner-orders", JSON.parse(JSON.stringify(currGoodsOrder)) ); uni.switchTab({ url: "/pages/order/order", success: (success) => { uni.showToast({ title: "添加成功", position: "center", }); }, }); ... };
//order页面接收加入购物车的商品数据 // pushBannerOrders:获取新增购物车订单 const pushBannerOrders = () => { // 从参数中获取新的order,并新增到bannerOrders中 let order: OrderItem = Array.from(uni.getStorageSync("banner-orders")); removeDuplicateOrder(order); // 获取后清除这个order uni.removeStorageSync("banner-orders"); }; onShow(()=>{ ... pushBannerOrdes(); ... })
- order页获取加入购物车数据后进行处理,(一次加入购物车的currGoodsOrder数据都是同一个商品,只是区分商品不同类型),对新数据进行去重等处理,调用removeDuplicateOrder方法。ordre页面有一个自己的bannerOrders数组,存放所有订单。第一次加入一种商品时,bannerOders为空,按currGoodsOrder商品类型划分,同一种类型的商品只显示一次,不同类型和不同商品都独占一行。重复的商品并且重复的类型都只显示一次。
-
const removeDuplicateOrder = (ORDER: OrderItem[]) => { // 1.处理ORDER // 创建新数组接收去重后的ORDER let newOrders: OrderItem[] = []; // 在ORDER中,所有订单出了goodsCustom和goodsCustomFormat存在不一致外,无法通过深度判断goodsCustom(对象)去重,所以使用goodsCustomFormat是否存在相同的 // 使用map存储判断后的数据 let map = new Map(); for (let item of ORDER) { if (!map.has(item.goodsCustomFormat)) { map.set(item.goodsCustomFormat, item); } } newOrders = [...map.values()]; // 判断newORders中每个成员在ORDER中的数量并创建newOrdersNum变量接收 let newOrdersNum: number[] = []; newOrders.forEach((item) => { let i = 0; ORDER.forEach((ITEM) => { if (ITEM.goodsCustomFormat === item.goodsCustomFormat) { i++; } }); newOrdersNum.push(i); }); // 将newOrdersNum数组中的数据按顺序赋值给每个newOrders中的成员的goodsNum属性 for (let i in newOrders) { newOrders[i].goodsNum = newOrdersNum[i]; } // newOrders中一定是同goodsId的商品,区分在同goodId是否有不同goodsCustomFormat,而newOrders是已经去重的数组,每个成员都是同goodsId不同goodsCustomFormat。 // 在为bannerOrders注入数据时,判断如下: // 1.判断当前bannerOrders中是否存在当前需要注入的数据newOrders的商品种类(judge,判断bannerOrders中是否存在与newOrders中的goodsId一致的商品) // 1.1 newOrders中的商品在bannerOrders中存在,判断bannerOrders中该数据的goodsCustomFormat是否与当前newOrders中的某个成员的goodsCustomFormat一致 // 1.1.1 item.goodsCustomFormat == ITEM.goodsCustomFormat即存在与newOrders中某个成员同商品同商品类型的数据,则增加bannerOrders中的goodsNum // 1.1.2 在bannerOrders中找不到同类型goodsCustomFormat的商品,则将newOrders中的该数据push到bannerOrders中 // 1.2 newOrders中的商品在bannerOrders中不存在,即newOrders中的goodsId在bannerOrders中找不到,则直接将整个newOrders拼接到bannerOrders中(完成第一次添 // 加购物车后,第二次不同的商品添加入购物车,直接将第二次的所有商品拼接到当前的bannerOrders中) let judge = bannerOrders.value.findIndex( (item: OrderItem) => item.goodsId === newOrders[0].goodsId ); if (judge !== -1) { //1.1 bannerOrders.value.forEach((item) => { newOrders.forEach((ITEM) => { // 1.1 if (item.goodsCustomFormat == ITEM.goodsCustomFormat) { // 1.1.1 item.goodsNum += ITEM.goodsNum; } else if ( bannerOrders.value.findIndex( (item3) => item3.goodsCustomFormat === ITEM.goodsCustomFormat ) === -1 ) { // 1.1.2 bannerOrders.value.push(ITEM); } }); }); } else { // 1.2 bannerOrders.value = bannerOrders.value.concat(newOrders); } };
my页面
- my页面与index页面大致相同
个人信息修改页
- 个人信息页为属于my页面的分包下的一个页面myInfo。
- 在myInfo页面中展示所有用户信息,并且可修改其中信息,修改后发送请求以更新数据。