✔ 完成填写订单模块的开发。
✔ 完成订单详情模块的开发。
✔ 完成订单列表模块的开发。
加入购物车
在商品详情页把 选中规格后的商品(SKU) 加入购物车。

接口封装
src/services/cart.ts
1 2 3 4 5 6 7 8 9 10 11 12
| import { http } from '@/utils/http'
export const postMemberCartAPI = (data: { skuId: string; count: number }) => { return http({ method: 'POST', url: '/member/cart', data, }) }
|
参考代码
通过 SKU
组件提供的 add-cart
事件,获取加入购物车时所需的参数,pages/goods/goods.vue
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <script setup lang="ts"> import { postMemberCartAPI } from '@/services/cart' import type { SkuPopupEvent } from '@/components/vk-data-goods-sku-popup/vk-data-goods-sku-popup'
const onAddCart = async (ev: SkuPopupEvent) => { await postMemberCartAPI({ skuId: ev._id, count: ev.buy_num }) uni.showToast({ title: '添加成功' }) isShowSku.value = false } </script>
<template> <vk-data-goods-sku-popup v-model="isShowSku" :localdata="localdata" @add-cart="onAddCart" /> </template>
|
小结一下
购物车列表
购物车列表需要访问后才能登录。

静态结构
src/pages/cart/cart.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380
| <script setup lang="ts">
</script>
<template> <scroll-view scroll-y class="scroll-view"> <template v-if="true"> <view class="cart-list" v-if="true"> <view class="tips"> <text class="label">满减</text> <text class="desc">满1件, 即可享受9折优惠</text> </view> <uni-swipe-action> <uni-swipe-action-item v-for="item in 2" :key="item" class="cart-swipe"> <view class="goods"> <text class="checkbox" :class="{ checked: true }"></text> <navigator :url="`/pages/goods/goods?id=1435025`" hover-class="none" class="navigator" > <image mode="aspectFill" class="picture" src="https://yanxuan-item.nosdn.127.net/da7143e0103304f0f3230715003181ee.jpg" ></image> <view class="meta"> <view class="name ellipsis">人手必备,儿童轻薄透气防蚊裤73-140cm</view> <view class="attrsText ellipsis">黄色小象 140cm</view> <view class="price">69.00</view> </view> </navigator> <view class="count"> <text class="text">-</text> <input class="input" type="number" value="1" /> <text class="text">+</text> </view> </view> <template #right> <view class="cart-swipe-right"> <button class="button delete-button">删除</button> </view> </template> </uni-swipe-action-item> </uni-swipe-action> </view> <view class="cart-blank" v-else> <image src="/static/images/blank_cart.png" class="image" /> <text class="text">购物车还是空的,快来挑选好货吧</text> <navigator url="/pages/index/index" hover-class="none"> <button class="button">去首页看看</button> </navigator> </view> <view class="toolbar"> <text class="all" :class="{ checked: true }">全选</text> <text class="text">合计:</text> <text class="amount">100</text> <view class="button-grounp"> <view class="button payment-button" :class="{ disabled: true }"> 去结算(10) </view> </view> </view> </template> <view class="login-blank" v-else> <text class="text">登录后可查看购物车中的商品</text> <navigator url="/pages/login/login" hover-class="none"> <button class="button">去登录</button> </navigator> </view> <XtxGuess ref="guessRef"></XtxGuess> <view class="toolbar-height"></view> </scroll-view> </template>
<style lang="scss"> // 根元素 :host { height: 100vh; display: flex; flex-direction: column; overflow: hidden; background-color: #f7f7f8; }
// 滚动容器 .scroll-view { flex: 1; }
// 购物车列表 .cart-list { padding: 0 20rpx;
// 优惠提示 .tips { display: flex; align-items: center; line-height: 1; margin: 30rpx 10rpx; font-size: 26rpx; color: #666;
.label { color: #fff; padding: 7rpx 15rpx 5rpx; border-radius: 4rpx; font-size: 24rpx; background-color: #27ba9b; margin-right: 10rpx; } }
// 购物车商品 .goods { display: flex; padding: 20rpx 20rpx 20rpx 80rpx; border-radius: 10rpx; background-color: #fff; position: relative;
.navigator { display: flex; }
.checkbox { position: absolute; top: 0; left: 0;
display: flex; align-items: center; justify-content: center; width: 80rpx; height: 100%;
&::before { content: '\e6cd'; font-family: 'erabbit' !important; font-size: 40rpx; color: #444; }
&.checked::before { content: '\e6cc'; color: #27ba9b; } }
.picture { width: 170rpx; height: 170rpx; }
.meta { flex: 1; display: flex; flex-direction: column; justify-content: space-between; margin-left: 20rpx; }
.name { height: 72rpx; font-size: 26rpx; color: #444; }
.attrsText { line-height: 1.8; padding: 0 15rpx; font-size: 24rpx; align-self: flex-start; border-radius: 4rpx; color: #888; background-color: #f7f7f8; }
.price { line-height: 1; font-size: 26rpx; color: #444; margin-bottom: 2rpx; color: #cf4444;
&::before { content: '¥'; font-size: 80%; } }
// 商品数量 .count { position: absolute; bottom: 20rpx; right: 5rpx;
display: flex; justify-content: space-between; align-items: center; width: 220rpx; height: 48rpx;
.text { height: 100%; padding: 0 20rpx; font-size: 32rpx; color: #444; }
.input { height: 100%; text-align: center; border-radius: 4rpx; font-size: 24rpx; color: #444; background-color: #f6f6f6; } } }
.cart-swipe { display: block; margin: 20rpx 0; }
.cart-swipe-right { display: flex; height: 100%;
.button { display: flex; justify-content: center; align-items: center; width: 50px; padding: 6px; line-height: 1.5; color: #fff; font-size: 26rpx; border-radius: 0; }
.delete-button { background-color: #cf4444; } } }
// 空状态 .cart-blank, .login-blank { display: flex; justify-content: center; align-items: center; flex-direction: column; height: 60vh; .image { width: 400rpx; height: 281rpx; } .text { color: #444; font-size: 26rpx; margin: 20rpx 0; } .button { width: 240rpx !important; height: 60rpx; line-height: 60rpx; margin-top: 20rpx; font-size: 26rpx; border-radius: 60rpx; color: #fff; background-color: #27ba9b; } }
// 吸底工具栏 .toolbar { position: fixed; left: 0; right: 0; bottom: calc(var(--window-bottom)); z-index: 1;
height: 100rpx; padding: 0 20rpx; display: flex; align-items: center; border-top: 1rpx solid #ededed; border-bottom: 1rpx solid #ededed; background-color: #fff;
.all { margin-left: 25rpx; font-size: 14px; color: #444; display: flex; align-items: center; }
.all::before { font-family: 'erabbit' !important; content: '\e6cd'; font-size: 40rpx; margin-right: 8rpx; }
.checked::before { content: '\e6cc'; color: #27ba9b; }
.text { margin-right: 8rpx; margin-left: 32rpx; color: #444; font-size: 14px; }
.amount { font-size: 20px; color: #cf4444;
.decimal { font-size: 12px; }
&::before { content: '¥'; font-size: 12px; } }
.button-grounp { position: absolute; right: 10rpx; top: 50%;
display: flex; justify-content: space-between; text-align: center; line-height: 72rpx; font-size: 13px; color: #fff; transform: translateY(-50%);
.button { width: 240rpx; margin: 0 10rpx; border-radius: 72rpx; }
.payment-button { background-color: #27ba9b;
&.disabled { opacity: 0.6; } } } } // 底部占位空盒子 .toolbar-height { height: 100rpx; box-sizing: content-box; } </style>
|
登录状态
已登录显示购物车列表,否则应引导用户去登录。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| <script setup lang="ts"> import { useMemberStore } from '@/stores'
const memberStore = useMemberStore() </script>
<template> <scroll-view scroll-y class="scroll-view"> <template v-if="memberStore.profile"> </template> <view class="login-blank" v-else> <text class="text">登录后可查看购物车中的商品</text> <navigator url="/pages/login/login" hover-class="none"> <button class="button">去登录</button> </navigator> </view> </scroll-view> </template>
|
pages/login/login.vue
1 2 3 4 5 6 7 8 9 10 11 12 13
| const loginSuccess = (profile: LoginResult) => { const memberStore = useMemberStore() memberStore.setProfile(profile) uni.showToast({ icon: 'success', title: '登录成功' }) setTimeout(() => { uni.navigateBack() }, 500) }
|
列表渲染
调用接口获取当前登录用户购物车中的商品列表。
src/services/cart.ts
1 2 3 4 5 6 7 8 9
|
export const getMemberCartAPI = () => { return http<CartItem[]>({ method: 'GET', url: '/member/cart', }) }
|
类型声明
src/types/cart.d.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| export type CartItem = { id: string skuId: string name: string picture: string count: number price: number nowPrice: number stock: number selected: boolean attrsText: string isEffective: boolean }
|
参考代码
在页面初始化的时候判断用户是否已登录,已登录获取购物车列表。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| <script setup lang="ts"> import { getMemberCartAPI } from '@/services/cart' import { useMemberStore } from '@/stores' import type { CartItem } from '@/types/cart' import { onShow } from '@dcloudio/uni-app' import { ref } from 'vue'
const memberStore = useMemberStore()
const cartList = ref<CartItem[]>([]) const getMemberCartData = async () => { const res = await getMemberCartAPI() cartList.value = res.result }
onShow(() => { if (memberStore.profile) { getMemberCartData() } }) </script>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83
| <template v-if="memberStore.profile"> <view class="cart-list" v-if="cartList.length"> <view class="tips"> <text class="label">满减</text> <text class="desc">满1件, 即可享受9折优惠</text> </view> <uni-swipe-action> <uni-swipe-action-item v-for="item in cartList" :key="item.skuId" class="cart-swipe" > <view class="goods"> <text class="checkbox" :class="{ checked: item.selected }"></text> <navigator :url="`/pages/goods/goods?id=${item.id}`" hover-class="none" class="navigator" > <image mode="aspectFill" class="picture" :src="item.picture" ></image> <view class="meta"> <view class="name ellipsis">{{ item.name }}</view> <view class="attrsText ellipsis">{{ item.attrsText }}</view> <view class="price">{{ item.nowPrice }}</view> </view> </navigator> <view class="count"> <text class="text">-</text> <input class="input" type="number" :value="item.count.toString()" /> <text class="text">+</text> </view> </view> <template #right> <view class="cart-swipe-right"> <button class="button delete-button">删除</button> </view> </template> </uni-swipe-action-item> </uni-swipe-action> </view> <view class="cart-blank" v-else> <image src="/static/images/blank_cart.png" class="image" /> <text class="text">购物车还是空的,快来挑选好货吧</text> <navigator url="/pages/index/index" hover-class="none"> <button class="button">去首页看看</button> </navigator> </view> <view class="toolbar"> <text class="all" :class="{ checked: true }">全选</text> <text class="text">合计:</text> <text class="amount">100</text> <view class="button-grounp"> <view class="button payment-button" :class="{ disabled: true }"> 去结算(10) </view> </view> </view> </template>
|
小结一下
删除购物车
通过侧滑删除购物车的商品,使用 uni-swipe-action 组件实现。
接口封装
src/services/cart.ts
1 2 3 4 5 6 7 8 9 10 11
|
export const deleteMemberCartAPI = (data: { ids: string[] }) => { return http({ method: 'DELETE', url: '/member/cart', data, }) }
|
参考代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| <script setup lang="ts"> import { deleteMemberCartAPI } from '@/services/cart'
const onDeleteCart = (skuId: string) => { uni.showModal({ content: '是否删除', success: async (res) => { if (res.confirm) { await deleteMemberCartAPI({ ids: [skuId] }) getMemberCartData() } }, }) } </script>
<template> <template #right> <view class="cart-swipe-right"> <button @tap="onDeleteCart(item.skuId)" class="button delete-button">删除</button> </view> </template> </template>
|
小结一下
修改商品信息
修改购买数量,修改选中状态。
接口封装
src/services/cart.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
export const putMemberCartBySkuIdAPI = ( skuId: string, data: { selected?: boolean; count?: number }, ) => { return http({ method: 'PUT', url: `/member/cart/${skuId}`, data, }) }
|
修改商品数量
复用 SKU
插件中的 步进器组件 修改商品数量,补充类型声明文件让组件类型更安全。
src/components/vk-data-input-number-box/vk-data-input-number-box.d.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
| import { Component } from '@uni-helper/uni-app-types'
export type InputNumberBox = Component<InputNumberBoxProps>
export type InputNumberBoxInstance = InstanceType<InputNumberBox>
export type InputNumberBoxProps = { modelValue: number min: number max: number step: number disabled: boolean inputWidth: string | number inputHeight: string | number bgColor: string index: string onChange: (event: InputNumberBoxEvent) => void onBlur: (event: InputNumberBoxEvent) => void onPlus: (event: InputNumberBoxEvent) => void onMinus: (event: InputNumberBoxEvent) => void }
export type InputNumberBoxEvent = { value: number index: string }
declare module '@vue/runtime-core' { export interface GlobalComponents { 'vk-data-input-number-box': InputNumberBox } }
|
参考代码
pages/cart/cart.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| <script setup lang="ts"> import type { InputNumberBoxEvent } from '@/components/vk-data-input-number-box/vk-data-input-number-box'
const onChangeCount = (ev: InputNumberBoxEvent) => { putMemberCartBySkuIdAPI(ev.index, { count: ev.value }) } </script>
<template> <view class="count"> <vk-data-input-number-box v-model="item.count" :min="1" :max="item.stock" :index="item.skuId" @change="onChangeCount" /> </view> </template>
|
小结一下
修改商品选中/全选
封装全选的函数,src/services/cart.ts
。
1 2 3 4 5 6 7 8 9 10 11
|
export const putMemberCartSelectedAPI = (data: { selected: boolean }) => { return http({ method: 'PUT', url: '/member/cart/selected', data, }) }
|
参考代码
pages/cart/cart.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| <script setup lang="ts">
const onChangeSelected = (item: CartItem) => { item.selected = !item.selected putMemberCartBySkuIdAPI(item.skuId, { selected: item.selected }) }
const isSelectedAll = computed(() => { return cartList.value.length && cartList.value.every((v) => v.selected) })
const onChangeSelectedAll = () => { const _isSelectedAll = !isSelectedAll.value cartList.value.forEach((item) => { item.selected = _isSelectedAll }) putMemberCartSelectedAPI({ selected: _isSelectedAll }) } </script>
<template> <view class="goods"> <text @tap="onChangeSelected(item)" class="checkbox" :class="{ checked: item.selected }"> </text> </view> <view class="toolbar"> <text @tap="onChangeSelectedAll" class="all" :class="{ checked: isSelectedAll }">全选</text> </view> </template>
|
小结一下
底部结算信息
计算总钱数(总金额)
计算并展示购物车中选中商品所要支付的总金额,在用户切换商品选中状态和改变购数量后总的金额也要相应的进行重新计算,要实现这个功能我们仍然借助计算属性来实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| <script setup lang="ts">
const selectedCartList = computed(() => { return cartList.value.filter((v) => v.selected) })
const selectedCartListCount = computed(() => { return selectedCartList.value.reduce((sum, item) => sum + item.count, 0) })
const selectedCartListMoney = computed(() => { return selectedCartList.value .reduce((sum, item) => sum + item.count * item.nowPrice, 0) .toFixed(2) })
const gotoPayment = () => { if (selectedCartListCount.value === 0) { return uni.showToast({ icon: 'none', title: '请选择商品', }) } uni.navigateTo({ url: '/pagesOrder/create/create' }) } </script>
|
1 2 3 4 5 6 7 8 9 10 11
| <text class="text">合计:</text> <text class="amount">{{ selectedCartListMoney }}</text> <view class="button-grounp"> <view @tap="gotoPayment" class="button payment-button" :class="{ disabled: selectedCartListCount === 0 }" > 去结算({{ selectedCartListCount }}) </view> </view>
|
小结一下
带返回按钮的购物车
为了解决小程序 tabBar 页面限制 导致无法返回上一页的问题,将购物车业务独立为组件,使其既可从底部 tabBar 访问,又可在商品详情页中跳转并返回。
这样就需要 两个购物车页面 实现该功能,其中一个页面为 tabBar 页,另一个为普通页。

目录结构如下。
1 2 3 4 5
| pages/cart ├── components │ └── CartMain.vue ...................................... 购物车业务组件 ├── cart2.vue ............................................. 普通页 └── cart.vue ............................................ TabBar页
|
pages/cart/cart.vue
1 2 3 4 5 6
| <script lang="ts" setup> import CartMain from './components/CartMain.vue' </script> <template> <CartMain /> </template>
|
pages.json
多添加一项配置。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| { "pages": [ { "path": "pages/cart/cart", "style": { "navigationBarTitleText": "购物车" } }, { "path": "pages/cart/cart2", "style": { "navigationBarTitleText": "购物车" } }, ], }
|
pages/goods/goods.vue
1 2 3 4 5 6 7
| <navigator class="icons-button" url="/pages/cart/cart2" open-type="navigate" > <text class="icon-cart"></text>购物车 </navigator>
|
把原本的购物车业务独立封装成组件,在两个购物车页面分别引入即可。
tabBar 页:小程序跳转到 tabBar 页面时,会关闭其他所有非 tabBar 页面,所以小程序的 tabBar 页没有后退按钮。
填写订单页
小兔鲜儿项目有三种方式可以生成订单信息,分别是:购物车结算、立即购买、再次购买。

静态结构
分包,src/pagesOrder/create/create.vue
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329
| <script setup lang="ts"> import { computed, ref } from 'vue'
const { safeAreaInsets } = uni.getSystemInfoSync()
const buyerMessage = ref('')
const deliveryList = ref([ { type: 1, text: '时间不限 (周一至周日)' }, { type: 2, text: '工作日送 (周一至周五)' }, { type: 3, text: '周末配送 (周六至周日)' }, ])
const activeIndex = ref(0)
const activeDelivery = computed(() => deliveryList.value[activeIndex.value])
const onChangeDelivery: UniHelper.SelectorPickerOnChange = (ev) => { activeIndex.value = ev.detail.value } </script>
<template> <scroll-view scroll-y class="viewport"> <navigator v-if="false" class="shipment" hover-class="none" url="/pagesMember/address/address?from=order" > <view class="user"> 张三 13333333333 </view> <view class="address"> 广东省 广州市 天河区 黑马程序员3 </view> <text class="icon icon-right"></text> </navigator> <navigator v-else class="shipment" hover-class="none" url="/pagesMember/address/address?from=order" > <view class="address"> 请选择收货地址 </view> <text class="icon icon-right"></text> </navigator>
<view class="goods"> <navigator v-for="item in 2" :key="item" :url="`/pages/goods/goods?id=1`" class="item" hover-class="none" > <image class="picture" src="https://yanxuan-item.nosdn.127.net/c07edde1047fa1bd0b795bed136c2bb2.jpg" /> <view class="meta"> <view class="name ellipsis"> ins风小碎花泡泡袖衬110-160cm </view> <view class="attrs">藏青小花 130</view> <view class="prices"> <view class="pay-price symbol">99.00</view> <view class="price symbol">99.00</view> </view> <view class="count">x5</view> </view> </navigator> </view>
<view class="related"> <view class="item"> <text class="text">配送时间</text> <picker :range="deliveryList" range-key="text" @change="onChangeDelivery"> <view class="icon-fonts picker">{{ activeDelivery.text }}</view> </picker> </view> <view class="item"> <text class="text">订单备注</text> <input class="input" :cursor-spacing="30" placeholder="选题,建议留言前先与商家沟通确认" v-model="buyerMessage" /> </view> </view>
<view class="settlement"> <view class="item"> <text class="text">商品总价: </text> <text class="number symbol">495.00</text> </view> <view class="item"> <text class="text">运费: </text> <text class="number symbol">5.00</text> </view> </view> </scroll-view>
<view class="toolbar" :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }"> <view class="total-pay symbol"> <text class="number">99.00</text> </view> <view class="button" :class="{ disabled: true }"> 提交订单 </view> </view> </template>
<style lang="scss"> page { display: flex; flex-direction: column; height: 100%; overflow: hidden; background-color: #f4f4f4; }
.symbol::before { content: '¥'; font-size: 80%; margin-right: 5rpx; }
.shipment { margin: 20rpx; padding: 30rpx 30rpx 30rpx 84rpx; font-size: 26rpx; border-radius: 10rpx; background: url(https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/locate.png) 20rpx center / 50rpx no-repeat #fff; position: relative;
.icon { font-size: 36rpx; color: #333; transform: translateY(-50%); position: absolute; top: 50%; right: 20rpx; }
.user { color: #333; margin-bottom: 5rpx; }
.address { color: #666; } }
.goods { margin: 20rpx; padding: 0 20rpx; border-radius: 10rpx; background-color: #fff;
.item { display: flex; padding: 30rpx 0; border-top: 1rpx solid #eee;
&:first-child { border-top: none; }
.picture { width: 170rpx; height: 170rpx; border-radius: 10rpx; margin-right: 20rpx; }
.meta { flex: 1; display: flex; flex-direction: column; justify-content: center; position: relative; }
.name { height: 80rpx; font-size: 26rpx; color: #444; }
.attrs { line-height: 1.8; padding: 0 15rpx; margin-top: 6rpx; font-size: 24rpx; align-self: flex-start; border-radius: 4rpx; color: #888; background-color: #f7f7f8; }
.prices { display: flex; align-items: baseline; margin-top: 6rpx; font-size: 28rpx;
.pay-price { margin-right: 10rpx; color: #cf4444; }
.price { font-size: 24rpx; color: #999; text-decoration: line-through; } }
.count { position: absolute; bottom: 0; right: 0; font-size: 26rpx; color: #444; } } }
.related { margin: 20rpx; padding: 0 20rpx; border-radius: 10rpx; background-color: #fff;
.item { display: flex; justify-content: space-between; align-items: center; min-height: 80rpx; font-size: 26rpx; color: #333; }
.input { flex: 1; text-align: right; margin: 20rpx 0; padding-right: 20rpx; font-size: 26rpx; color: #999; }
.item .text { width: 125rpx; }
.picker { color: #666; }
.picker::after { content: '\e6c2'; } }
.settlement { margin: 20rpx; padding: 0 20rpx; border-radius: 10rpx; background-color: #fff;
.item { display: flex; align-items: center; justify-content: space-between; height: 80rpx; font-size: 26rpx; color: #333; }
.danger { color: #cf4444; } }
.toolbar { position: fixed; left: 0; right: 0; bottom: calc(var(--window-bottom)); z-index: 1;
background-color: #fff; height: 100rpx; padding: 0 20rpx; border-top: 1rpx solid #eaeaea; display: flex; justify-content: space-between; align-items: center; box-sizing: content-box;
.total-pay { font-size: 40rpx; color: #cf4444;
.decimal { font-size: 75%; } }
.button { width: 220rpx; text-align: center; line-height: 72rpx; font-size: 26rpx; color: #fff; border-radius: 72rpx; background-color: #27ba9b; }
.disabled { opacity: 0.6; } } </style>
|
渲染基本信息
在购物车点击去结算后,进入填写订单页,用户可以选择订单的收货地址或补充订单信息。
src/services/order.ts
1 2 3 4 5 6 7 8 9 10 11
| import type { OrderPreResult } from '@/types/order' import { http } from '@/utils/http'
export const getMemberOrderPreAPI = () => { return http<OrderPreResult>({ method: 'GET', url: '/member/order/pre', }) }
|
类型声明
src/types/order.d.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| import type { AddressItem } from './address'
export type OrderPreResult = { goods: OrderPreGoods[] summary: { totalPrice: number postFee: number totalPayPrice: number } userAddresses: AddressItem[] }
export type OrderPreGoods = { attrsText: string count: number id: string name: string payPrice: string picture: string price: string skuId: string totalPayPrice: string totalPrice: string }
|
将后端返回的预付订单数据,渲染到页面中。
页面中调用,src/pagesOrder/create/create.vue
1 2 3 4 5 6 7 8 9 10 11 12
| import { getMemberOrderPreAPI } from '@/services/order' import type { OrderPreResult } from '@/types/order' import { onLoad } from '@dcloudio/uni-app'
const orderPre = ref<OrderPreResult>() const getMemberOrderPreData = async () => { const res = await getMemberOrderPreAPI() orderPre.value = res.result } onLoad(() => { getMemberOrderPreData() })
|
渲染内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| <view class="goods"> <navigator v-for="item in orderPre?.goods" :key="item.skuId" :url="`/pages/goods/goods?id=${item.id}`" class="item" hover-class="none" > <image class="picture" :src="item.picture" /> <view class="meta"> <view class="name ellipsis"> {{ item.name }} </view> <view class="attrs">{{ item.attrsText }}</view> <view class="prices"> <view class="pay-price symbol">{{ item.payPrice }}</view> <view class="price symbol">{{ item.price }}</view> </view> <view class="count">x{{ item.count }}</view> </view> </navigator> </view>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| <view class="settlement"> <view class="item"> <text class="text">商品总价: </text> <text class="number symbol">{{ orderPre?.summary.totalPayPrice.toFixed(2) }}</text> </view> <view class="item"> <text class="text">运费: </text> <text class="number symbol">{{ orderPre?.summary.postFee.toFixed(2) }}</text> </view> </view>
|
1 2 3 4 5 6 7 8 9 10 11
| <view class="toolbar" :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }" > <view class="total-pay symbol"> <text class="number">{{ orderPre?.summary.totalPayPrice.toFixed(2) }}</text> </view> <view class="button" :class="{ disabled: true }"> 提交订单 </view> </view>
|
小结一下
渲染收获地址
- 渲染默认收获地址,
src/pagesOrder/create/create.vue
。
1 2 3
| const selecteAddress = computed(() => { return orderPre.value?.userAddresses.find((v) => v.isDefault) })
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| <navigator v-if="selecteAddress" class="shipment" hover-class="none" url="/pagesMember/address/address?from=order" > <view class="user"> {{ selecteAddress?.receiver }} {{ selecteAddress?.contact }} </view> <view class="address"> {{ selecteAddress?.fullLocation }} {{ selecteAddress?.address }} </view> <text class="icon icon-right"></text> </navigator>
|
- 为了更好管理选中收货地址,创建独立 Store 维护,
stores/modules/address.ts
。
1 2 3 4 5 6 7 8 9 10 11 12 13
| import type { AddressItem } from '@/types/address' import { defineStore } from 'pinia' import { ref } from 'vue'
export const useAddressStore = defineStore('address', () => { const selectedAddress = ref<AddressItem>()
const changeSelectedAddress = (val: AddressItem) => { selectedAddress.value = val }
return { selectedAddress, changeSelectedAddress } })
|
src/pagesOrder/create/create.vue
1 2 3 4 5 6 7 8
| const addressStore = useAddressStore()
const selecteAddress = computed(() => { return ( addressStore.selectedAddress || orderPre.value?.userAddresses.find((v) => v.isDefault) ) })
|
地址管理页
修改选中收货地址,<navigator>
组件需要阻止事件冒泡,pagesMember/address/address.vue
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| <script setup lang="ts">
const onChangeAddress = (item: AddressItem) => { const addressStore = useAddressStore() addressStore.changeSelectedAddress(item) uni.navigateBack() } </script>
<template> <uni-swipe-action-item class="item" v-for="item in addressList" :key="item.id"> <view class="item-content" @tap="onChangeAddress(item)"> <view class="user"> {{ item.receiver }} <text class="contact">{{ item.contact }}</text> <text v-if="item.isDefault" class="badge">默认</text> </view> <view class="locate">{{ item.fullLocation }} {{ item.address }}</view> <navigator class="edit" hover-class="none" :url="`/pagesMember/address-form/address-form?id=${item.id}`" @tap.stop="() => {}" > 修改 </navigator> </view> </uni-swipe-action-item> </template>
|
小结一下
立即购买
从商品详情页的 SKU
组件直接点击【立即购买按钮】跳转到填写订单页,需要传递页面参数。
封装接口
src/services/order.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14
|
export const getMemberOrderPreNowAPI = (data: { skuId: string count: string addressId?: string }) => { return http<OrderPreResult>({ method: 'GET', url: '/member/order/pre/now', data, }) }
|
页面传参
从商品详情页的【立即购买事件】中收集两个必要参数,跳转填写订单页并传递页面参数。
pages/goods/goods.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| <script setup lang="ts">
const onBuyNow = (ev: SkuPopupEvent) => { uni.navigateTo({ url: `/pagesOrder/create/create?skuId=${ev._id}&count=${ev.buy_num}` }) } </script>
<template> <vk-data-goods-sku-popup v-model="isShowSku" :localdata="localdata" :mode="mode" add-cart-background-color="#FFA868" buy-now-background-color="#27BA9B" ref="skuPopupRef" :actived-style="{ color: '#27BA9B', borderColor: '#27BA9B', backgroundColor: '#E9F8F5' }" @add-cart="onAddCart" @buy-now="onBuyNow" /> </template>
|
填写订单
pagesOrder/create/create.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| <script setup lang="ts"> import { getMemberOrderPreAPI, getMemberOrderPreNowAPI } from '@/services/order'
const query = defineProps<{ skuId?: string count?: string }>()
const orderPre = ref<OrderPreResult>()
const getMemberOrderPreData = async () => { if (query.count && query.skuId) { const res = await getMemberOrderPreNowAPI({ count: query.count, skuId: query.skuId }) orderPre.value = res.result } else { const res = await getMemberOrderPreAPI() orderPre.value = res.result } } </script>
|
小结一下
提交订单
收集填写订单页的数据,点击页面底部的提交订单按钮,创建一个新的订单。
类型声明
src/types/order.d.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| export type OrderCreateParams = { addressId: string deliveryTimeType: number buyerMessage: string goods: { count: number skuId: string }[] payChannel: 1 | 2 payType: 1 | 2 }
export type OrderCreateResult = { id: string }
|
接口封装
src/services/order.ts
1 2 3 4 5 6 7 8 9 10 11
|
export const postMemberOrderAPI = (data: OrderCreateParams) => { return http<{ id: string }>({ method: 'POST', url: '/member/order', data, }) }
|
参考代码
点击提交订单按钮实现创建订单,订单创建成功后,跳转到订单详情并传递订单 id。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| <script setup lang="ts">
const onOrderSubmit = async () => { if (!selecteAddress.value?.id) { return uni.showToast({ icon: 'none', title: '请选择收货地址' }) } const res = await postMemberOrderAPI({ addressId: selecteAddress.value.id, buyerMessage: buyerMessage.value, deliveryTimeType: activeDelivery.value.type, goods: orderPre.value!.goods.map((v) => ({ count: v.count, skuId: v.skuId })), payChannel: 2, payType: 1 }) uni.redirectTo({ url: `/pagesOrder/detail/detail?id=${res.result.id}` }) } </script>
<template> <view class="button" :class="{ disabled: !selecteAddress?.id }" @tap="onOrderSubmit"> 提交订单 </view> </template>
|
小结一下
订单详情页
需要展示多种订单状态 并实现不同订单状态对应的业务。
静态结构
已完成通过页面参数获取到订单 id 等基础业务,pagesOrder/detail/detail.vue
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639
| <script setup lang="ts"> import { useGuessList } from '@/composables' import { ref } from 'vue'
const { safeAreaInsets } = uni.getSystemInfoSync()
const { guessRef, onScrolltolower } = useGuessList()
const popup = ref<UniHelper.UniPopupInstance>()
const reasonList = ref([ '商品无货', '不想要了', '商品信息填错了', '地址信息填写错误', '商品降价', '其它', ])
const reason = ref('')
const onCopy = (id: string) => { uni.setClipboardData({ data: id }) }
const query = defineProps<{ id: string }>() </script>
<template> <view class="navbar" :style="{ paddingTop: safeAreaInsets?.top + 'px' }"> <view class="wrap"> <navigator v-if="true" open-type="navigateBack" class="back icon-left"></navigator> <navigator v-else url="/pages/index/index" open-type="switchTab" class="back icon-home"> </navigator> <view class="title">订单详情</view> </view> </view> <scroll-view scroll-y class="viewport" id="scroller" @scrolltolower="onScrolltolower"> <template v-if="true"> <view class="overview" :style="{ paddingTop: safeAreaInsets!.top + 20 + 'px' }"> <template v-if="true"> <view class="status icon-clock">等待付款</view> <view class="tips"> <text class="money">应付金额: ¥ 99.00</text> <text class="time">支付剩余</text> 00 时 29 分 59 秒 </view> <view class="button">去支付</view> </template> <template v-else> <view class="status"> 待付款 </view> <view class="button-group"> <navigator class="button" :url="`/pagesOrder/create/create?orderId=${query.id}`" hover-class="none" > 再次购买 </navigator> <view v-if="false" class="button"> 模拟发货 </view> </view> </template> </view> <view class="shipment"> <view v-for="item in 1" :key="item" class="item"> <view class="message"> 您已在广州市天河区黑马程序员完成取件,感谢使用菜鸟驿站,期待再次为您服务。 </view> <view class="date"> 2023-04-14 13:14:20 </view> </view> <view class="locate"> <view class="user"> 张三 13333333333 </view> <view class="address"> 广东省 广州市 天河区 黑马程序员 </view> </view> </view>
<view class="goods"> <view class="item"> <navigator class="navigator" v-for="item in 2" :key="item" :url="`/pages/goods/goods?id=${item}`" hover-class="none" > <image class="cover" src="https://yanxuan-item.nosdn.127.net/c07edde1047fa1bd0b795bed136c2bb2.jpg" ></image> <view class="meta"> <view class="name ellipsis">ins风小碎花泡泡袖衬110-160cm</view> <view class="type">藏青小花, 130</view> <view class="price"> <view class="actual"> <text class="symbol">¥</text> <text>99.00</text> </view> </view> <view class="quantity">x1</view> </view> </navigator> <view class="action" v-if="true"> <view class="button primary">申请售后</view> <navigator url="" class="button"> 去评价 </navigator> </view> </view> <view class="total"> <view class="row"> <view class="text">商品总价: </view> <view class="symbol">99.00</view> </view> <view class="row"> <view class="text">运费: </view> <view class="symbol">10.00</view> </view> <view class="row"> <view class="text">应付金额: </view> <view class="symbol primary">109.00</view> </view> </view> </view>
<view class="detail"> <view class="title">订单信息</view> <view class="row"> <view class="item"> 订单编号: {{ query.id }} <text class="copy" @tap="onCopy(query.id)">复制</text> </view> <view class="item">下单时间: 2023-04-14 13:14:20</view> </view> </view>
<XtxGuess ref="guessRef" />
<view class="toolbar-height" :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }"></view> <view class="toolbar" :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }"> <template v-if="true"> <view class="button primary"> 去支付 </view> <view class="button" @tap="popup?.open?.()"> 取消订单 </view> </template> <template v-else> <navigator class="button secondary" :url="`/pagesOrder/create/create?orderId=${query.id}`" hover-class="none" > 再次购买 </navigator> <view class="button primary"> 确认收货 </view> <view class="button"> 去评价 </view> <view class="button delete"> 删除订单 </view> </template> </view> </template> <template v-else> <PageSkeleton /> </template> </scroll-view> <uni-popup ref="popup" type="bottom" background-color="#fff"> <view class="popup-root"> <view class="title">订单取消</view> <view class="description"> <view class="tips">请选择取消订单的原因:</view> <view class="cell" v-for="item in reasonList" :key="item" @tap="reason = item"> <text class="text">{{ item }}</text> <text class="icon" :class="{ checked: item === reason }"></text> </view> </view> <view class="footer"> <view class="button" @tap="popup?.close?.()">取消</view> <view class="button primary">确认</view> </view> </view> </uni-popup> </template>
<style lang="scss"> page { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.navbar { width: 750rpx; color: #000; position: fixed; top: 0; left: 0; z-index: 9; background-color: transparent;
.wrap { position: relative;
.title { height: 44px; display: flex; justify-content: center; align-items: center; font-size: 32rpx; color: transparent; }
.back { position: absolute; left: 0; height: 44px; width: 44px; font-size: 44rpx; display: flex; align-items: center; justify-content: center; color: #fff; } } }
.viewport { background-color: #f7f7f8; }
.overview { display: flex; flex-direction: column; align-items: center;
line-height: 1; padding-bottom: 30rpx; color: #fff; background-image: url(https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/order_bg.png); background-size: cover;
.status { font-size: 36rpx; }
.status::before { margin-right: 6rpx; font-weight: 500; }
.tips { margin: 30rpx 0; display: flex; font-size: 14px; align-items: center;
.money { margin-right: 30rpx; } }
.button-group { margin-top: 30rpx; display: flex; justify-content: center; align-items: center; }
.button { width: 260rpx; height: 64rpx; margin: 0 10rpx; text-align: center; line-height: 64rpx; font-size: 28rpx; color: #27ba9b; border-radius: 68rpx; background-color: #fff; } }
.shipment { line-height: 1.4; padding: 0 20rpx; margin: 20rpx 20rpx 0; border-radius: 10rpx; background-color: #fff;
.locate, .item { min-height: 120rpx; padding: 30rpx 30rpx 25rpx 75rpx; background-size: 50rpx; background-repeat: no-repeat; background-position: 6rpx center; }
.locate { background-image: url(https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/locate.png);
.user { font-size: 26rpx; color: #444; }
.address { font-size: 24rpx; color: #666; } }
.item { background-image: url(https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/car.png); border-bottom: 1rpx solid #eee; position: relative;
.message { font-size: 26rpx; color: #444; }
.date { font-size: 24rpx; color: #666; } } }
.goods { margin: 20rpx 20rpx 0; padding: 0 20rpx; border-radius: 10rpx; background-color: #fff;
.item { padding: 30rpx 0; border-bottom: 1rpx solid #eee;
.navigator { display: flex; margin: 20rpx 0; }
.cover { width: 170rpx; height: 170rpx; border-radius: 10rpx; margin-right: 20rpx; }
.meta { flex: 1; display: flex; flex-direction: column; justify-content: center; position: relative; }
.name { height: 80rpx; font-size: 26rpx; color: #444; }
.type { line-height: 1.8; padding: 0 15rpx; margin-top: 6rpx; font-size: 24rpx; align-self: flex-start; border-radius: 4rpx; color: #888; background-color: #f7f7f8; }
.price { display: flex; margin-top: 6rpx; font-size: 24rpx; }
.symbol { font-size: 20rpx; }
.original { color: #999; text-decoration: line-through; }
.actual { margin-left: 10rpx; color: #444; }
.text { font-size: 22rpx; }
.quantity { position: absolute; bottom: 0; right: 0; font-size: 24rpx; color: #444; }
.action { display: flex; flex-direction: row-reverse; justify-content: flex-start; padding: 30rpx 0 0;
.button { width: 200rpx; height: 60rpx; text-align: center; justify-content: center; line-height: 60rpx; margin-left: 20rpx; border-radius: 60rpx; border: 1rpx solid #ccc; font-size: 26rpx; color: #444; }
.primary { color: #27ba9b; border-color: #27ba9b; } } }
.total { line-height: 1; font-size: 26rpx; padding: 20rpx 0; color: #666;
.row { display: flex; align-items: center; justify-content: space-between; padding: 10rpx 0; }
.symbol::before { content: '¥'; font-size: 80%; margin-right: 3rpx; }
.primary { color: #cf4444; font-size: 36rpx; } } }
.detail { line-height: 1; padding: 30rpx 20rpx 0; margin: 20rpx 20rpx 0; font-size: 26rpx; color: #666; border-radius: 10rpx; background-color: #fff;
.title { font-size: 30rpx; color: #444; }
.row { padding: 20rpx 0;
.item { padding: 10rpx 0; display: flex; align-items: center; }
.copy { border-radius: 20rpx; font-size: 20rpx; border: 1px solid #ccc; padding: 5rpx 10rpx; margin-left: 10rpx; } } }
.toolbar-height { height: 100rpx; box-sizing: content-box; }
.toolbar { position: fixed; left: 0; right: 0; bottom: calc(var(--window-bottom)); z-index: 1;
height: 100rpx; padding: 0 20rpx; display: flex; align-items: center; flex-direction: row-reverse; border-top: 1rpx solid #ededed; border-bottom: 1rpx solid #ededed; background-color: #fff; box-sizing: content-box;
.button { display: flex; justify-content: center; align-items: center;
width: 200rpx; height: 72rpx; margin-left: 15rpx; font-size: 26rpx; border-radius: 72rpx; border: 1rpx solid #ccc; color: #444; }
.delete { order: 4; }
.button { order: 3; }
.secondary { order: 2; color: #27ba9b; border-color: #27ba9b; }
.primary { order: 1; color: #fff; background-color: #27ba9b; } }
.popup-root { padding: 30rpx 30rpx 0; border-radius: 10rpx 10rpx 0 0; overflow: hidden;
.title { font-size: 30rpx; text-align: center; margin-bottom: 30rpx; }
.description { font-size: 28rpx; padding: 0 20rpx;
.tips { color: #444; margin-bottom: 12rpx; }
.cell { display: flex; justify-content: space-between; align-items: center; padding: 15rpx 0; color: #666; }
.icon::before { content: '\e6cd'; font-family: 'erabbit' !important; font-size: 38rpx; color: #999; }
.icon.checked::before { content: '\e6cc'; font-size: 38rpx; color: #27ba9b; } }
.footer { display: flex; justify-content: space-between; padding: 30rpx 0 40rpx; font-size: 28rpx; color: #444;
.button { flex: 1; height: 72rpx; text-align: center; line-height: 72rpx; margin: 0 20rpx; color: #444; border-radius: 72rpx; border: 1rpx solid #ccc; }
.primary { color: #fff; background-color: #27ba9b; border: none; } } } </style>
|
自定义导航栏交互
导航栏左上角按钮:获取当前页面栈,如果不能返回上一页,按钮变成返回首页。
导航栏动画效果:滚动驱动的动画,根据滚动位置而不断改变动画的进度。
滚动驱动的动画目前只支持微信小程序端,暂不支持网页端。

pages.json
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| { "subPackages": [ { "root": "pagesOrder", "pages": [ { "path": "detail/detail", "style": { "navigationBarTitleText": "订单详情", "navigationStyle": "custom" } } ] } ], }
|
参考代码,pagesOrder/detail/detail.vue
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
| <script setup lang="ts">
const pages = getCurrentPages()
const pageInstance = pages.at(-1) as any
onReady(() => { pageInstance.animate( '.navbar', [{ backgroundColor: 'transparent' }, { backgroundColor: '#f8f8f8' }], 1000, { scrollSource: '#scroller', startScrollOffset: 0, endScrollOffset: 50, timeRange: 1000, }, ) pageInstance.animate('.navbar .title', [{ color: 'transparent' }, { color: '#000' }], 1000, { scrollSource: '#scroller', timeRange: 1000, startScrollOffset: 0, endScrollOffset: 50, }) pageInstance.animate('.navbar .back', [{ color: '#fff' }, { color: '#000' }], 1000, { scrollSource: '#scroller', timeRange: 1000, startScrollOffset: 0, endScrollOffset: 50, }) }) </script>
<template> <view class="navbar" :style="{ paddingTop: safeAreaInsets?.top + 'px' }"> <view class="wrap"> <navigator v-if="pages.length > 1" open-type="navigateBack" class="back icon-left" ></navigator> <navigator v-else url="/pages/index/index" open-type="switchTab" class="back icon-home"> </navigator> <view class="title">订单详情</view> </view> </view> <scroll-view class="viewport" scroll-y enable-back-to-top id="scroller"> ...滚动容器 </scroll-view> </template>
|
小结一下
获取订单详情
- 声明后端返回数据的类型,
src/types/order.d.ts
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| export type OrderResult = { id: string orderState: number countdown: number skus: OrderSkuItem[] receiverContact: string receiverMobile: string receiverAddress: string createTime: string totalMoney: number postFee: number payMoney: number }
export type OrderSkuItem = { id: string spuId: string name: string attrsText: string quantity: number curPrice: number image: string }
|
- 接口封装,
services/order.ts
。
1 2 3 4 5 6 7 8 9 10 11
| import type { OrderResult } from '@/types/order'
export const getMemberOrderByIdAPI = (id: string) => { return http<OrderResult>({ method: 'GET', url: `/member/order/${id}`, }) }
|
- 页面中调用,
pagesOrder/detail/detai.vue
。
1 2 3 4 5 6 7 8 9 10
| import { onLoad } from '@dcloudio/uni-app' import type { OrderResult } from '@/types/order' const order = ref<OrderResult>() const getMemberOrderByIdData = async () => { const res = await getMemberOrderByIdAPI(query.id) order.value = res.result } onLoad(() => { getMemberOrderByIdData() })
|
渲染订单状态
基本内容
在订单详情中除了展示订单信息外,还需要根据不同订单状态展示不同的内容。
订单状态(orderState) |
含义 |
1 |
待付款 |
2 |
待发货 |
3 |
待收货 |
4 |
待评价 |
5 |
已完成 |
6 |
已取消 |
枚举的作用:通过枚举来替代无意义的订单状态数字,提高程序的可读性。
- 定义枚举,
src/services/constants.ts
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| export enum OrderState { DaiFuKuan = 1, DaiFaHuo = 2, DaiShouHuo = 3, DaiPingJia = 4, YiWanCheng = 5, YiQuXiao = 6, }
export const orderStateList = [ { id: 0, text: '' }, { id: 1, text: '待付款' }, { id: 2, text: '待发货' }, { id: 3, text: '待收货' }, { id: 4, text: '待评价' }, { id: 5, text: '已完成' }, { id: 6, text: '已取消' }, ]
|
- 根据后端返回的数据渲染订单详情,
pagesOrder/detail/detail.vue
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
| <template v-if="order"> <view class="overview" :style="{ paddingTop: safeAreaInsets!.top + 20 + 'px' }" > <template v-if="order.orderState === OrderState.DaiFuKuan"> <view class="status icon-clock">等待付款</view> <view class="tips"> <text class="money">应付金额: ¥ 99.00</text> <text class="time">支付剩余</text> 00 时 29 分 59 秒 </view> <view class="button">去支付</view> </template> <template v-else> <view class="status"> {{ orderStateList[order.orderState].text }} </view> <view class="button-group"> <navigator class="button" :url="`/pagesOrder/create/create?orderId=${query.id}`" hover-class="none" > 再次购买 </navigator> <view v-if="false" class="button"> 模拟发货 </view> </view> </template> </view>
</template>
|
小结一下
支付倒计时
参考代码
通过 uni-ui 组件库的 uni-countdown 实现倒计时。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| <script setup lang="ts">
const onTimeup = () => { order.value!.orderState = OrderState.YiQuXiao } </script>
<template> <template v-if="order.orderState === OrderState.DaiFuKuan"> <view class="status icon-clock">等待付款</view> <view class="tips"> <text class="money">应付金额: ¥ 99.00</text> <text class="time">支付剩余</text> <uni-countdown :second="order.countdown" color="#fff" splitor-color="#fff" :show-day="false" :show-colon="false" @timeup="onTimeup" /> </view> <view class="button">去支付</view> </template> </template>
|
小结一下
订单支付
订单支付其实就是根据订单号查询到支付信息,在小程序中调用微信支付的 API 而已。
微信支付说明
由于微信支付的限制,仅 appid 为 wx26729f20b9efae3a
的开发者才能调用该接口。此外,开发者还需要微信授权登录。
对于其他开发者,可以使用模拟支付接口进行开发测试,调用后,订单状态将自动更新为已支付。
调用接口
services/pay.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| import { http } from '@/utils/http'
export const getPayWxPayMiniPayAPI = (data: { orderId: string }) => { return http<WechatMiniprogram.RequestPaymentOption>({ method: 'GET', url: '/pay/wxPay/miniPay', data, }) }
export const getPayMockAPI = (data: { orderId: string }) => { return http({ method: 'GET', url: '/pay/mock', data, }) }
|
参考代码
通过环境变量区分开发环境,调用不同接口,pagesOrder/detail/detail.vue
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| <script setup lang="ts"> import { getPayMockAPI, getPayWxPayMiniPayAPI } from '@/services/pay'
const onOrderPay = async () => { if (import.meta.env.DEV) { await getPayMockAPI({ orderId: query.id }) } else { const res = await getPayWxPayMiniPayAPI({ orderId: query.id }) await wx.requestPayment(res.result) } uni.redirectTo({ url: `/pagesOrder/payment/payment?id=${query.id}` }) } </script>
<template> <view class="button" @tap="onOrderPay">去支付</view> </template>
|
注意真实支付,需要修改 pages/login/login.vue
中的登录方式。
1 2 3 4 5 6 7 8
| <button class="button phone" open-type="getPhoneNumber" @getphonenumber="onGetphonenumber" > <text class="icon icon-phone"></text> 手机号快捷登录 </button>
|
支付成功页
主要用于展示支付结果,src/pagesOrder/payment/payment.vue
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95
| <script setup lang="ts"> import { useGuessList } from '@/composables'
const query = defineProps<{ id: string }>()
const { guessRef, onScrolltolower } = useGuessList() </script>
<template> <scroll-view class="viewport" scroll-y @scrolltolower="onScrolltolower"> <view class="overview"> <view class="status icon-checked">支付成功</view> <view class="buttons"> <navigator hover-class="none" class="button navigator" url="/pages/index/index" open-type="switchTab" > 返回首页 </navigator> <navigator hover-class="none" class="button navigator" :url="`/pagesOrder/detail/detail?id=${query.id}`" open-type="redirect" > 查看订单 </navigator> </view> </view>
<XtxGuess ref="guessRef" /> </scroll-view> </template>
<style lang="scss"> page { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.viewport { background-color: #f7f7f8; }
.overview { line-height: 1; padding: 50rpx 0; color: #fff; background-color: #27ba9b;
.status { font-size: 36rpx; font-weight: 500; text-align: center; }
.status::before { display: block; font-size: 110rpx; margin-bottom: 20rpx; }
.buttons { height: 60rpx; line-height: 60rpx; display: flex; justify-content: center; align-items: center; margin-top: 60rpx; }
.button { text-align: center; margin: 0 10rpx; font-size: 28rpx; color: #fff;
&:first-child { width: 200rpx; border-radius: 64rpx; border: 1rpx solid #fff; } } } </style>
|
小结一下
模拟发货
基本内容
仅在订单状态为待发货时,可模拟发货,调用后订单状态修改为待收货,包含模拟物流。
仅在开发期间使用,项目上线后应该是由商家发货。
接口封装,src/services/order.ts
。
1 2 3 4 5 6 7 8 9 10 11
|
export const getMemberOrderConsignmentByIdAPI = (id: string) => { return http({ method: 'GET', url: `/member/order/consignment/${id}`, }) }
|
参考代码,pagesOrder/detail/detail.vue
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| <script setup lang="ts"> import { getMemberOrderConsignmentByIdAPI } from '@/services/order'
const isDev = import.meta.env.DEV
const onOrderSend = async () => { if (isDev) { await getMemberOrderConsignmentByIdAPI(query.id) uni.showToast({ icon: 'success', title: '模拟发货完成' }) order.value!.orderState = OrderState.DaiShouHuo } } </script>
<template> <view v-if="isDev && order.orderState == OrderState.DaiFaHuo" @tap="onOrderSend" class="button"> 模拟发货 </view> </template>
|
小结一下
确认收货
基本内容
点击确认收货时需二次确认,提示文案:为保障您的权益,请收到货并确认无误后,再确认收货
接口封装,services/order.ts
。
1 2 3 4 5 6 7 8 9 10 11
|
export const putMemberOrderReceiptByIdAPI = (id: string) => { return http<OrderResult>({ method: 'PUT', url: `/member/order/${id}/receipt`, }) }
|
参考代码,pagesOrder/detail/detail.vue
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| const onOrderConfirm = () => { uni.showModal({ content: '为保障您的权益,请收到货并确认无误后,再确认收货', success: async (success) => { if (success.confirm) { const res = await putMemberOrderReceiptByIdAPI(query.id) order.value = res.result } }, }) }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| <view class="button-group"> <navigator class="button" :url="`/pagesOrder/create/create?orderId=${query.id}`" hover-class="none" > 再次购买 </navigator> <view v-if="isDev && order.orderState === OrderState.DaiFaHuo" class="button" @tap="onOrderSend" > 模拟发货 </view> <view v-if="order.orderState === OrderState.DaiShouHuo" @tap="onOrderConfirm" class="button" > 确认收货 </view> </view>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| <view class="toolbar" :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }" > <template v-if="order.orderState === OrderState.DaiFuKuan"> <view class="button primary" @tap="onOrderPay"> 去支付 </view> <view class="button" @tap="popup?.open?.()"> 取消订单 </view> </template> <template v-else> <navigator class="button secondary" :url="`/pagesOrder/create/create?orderId=${query.id}`" hover-class="none" > 再次购买 </navigator> <view class="button primary" v-if="order.orderState === OrderState.DaiShouHuo" @tap="onOrderConfirm" > 确认收货 </view> <view class="button"> 去评价 </view> <view class="button delete"> 删除订单 </view> </template> </view>
|
小结一下
订单物流
仅在订单状态为待收货,待评价,已完成时,可获取物流信息。
请求封装
types/order.d.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| export type OrderLogisticResult = { company: { name: string number: string tel: string } count: number list: LogisticItem[] }
export type LogisticItem = { id: string text: string time: string }
|
services/order.ts
1 2 3 4 5 6 7 8 9 10 11 12
| import type { OrderLogisticResult } from '@/types/order'
export const getMemberOrderLogisticsByIdAPI = (id: string) => { return http<OrderLogisticResult>({ method: 'GET', url: `/member/order/${id}/logistics`, }) }
|
页面中调用,pagesOrder/detail/detail.vue
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| const order = ref<OrderResult>()
const logisticList = ref<LogisticItem[]>([])
const getMemberOrderLogisticsByIdData = async () => { const res = await getMemberOrderLogisticsByIdAPI(query.id) logisticList.value = res.result.list } const getMemberOrderByIdData = async () => { const res = await getMemberOrderByIdAPI(query.id) order.value = res.result if ( [ OrderState.DaiShouHuo, OrderState.DaiPingJia, OrderState.YiWanCheng ].includes(order.value.orderState) ) { getMemberOrderLogisticsByIdData() } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| <view class="shipment"> <view v-for="item in logisticList" :key="item.id" class="item"> <view class="message"> {{ item.text }} </view> <view class="date"> {{ item.time }} </view> </view> <view class="locate"> <view class="user"> {{ order.receiverContact }} {{ order.receiverMobile }} </view> <view class="address"> {{ order.receiverAddress }} </view> </view> </view>
|
小结一下
删除订单
仅在订单状态为待评价,已完成,已取消时,可删除订单。
接口封装
1 2 3 4 5 6 7 8 9 10 11 12
|
export const deleteMemberOrderAPI = (data: { ids: string[] }) => { return http({ method: 'DELETE', url: `/member/order`, data, }) }
|
代码展示
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409
| <script setup lang="ts"> import { useGuessList } from '@/composables' import { getMemberOrderByIdAPI } from '@/services/order' import type { OrderResult } from '@/types/order' import { onLoad, onReady } from '@dcloudio/uni-app' import { ref } from 'vue' import { OrderState, orderStateList } from '@/services/constants' import { getPayMockAPI, getPayWxPayMiniPayAPI } from '@/services/pay' import { getMemberOrderConsignmentByIdAPI, putMemberOrderReceiptByIdAPI } from '@/services/order' import type { LogisticItem } from '@/types/order' import { getMemberOrderLogisticsByIdAPI, deleteMemberOrderAPI } from '@/services/order'
const { safeAreaInsets } = uni.getSystemInfoSync()
const { guessRef, onScrolltolower } = useGuessList()
const popup = ref<UniHelper.UniPopupInstance>()
const reasonList = ref([ '商品无货', '不想要了', '商品信息填错了', '地址信息填写错误', '商品降价', '其它' ])
const reason = ref('')
const onCopy = (id: string) => { uni.setClipboardData({ data: id }) }
const query = defineProps<{ id: string }>()
const order = ref<OrderResult>() const logisticList = ref<LogisticItem[]>([]) const getMemberOrderLogisticsByIdData = async () => { const res = await getMemberOrderLogisticsByIdAPI(query.id) logisticList.value = res.result.list } const getMemberOrderByIdData = async () => { const res = await getMemberOrderByIdAPI(query.id) order.value = res.result if ( [ OrderState.DaiShouHuo, OrderState.DaiPingJia, OrderState.YiWanCheng ].includes(order.value.orderState) ) { getMemberOrderLogisticsByIdData() } } onLoad(() => { getMemberOrderByIdData() })
const pages = getCurrentPages()
const pageInstance = pages.at(-1) as any
onReady(() => { pageInstance.animate( '.navbar', [{ backgroundColor: 'transparent' }, { backgroundColor: '#f8f8f8' }], 1000, { scrollSource: '#scroller', startScrollOffset: 0, endScrollOffset: 50, timeRange: 1000 } ) pageInstance.animate( '.navbar .title', [{ color: 'transparent' }, { color: '#000' }], 1000, { scrollSource: '#scroller', timeRange: 1000, startScrollOffset: 0, endScrollOffset: 50 } ) pageInstance.animate( '.navbar .back', [{ color: '#fff' }, { color: '#000' }], 1000, { scrollSource: '#scroller', timeRange: 1000, startScrollOffset: 0, endScrollOffset: 50 } ) })
const onOrderPay = async () => { if (import.meta.env.DEV) { await getPayMockAPI({ orderId: query.id }) } else { const res = await getPayWxPayMiniPayAPI({ orderId: query.id }) await wx.requestPayment(res.result) } uni.redirectTo({ url: `/pagesOrder/payment/payment?id=${query.id}` }) } const isDev = import.meta.env.DEV const onOrderSend = async () => { if (isDev) { await getMemberOrderConsignmentByIdAPI(query.id) uni.showToast({ icon: 'success', title: '模拟发货完成' }) order.value!.orderState = OrderState.DaiShouHuo } } const onOrderConfirm = () => { uni.showModal({ content: '为保障您的权益,请收到货并确认无误后,再确认收货', success: async (success) => { if (success.confirm) { const res = await putMemberOrderReceiptByIdAPI(query.id) order.value = res.result } } }) }
const onTimeup = () => { order.value!.orderState = OrderState.YiQuXiao }
const onOrderDelete = () => { uni.showModal({ content: '是否删除订单', success: async (success) => { if (success.confirm) { await deleteMemberOrderAPI({ ids: [query.id] }) uni.redirectTo({ url: '/pagesOrder/list/list' }) } } }) } </script>
<template> <view class="navbar" :style="{ paddingTop: safeAreaInsets?.top + 'px' }"> <view class="wrap"> <navigator v-if="pages.length > 1" open-type="navigateBack" class="back icon-left" ></navigator> <navigator v-else url="/pages/index/index" open-type="switchTab" class="back icon-home" > </navigator> <view class="title">订单详情</view> </view> </view> <scroll-view scroll-y class="viewport" id="scroller" @scrolltolower="onScrolltolower" > <template v-if="order"> <view class="overview" :style="{ paddingTop: safeAreaInsets!.top + 20 + 'px' }" > <template v-if="order.orderState === OrderState.DaiFuKuan"> <view class="status icon-clock">等待付款</view> <view class="tips"> <text class="money">应付金额: ¥ {{ order.payMoney }}</text> <text class="time">支付剩余</text> <uni-countdown :second="order.countdown" color="#fff" splitor-color="#fff" :show-day="false" :show-colon="false" @timeup="onTimeup" /> </view> <view class="button" @tap="onOrderPay">去支付</view> </template> <template v-else> <view class="status"> {{ orderStateList[order.orderState].text }} </view> <view class="button-group"> <navigator class="button" :url="`/pagesOrder/create/create?orderId=${query.id}`" hover-class="none" > 再次购买 </navigator> <view v-if="isDev && order.orderState === OrderState.DaiFaHuo" class="button" @tap="onOrderSend" > 模拟发货 </view> <view v-if="order.orderState === OrderState.DaiShouHuo" @tap="onOrderConfirm" class="button" > 确认收货 </view> </view> </template> </view> <view class="shipment"> <view v-for="item in logisticList" :key="item.id" class="item"> <view class="message"> {{ item.text }} </view> <view class="date"> {{ item.time }} </view> </view> <view class="locate"> <view class="user"> {{ order.receiverContact }} {{ order.receiverMobile }} </view> <view class="address"> {{ order.receiverAddress }} </view> </view> </view>
<view class="goods"> <view class="item"> <navigator class="navigator" v-for="item in order.skus" :key="item.id" :url="`/pages/goods/goods?id=${item.spuId}`" hover-class="none" > <image class="cover" :src="item.image"></image> <view class="meta"> <view class="name ellipsis">{{ item.name }}</view> <view class="type">{{ item.attrsText }}</view> <view class="price"> <view class="actual"> <text class="symbol">¥</text> <text>{{ item.curPrice }}</text> </view> </view> <view class="quantity">x{{ item.quantity }}</view> </view> </navigator> <view class="action" v-if="order.orderState === OrderState.DaiPingJia" > <view class="button primary">申请售后</view> <navigator url="" class="button"> 去评价 </navigator> </view> </view> <view class="total"> <view class="row"> <view class="text">商品总价: </view> <view class="symbol">{{ order.totalMoney }}</view> </view> <view class="row"> <view class="text">运费: </view> <view class="symbol">{{ order.postFee }}</view> </view> <view class="row"> <view class="text">应付金额: </view> <view class="symbol primary">{{ order.payMoney }}</view> </view> </view> </view>
<view class="detail"> <view class="title">订单信息</view> <view class="row"> <view class="item"> 订单编号: {{ query.id }} <text class="copy" @tap="onCopy(query.id)">复制</text> </view> <view class="item">下单时间: {{ order.createTime }}</view> </view> </view>
<XtxGuess ref="guessRef" /> <view class="toolbar-height" :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }" ></view> <view class="toolbar" :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }" > <template v-if="order.orderState === OrderState.DaiFuKuan"> <view class="button primary" @tap="onOrderPay"> 去支付 </view> <view class="button" @tap="popup?.open?.()"> 取消订单 </view> </template> <template v-else> <navigator class="button secondary" :url="`/pagesOrder/create/create?orderId=${query.id}`" hover-class="none" > 再次购买 </navigator> <view class="button primary" v-if="order.orderState === OrderState.DaiShouHuo" @tap="onOrderConfirm" > 确认收货 </view> <view class="button" v-if="order.orderState === OrderState.DaiPingJia" > 去评价 </view> <view class="button delete" v-if="order.orderState >= OrderState.DaiPingJia" @tap="onOrderDelete" > 删除订单 </view> </template> </view> </template> <template v-else> <PageSkeleton /> </template> </scroll-view> <uni-popup ref="popup" type="bottom" background-color="#fff"> <view class="popup-root"> <view class="title">订单取消</view> <view class="description"> <view class="tips">请选择取消订单的原因:</view> <view class="cell" v-for="item in reasonList" :key="item" @tap="reason = item" > <text class="text">{{ item }}</text> <text class="icon" :class="{ checked: item === reason }"></text> </view> </view> <view class="footer"> <view class="button" @tap="popup?.close?.()">取消</view> <view class="button primary">确认</view> </view> </view> </uni-popup> </template>
|
小结一下
再次购买
现在是第三种生成订单信息,从订单详情页的【再次购买】按钮跳转到填写订单页,需要传递页面参数。
- 订单详情页,
pagesOrder/detail/detail.vue
。
1 2 3 4 5 6 7 8 9
| <template> <navigator class="button" hover-class="none" :url="`/pagesOrder/create/create?orderId=${query.id}`" > 再次购买 </navigator> </template>
|
- 封装接口,
services/order.ts
。
1 2 3 4 5 6 7 8 9 10
|
export const getMemberOrderRepurchaseByIdAPI = (id: string) => { return http<OrderPreResult>({ method: 'GET', url: `/member/order/repurchase/${id}`, }) }
|
- 填写订单页,
pagesOrder/create/create.vue
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| <script setup lang="ts">
const query = defineProps<{ skuId?: string count?: string orderId?: string }>()
const orderPre = ref<OrderPreResult>() const getMemberOrderPreData = async () => { if (query.count && query.skuId) { const res = await getMemberOrderPreNowAPI({ count: query.count, skuId: query.skuId, }) orderPre.value = res.result } else if (query.orderId) { const res = await getMemberOrderRepurchaseByIdAPI(query.orderId) orderPre.value = res.result } else { const res = await getMemberOrderPreAPI() orderPre.value = res.result } } </script>
|
取消订单
仅在订单状态为待付款时,可取消订单。
- 接口封装,
services/order.ts
。
1 2 3 4 5 6 7 8 9 10 11 12 13
|
export const getMemberOrderCancelByIdAPI = (id: string, data: { cancelReason: string }) => { return http<OrderResult>({ method: 'PUT', url: `/member/order/${id}/cancel`, data, }) }
|
- 绑定点击事件,
pagesOrder/detail/detail.vue
。
1 2 3 4
| <view class="footer"> <view class="button" @tap="popup?.close?.()">取消</view> <view class="button primary" @tap="onOrderCancel">确认</view> </view>
|
- 代码逻辑。
1 2 3 4 5 6 7 8 9 10 11 12
| const onOrderCancel = async () => { const res = await getMemberOrderCancelByIdAPI(query.id, { cancelReason: reason.value }) order.value = res.result popup.value?.close!() uni.showToast({ icon: 'none', title: '订单取消成功' }) }
|
订单列表页
根据订单的不同状态展示订单列表,并实现多 Tabs 分页加载。

静态结构
pagesOrder/list/list.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304
| <script setup lang="ts"> import { ref } from 'vue'
const { safeAreaInsets } = uni.getSystemInfoSync()
const orderTabs = ref([ { orderState: 0, title: '全部' }, { orderState: 1, title: '待付款' }, { orderState: 2, title: '待发货' }, { orderState: 3, title: '待收货' }, { orderState: 4, title: '待评价' }, ]) </script>
<template> <view class="viewport"> <view class="tabs"> <text class="item" v-for="item in 5" :key="item"> 待付款 </text> <view class="cursor" :style="{ left: 0 * 20 + '%' }"></view> </view> <swiper class="swiper"> <swiper-item v-for="item in 5" :key="item"> <scroll-view scroll-y class="orders"> <view class="card" v-for="item in 2" :key="item"> <view class="status"> <text class="date">2023-04-14 13:14:20</text> <text>待付款</text> <text class="icon-delete"></text> </view> <navigator v-for="sku in 2" :key="sku" class="goods" :url="`/pagesOrder/detail/detail?id=1`" hover-class="none" > <view class="cover"> <image mode="aspectFit" src="https://yanxuan-item.nosdn.127.net/c07edde1047fa1bd0b795bed136c2bb2.jpg" ></image> </view> <view class="meta"> <view class="name ellipsis">ins风小碎花泡泡袖衬110-160cm</view> <view class="type">藏青小花 130</view> </view> </navigator> <view class="payment"> <text class="quantity">共5件商品</text> <text>实付</text> <text class="amount"> <text class="symbol">¥</text>99</text> </view> <view class="action"> <template v-if="true"> <view class="button primary">去支付</view> </template> <template v-else> <navigator class="button secondary" :url="`/pagesOrder/create/create?orderId=id`" hover-class="none" > 再次购买 </navigator> <view v-if="false" class="button primary">确认收货</view> </template> </view> </view> <view class="loading-text" :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }"> {{ true ? '没有更多数据~' : '正在加载...' }} </view> </scroll-view> </swiper-item> </swiper> </view> </template>
<style lang="scss"> page { height: 100%; overflow: hidden; }
.viewport { height: 100%; display: flex; flex-direction: column; background-color: #fff; }
// tabs .tabs { display: flex; justify-content: space-around; line-height: 60rpx; margin: 0 10rpx; background-color: #fff; box-shadow: 0 4rpx 6rpx rgba(240, 240, 240, 0.6); position: relative; z-index: 9;
.item { flex: 1; text-align: center; padding: 20rpx; font-size: 28rpx; color: #262626; }
.cursor { position: absolute; left: 0; bottom: 0; width: 20%; height: 6rpx; padding: 0 50rpx; background-color: #27ba9b; transition: all 0.4s; } }
// swiper .swiper { background-color: #f7f7f8; }
// 订单列表 .orders { .card { min-height: 100rpx; padding: 20rpx; margin: 20rpx 20rpx 0; border-radius: 10rpx; background-color: #fff;
&:last-child { padding-bottom: 40rpx; } }
.status { display: flex; align-items: center; justify-content: space-between; font-size: 28rpx; color: #999; margin-bottom: 15rpx;
.date { color: #666; flex: 1; }
.primary { color: #ff9240; }
.icon-delete { line-height: 1; margin-left: 10rpx; padding-left: 10rpx; border-left: 1rpx solid #e3e3e3; } }
.goods { display: flex; margin-bottom: 20rpx;
.cover { width: 170rpx; height: 170rpx; margin-right: 20rpx; border-radius: 10rpx; overflow: hidden; position: relative; }
.quantity { position: absolute; bottom: 0; right: 0; line-height: 1; padding: 6rpx 4rpx 6rpx 8rpx; font-size: 24rpx; color: #fff; border-radius: 10rpx 0 0 0; background-color: rgba(0, 0, 0, 0.6); }
.meta { flex: 1; display: flex; flex-direction: column; justify-content: center; }
.name { height: 80rpx; font-size: 26rpx; color: #444; }
.type { line-height: 1.8; padding: 0 15rpx; margin-top: 10rpx; font-size: 24rpx; align-self: flex-start; border-radius: 4rpx; color: #888; background-color: #f7f7f8; }
.more { flex: 1; display: flex; align-items: center; justify-content: center; font-size: 22rpx; color: #333; } }
.payment { display: flex; justify-content: flex-end; align-items: center; line-height: 1; padding: 20rpx 0; text-align: right; color: #999; font-size: 28rpx; border-bottom: 1rpx solid #eee;
.quantity { font-size: 24rpx; margin-right: 16rpx; }
.amount { color: #444; margin-left: 6rpx; }
.symbol { font-size: 20rpx; } }
.action { display: flex; justify-content: flex-end; align-items: center; padding-top: 20rpx;
.button { width: 180rpx; height: 60rpx; display: flex; justify-content: center; align-items: center; margin-left: 20rpx; border-radius: 60rpx; border: 1rpx solid #ccc; font-size: 26rpx; color: #444; }
.secondary { color: #27ba9b; border-color: #27ba9b; }
.primary { color: #fff; background-color: #27ba9b; } }
.loading-text { text-align: center; font-size: 28rpx; color: #666; padding: 20rpx 0; } } </style>
|
Tabs 滑动切换
订单列表的 Tabs 支持滑动切换,从【我的】进入订单列表,能高亮对应的下标。
- 循环标题和对应的轮播,点击高亮并滑动对应的轮播图,
pagesOder/list/list.vue
。
1
| const activeIndex = ref(0)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| <view class="tabs"> <text class="item" v-for="(item, index) in orderTabs" :key="item.title" @tap="activeIndex = index" > {{ item.title }} </text> <view class="cursor" :style="{ left: activeIndex * 20 + '%' }"></view> </view>
|
1 2
| <swiper-item v-for="item in orderTabs" :key="item.title"></swiper-item>
|
1 2
| <swiper :current="activeIndex" class="swiper">
|
- 滑动轮播图也高亮对应的 Tab。
1
| <swiper :current="activeIndex" class="swiper" @change="activeIndex = $event.detail.current">
|
小结一下
跳转 Tab 页面高亮
从【我的】页面跳过来高亮对应的 Tab。
1 2 3 4 5 6 7 8 9 10
| const query = defineProps<{ type: string }>()
const activeIndex = ref( orderTabs.value.findIndex((v) => v.orderState === +query.type) )
|
pagesOrder/list/list.vue
1 2 3 4
| const query = defineProps<{ type: string }>() const activeIndex = ref(orderTabs.value.findIndex(v => v.orderState === Number(query.type)))
|
获取订单列表
当前页面是多 Tabs 列表的情况,每个 Tabs 都是独立的列表,并支持分页加载。
- 定义类型,
types/order.d.ts
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| import type { PageParams } from '@/types/global'
export type OrderListParams = PageParams & { orderState: number }
export type OrderListResult = { counts: number items: OrderItem[] page: number pages: number pageSize: number }
export type OrderItem = OrderResult & { totalNum: number }
|
- 接口封装,
services/order.ts
。
1 2 3 4 5 6 7 8 9 10 11 12
| import type { OrderListParams, OrderListResult } from '@/types/order'
export const getMemberOrderAPI = (data: OrderListParams) => { return http<OrderListResult>({ method: 'GET', url: `/member/order`, data, }) }
|
订单列表渲染
为了更好维护多 Tabs 列表,把列表抽离成业务组件,在组件内部独立维护列表数据,包括分页,下拉刷新等业务。
- 封装列表组件,
pagesOrder/list/components/OrderList.vue
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95
| <script setup lang="ts"> import { OrderState } from '@/services/constants' import { orderStateList } from '@/services/constants' import { getMemberOrderAPI } from '@/services/order' import type { OrderItem } from '@/types/order' import type { OrderListParams } from '@/types/order' import { onMounted, ref } from 'vue'
const { safeAreaInsets } = uni.getSystemInfoSync()
const props = defineProps<{ orderState: number }>()
const queryParams: OrderListParams = { page: 1, pageSize: 5, orderState: props.orderState, }
const orderList = ref<OrderItem[]>([]) const getMemberOrderData = async () => { const res = await getMemberOrderAPI(queryParams) orderList.value = res.result.items }
onMounted(() => { getMemberOrderData() }) </script>
<template> <scroll-view scroll-y class="orders"> <view class="card" v-for="order in orderList" :key="order.id"> <view class="status"> <text class="date">{{ order.createTime }}</text> <text>{{ orderStateList[order.orderState].text }}</text> <text v-if="order.orderState >= OrderState.DaiPingJia" class="icon-delete"></text> </view> <navigator v-for="item in order.skus" :key="item.id" class="goods" :url="`/pagesOrder/detail/detail?id=${order.id}`" hover-class="none" > <view class="cover"> <image mode="aspectFit" :src="item.image"></image> </view> <view class="meta"> <view class="name ellipsis">{{ item.name }}</view> <view class="type">{{ item.attrsText }}</view> </view> </navigator> <view class="payment"> <text class="quantity">共{{ order.totalNum }}件商品</text> <text>实付</text> <text class="amount"> <text class="symbol">¥</text>{{ order.payMoney }}</text> </view> <view class="action"> <template v-if="order.orderState === OrderState.DaiFuKuan"> <view class="button primary">去支付</view> </template> <template v-else> <navigator class="button secondary" :url="`/pagesOrder/create/create?orderId=id`" hover-class="none" > 再次购买 </navigator> <view v-if="order.orderState === OrderState.DaiShouHuo" class="button primary" >确认收货</view > </template> </view> </view> <view class="loading-text" :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }"> {{ true ? '没有更多数据~' : '正在加载...' }} </view> </scroll-view> </template>
|
- 订单列表页,把订单状态传递给列表组件(父传子),
pagesOrder/list/list.vue
。
1 2
| import OrderList from './components/OrderList.vue'
|
1 2 3 4 5 6 7
| <swiper class="swiper" :current="activeIndex" @change="activeIndex = $event.detail.current"> <swiper-item v-for="item in orderTabs" :key="item.title"> <OrderList :order-state="item.orderState" /> </swiper-item> </swiper>
|
小结一下
分页加载
分页加载的逻辑在之前我们已经学习过,本节就不再重复讲义的内容了,下拉刷新业务同理。
订单支付
订单支付功能之前我们已经学习过,也不再重复,确认收货,删除订单等按钮的业务同理。
pagesOrder/list/components/OrderList.vue
1 2 3 4 5 6 7 8 9 10 11
| const onOrderPay = async (id: string) => { if (import.meta.env.DEV) { await getPayMockAPI({ orderId: id }) } else { const res = await getPayWxPayMiniPayAPI({ orderId: id }) wx.requestPayment(res.result) } uni.showToast({ title: '支付成功' }) const order = orderList.value.find(v => v.id === id) order!.orderState = OrderState.DaiFaHuo }
|
1
| <view class="button primary" @tap="onOrderPay(order.id)">去支付</view>
|
1 2 3 4 5 6 7
| <navigator class="button secondary" :url="`/pagesOrder/create/create?orderId=${order.id}`" hover-class="none" > 再次购买 </navigator>
|
微信小程序端
把当前 uni-app 项目打包成微信小程序端,并发布上线。
核心步骤
运行打包命令 pnpm build:mp-weixin
。
预览和测试,微信开发者工具导入生成的 /dist/build/mp-weixin
目录。
上传小程序代码。
提交审核和发布。
步骤图示
项目打包上线需要使用到多个工具,注意工具之间的职责。
1
| VSCode ----> 微信开发者工具 ----> 微信公众平台
|
了解:开发者可独立使用 miniprogram-ci 进行小程序代码的上传等操作!
打包成其他小程序端的步骤类似,只是更换了 打包命令 和 开发者工具。
小结一下
条件编译
基本了解
Q:按照 uni-app 规范开发可保证多平台兼容,但每个平台有自己的一些特性,该如何处理?
A:通过条件编译,让代码按条件编译到指定平台。

网页端不支持微信平台授权登录等功能,可通过 条件编译,实现不同端渲染不同的登录界面。
条件编译语法
通过特殊注释,以 #ifdef
或 #ifndef
加 平台名称 开头,以 #endif
结尾,支持 vue、ts、js、scss、css、pages.json
等文件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| <script setup lang="ts">
wx.login() wx.requestPayment()
</script>
<template> <button open-type="openSetting">授权管理</button> <button open-type="feedback">问题反馈</button> <button open-type="contact">联系我们</button> </template>
<style>
page { background-color: pink; } </style>
|
了解:#ifdef H5 || MP-WEIXIN
表示在 H5 平台 或 微信小程序平台存在的代码。
可通过搜索 wx.
和 open-type
等平台关键词,快速查找需要添加编译模式的代码。
操作代码
pnpm dev:h5
pages/login/login.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103
| <script setup lang="ts"> import { onLoad } from '@dcloudio/uni-app' import { postLoginWxMinAPI, postLoginWxMinSimpleAPI } from '@/services/login' import type { LoginResult } from '@/types/member' import { useMemberStore } from '@/stores'
let code = '' onLoad(async () => { const res = await uni.login() code = res.code })
const loginSuccess = (profile: LoginResult) => { const memberStore = useMemberStore() memberStore.setProfile(profile) uni.showToast({ icon: 'success', title: '登录成功' }) setTimeout(() => { uni.navigateBack() }, 500) }
const onGetphonenumber: UniHelper.ButtonOnGetphonenumber = async (ev) => { const encryptedData = ev.detail.encryptedData! const iv = ev.detail.iv! const res = await postLoginWxMinAPI({ code, encryptedData, iv }) loginSuccess(res.result) }
const onGetphonenumberSimple = async () => { const res = await postLoginWxMinSimpleAPI('13123456789') loginSuccess(res.result) }
</script> <template> <view class="viewport"> <view class="logo"> <image src="https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/logo_icon.png" ></image> </view> <view class="login"> <input type="text" class="input" placeholder="请输入用户名/手机号码" /> <input type="text" class="input" password placeholder="请输入密码" /> <button class="button phone">登录</button>
<button class="button phone" open-type="getPhoneNumber" @getphonenumber="onGetphonenumber" > <text class="icon icon-phone"></text> 手机号快捷登录 </button> <view class="extra"> <view class="caption"> <text>其他登录方式</text> </view> <view class="options"> <button> <text class="icon icon-phone">模拟快捷登录</text> </button> </view> </view> <view class="tips" >登录/注册即视为你同意《服务条款》和《小兔鲜儿隐私协议》</view > </view> </view> </template>
<style lang="scss"> // ... .login { display: flex; flex-direction: column; height: 60vh; padding: 40rpx 20rpx 20rpx; .input { width: 100%; height: 80rpx; font-size: 28rpx; border-radius: 72rpx; border: 1px solid #ddd; padding-left: 30rpx; margin-bottom: 20rpx; } // ... } </style>
|
打包为 H5 端
把当前 uni-app 项目打包成网页(H5)端,并配置路由基础路径。
核心步骤
运行打包命令 pnpm build:h5
。
预览和测试,使用浏览器打开 /dist/build/h5
目录下的 index.html
文件。
由运维部署到服务器。
路由基础路径
默认的路由基础路径为 /
根路径,部分网站并不是部署到根路径,需要按运维要求调整,manifest.json
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| {
"mp-weixin": { }, "h5": { "router": { "base": "./" } } "vueVersion": "3" }
|
小结一下
打包为 APP 端
App 端 的打包,预览、测试、发行,使用 HBuilderX
工具。
login.vue