电商小程序

一、效果展示


在线预览:











二、搭建项目环境


  1. 新建项目,选择uni-app的默认模板即可

  1. 在manifest.json中设置小程序的APPID

  1. 由于小程序项目不支持axios,原生的wx.request不支持拦截器,所以这里使用的第三方包@escook/request-miniprogram

安装方法:

1
npm i @escook/request-miniprogram
  1. 从原项目中复制图片资源到static文件夹下面

  2. 在uni.scss中定义全局颜色:

1
$main-color: #C00000;

三、TabBar和导航条


  1. tabBar

在pages文件夹下面创建四个页面home.vue、cate.vue、cart.vue、my.vue,具体配置如下:

在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
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
"tabBar": {

"selectedColor": "#C00000",

"list": [{

"path": "pages/home/home.vue",

"text": "首页",

"iconPath": "static/tab_icons/home.png",

"selectedIconPath": "static/tab_icons/home-active.png"

}

,

{

"path": "pages/cate/cate.vue",

"text": "分类",

"iconPath": "static/tab_icons/cate.png",

"selectedIconPath": "static/tab_icons/cate-active.png"

}

,

{

"path": "pages/cart/cart.vue",

"text": "购物车",

"iconPath": "static/tab_icons/cart.png",

"selectedIconPath": "static/tab_icons/cart-active.png"

}

,

{

"path": "pages/my/my.vue",

"text": "我的",

"iconPath": "static/tab_icons/my.png",

"selectedIconPath": "static/tab_icons/my-active.png"

}

]
}
  1. 导航条

在pages.json中增加一下配置:

1
2
3
4
5
6
7
8
9
10
11
"globalStyle": {

"navigationBarTextStyle": "white",

"navigationBarTitleText": "牛逼商城",

"navigationBarBackgroundColor": "#C00000",

"backgroundColor": "#FFF"

}

四、实现首页


  1. 封装网络请求模块
  1. 新建工具文件夹utils,新建request.js:
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
import {
$http
} from '@escook/request-miniprogram'

$http.baseUrl = 'https://api-hmugo-web.itheima.net'

uni.$http = $http

$http.beforeRequest = options = {

uni.showLoading({

title: ''
数据加载中......",

mask: true

})

}

$http.hideRequest = () = {

uni.hideLoading()

}
  1. 修改request-miniprogram的源码,当传入的url是一个完整的连接时,不需要组装baseUrl:
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
getUrl(url) {

return url.startsWith('http') ? url : this.baseUrl + url

}

get(url) {

...

this.url = getUrl(url)

...

}

post(url) {

...

this.url = getUrl(url)

...

}

put(url) {

...

this.url = getUrl(url)

...

}

delete(url) {

...

this.url = getUrl(url)

...

}
  1. 新建一个api/index.js,用来封装各种请求:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export const getSwiperDataAPI = () => uni.$http.get('/api/public/v1/home/swiperdata')

export const getNavListAPI = () => uni.$http.get('/api/public/v1/home/catitems')

export const getFooterListAPI = () => uni.$http.get('/api/public/v1/home/floordata')

export const getCateListAPI = () => uni.$http.get('/api/public/v1/categories')

export const getSearchListAPI = query => uni.$http.get('/api/public/v1/goods/qsearch', { query })

export const getGoodsListAPI = query => uni.$http.get('/api/public/v1/goods/search', query)

export const getGoodsDetailAPI = goods_id => uni.$http.get('/api/public/v1/goods/detail', { goods_id })

export const getWxLoginAPI = query => uni.$http.get('/api/public/v1/users/wxlogin', query)

export const getCreateOrderAPI = queryInfo => uni.$http.post('/api/public/v1/my/orders/create', queryInfo)

export const getPreOrderAPI = orderNumber => uni.$http.post('/api/public/v1/my/orders/req_unifiedorder', {order_number: orderNumber})

export const getChkOrderAPI = orderNumber => uni.$http.post('/api/public/v1/my/orders/chkOrder', {order_number: orderNumber})
  1. 封装消息函数

创建utils/showMsg.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
uni.$showMsg = (title = "数据加载失败!", duration = 1500) => {

return uni.showToast({

title,

duration,

icon: 'none'

})

}
  1. 在main.js中引入封装函数
1
2
3
import './utils/request.js'

import './utils/showMsg.js'
  1. 实现轮播图
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
<template>

<swiper indicator-dots circular autoplay :interval="3000" :duration="1000">

<swiper-item v-for="(item, i) in swipers" :key="i" @click="onNavigate(item)">

<image :src="item. image_src" />

<swiper-item>

</swiper>

</template>

<script>
import {
getSwiperDataAPI
} from '../api'

export default {

data() {

return {

swipers: []

}

},

onLoad() {

this.getSwiperData()

},

methods: {

async getSwiperData() {

const {
data: res
} = await this.getSwiperDataAPI()

if (res.meta.status !== 200) return uni.$showMsg(res.meta.msg)

this.swipers = res.message

},

onNavigate(item) {

if (item.open_type === 'navigate') {

uni.navigateTo('/subpkg/goods_detail/goods_detail?goods_id=' +
item.goods_id)

}

}

}

}
</script>

<style lang="scss" scoped>
swiper {

height: 330rpx;

image {

width: 100%;

height: 100%;

}

}
</style>
  1. 实现分类导航
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
<template>

...

<view class="nav-list">

<view class="nav-item" v-for="(item, i) in navList" :key="i" @click="onNavigate(item)">

<image class="nav-img" :src="item. image_src" />

</view>

</view>

</template>

<script>
import {
getNavListAPI
} from '../api'

export default {

data() {

return {

navList: []

}

},

onLoad() {

this.getNavList()

},

methods: {

async getNavList() {

const {
data: res
} = await getNavListAPI()

if (res.meta.status !== 200) return uni.$showMsg(res.meta.msg)

this.navList = res.message

},

onNavigate(item) {

...

else if (item.open_type === 'switchTab') {

uni.switchTab({

url: item.navigator_url

})

}

}

}

}
</script>

<style lang="scss" scoped>
.nav-list {

display: flex;

justify-content: space-around;

margin: 15px 0;

.nav-img {

width: 128px;

height: 140px;

}

}
</style>
  1. 实现楼层
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
<template>

...

<view class="footer-list">

<view class="footer-item" v-for="(item, i) in footerList" :key="i">

<images class="footer-title" :src="item.image_src" />

<view class="footer-img-box">

<view class="left-img-box" @click="onNavigate(item.product_list[0])">

<image :src="item.product_list[0].image_src" mode="widthFix" :style="' width: ' + item.product_list[0]. image_width +
'rpx;'" />

</view>

<view class="right-img-box">

<view class="right-img-item" v-for="(item2, i2) in
item.product_list" :key="i2" @click="onNavigate(item2)" v-if="i2
!== 0">

<image :src="item2.image_src" mode="widthFix" :style="' width: '
+ item2. image_width + 'rpx;'" />

</view>

</view>

</view>

<view>

</view>

</template>

<script>
import {
getFooterListAPI
} from '../api'

export default {

data() {

return {

footerList: []

}

},

onLoad() {

this.getFooterList()

},

methods: {

async getFooterList() {

const {
data: res
} = await this.getFooterListAPI()

if (res.meta.status !== 200) return uni.$showMsg(res.meta.msg)

this.footerList = res.message

}

}

}
</script>

<style lang="scss" scoped>
.footer-list {

margin: 10px 0;

display: flex;

flex-direction: column;

.footer-item {

.footer-title {

width: 100%;

height: 60rpx;

}

.footer-img-box {

display: flex;

padding-left: 10px;

.right-img-box {

display: flex;

flex-wrap: wrap;

justify-content: space-around;

}

}

}

}
</style>

五、实现分类页


  1. 实现左侧大类别
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
<template>

<view class="scroll-view-container">

<scroll-view class="left-scroll-view" :style="'height: ' + wh +
'px;'" scroll-y>

<view :class="['left-scroll-item', active === i ? 'active' :
'']" v-for="(item, i) in cateList" :key="i" @click="onScrollItemClick(i)">{{ item.cat_name }}</view>

</scroll-view>

</view>

</template>

<script>
import {
getCateListAPI
} from '../api'

export default {

data() {

return {

cateList: [],

active: 0,

wh: 0

}

},

onLoad() {

const sysInfo = uni.getSystemInfoSync()

this.wh = sysInfo.windowHeight

this.getCateList()

},

methods: {

async getCateList() {

const {
data: res
} = await getCateListAPI()

if (res.meta.status !== 200) return uni.$showMsg(res.meta.msg)

this.cateList = cateList

},

onScrollItemClick(index) {

this.active = index

}

}

}
</script>

<style lang="scss" scoped>
.scroll-view-container {

display: flex;

.left-scroll-view {

width: 120px;

.left-scroll-item {

font-size: 12px;

line-height: 60px;

text-align: center;

background-color: #f7f7f7;

&.active {

background-color: #fff;

position: relative;

&::before {

content: ' ';

display: block;

position: absolute;

width: 3px;

background-color: $main-color;

height: 30px;

left: 0;

top: 50%;

transform: translateY()
}

}

}

}

}
</style>

注意:这里必须设置内容高度为屏幕高度,滚动条才能显示出来

  1. 实现右侧二级类别渲染
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
<template>

<scroll-view class="right-scroll-view" :style="'height: ' + wh +
'px'" scroll-y>

<view class="cate-lv2" v-for="(item2, i2) in cateLv2" :key="i2">

<view class="cate-lv2-title">

/ {{ item2. cat_name }} /

</view>

<view class="cate-lv3">

<view class="cate-lv3-item" v-for="(item3, i3) in cateLv2.children" :key="i3" @click="gotoGoodsList(item.cat_id)">

<image :src="item3.cat_icon" />

<text>{{ item3.cat_name }}</text>

</view>

</view>

</view>

</scroll-view>

</template>

<script>
export default {

data() {

return: {

cateLv2: []

}

}

methods: {

async getCateList() {

...

this.cateLv2 = this.cateList.children[0]

},

onScrollItemClick(index) {

...

this.cateLv2 = this.cateList.children[index]

},

gotoGoodsList(id) {

uni.navigateTo({

url: '/subpkg/goods-list/goods-list?cid=' + id

})

}

}

}
</script>

<style>
.scroll-view-container {

display: flex;

.cate-lv2-title {

text-align: center;

font-weight: bold;

font-size: 12px;

padding: 15px 0;

}

.cate-lv3 {

display: flex;

flex-wrap: wrap;

.cate-lv3-item {

width: 33.33%;

display: flex;

flex-direction: column;

align-items: center;

margin-bottom: 10px;

image {

width: 60px;

height: 60px;

}

text {

font-size: 12px;

}

}

}

}
</style>
  1. 切换页签滚动条置顶
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
<template>

...

<scroll-view class='right-scroll-view' :scrollTop="scrollTop">

...

</template>

<script>
export default {

data() {

return {

scrollTop: 0

}

},

methods: {

onScrollItemClick(i) {

...

this.scrollTop = this.scrollTop ? 0 : 1

}

}

}
</script>

由于当值相同时,v-bind不会进行刷新,所以scrollTop要在0和1之间反复切换。

  1. 实现搜索
  1. 创建组件my-search.vue,实现一个假的搜索框,点击展开真正的搜索页面
    从DCloud市场上安装组件uni-icons
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
<template>

<view class="my-search-container" :style="' backgroundColor: ' +
bgColor + ';'">

<view class="my-search-box" :style="'border-radius: ' + radius +
'px;'" @click="onSearchBoxClick">

<uni-icons icon="search" size="17"></uni-icons>

<text class="placeholder">搜索</text>

</view>

</view>

</template>

<script>
export default {

props: {

bgColor: {

type: String,

default: '#C00000'

},

radius: {

type: Number,

default: 18

}

},

methods: {

onSearchBoxClick() {

uni.navigateTo({

url: '/subpkg/search/search'

})

}

}

}
</script>

<style>
.my-search-container {

height: 50;

display: flex;

align-items: center;

padding: 0 10px;

.my-search-box {

background-color: white;

display: flex;

width: 100%;

justify-content: center;

align-items: center;

height: 36px;

.placeholder {

font-size: 15px;

paddding-left: 5px;

}

}

}
</style>
  1. 创建分包search.vue
    从DCloud插件市场安装uni-search-box跟uni-tag插件
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
<template>

<uni-search-box @input="input" cancelButton="none" radius="100" placeholder="请输入内容" @confirm="gotoGoodsList" focus />

<view class="sugg-list" v-if="suggestList.length !== 0">

<view class="sugg-item" v-for="(item, i) in suggestList" :key="i" @click="gotoGoodsDetail(item.goods_id)">

<text>{{ item.goods_name }}</text>

<uni-icons icon="arrowright" size="16" />

</view>

</view>

<view class="history-box" v-else>

<view class="history-title">

<text>搜索历史</text>

<uni-icons icon="trash" size="18" @click="onTrashClick" />

</view>

<view class="history-list">

<uni-tag :text="item" v-for="(item, i) in historys" :key="i" @click="gotoGoodsList(item)" />

</view>

</view>

</template>

<script>
import {
getSearchListAPI
} from '../../api'

export default {

data() {

return {

kw: '',

searchList: [],

historyList: [],

timer: null

}

},

onLoad() {

this.histroyList = JSON.parse(uni.getStorageSync('kw') ||
'[]')

},

methods: {

input(e) {

clearTimeout(this.timer)

this.timer = setTimeout(() => {

this.kw = e

this.getSearchList()

}, 500)

},

async getSearchList() {

if (this.kw === '') return this.searchList = []

const {
data: res
} = await getSearchListAPI(this.kw)

if (res.meta.status !== 200) return uni.$showMsg(res.meta.msg)

this.searchList = res.message

this.saveSearchHistory()

},

saveSearchHistory() {

const set = new Set(this.historyList)

set.delete(this.kw)

set.add(this.kw)

uni.setStorageSync('kw', JSON.stringify(this.historyList))

},

onTrashClick() {

this.histroyList = []

uni.setStorageSync('kw', '[]')

},

gotoGoodsDetail(id) {

uni.navigateTo({

url: '/subpkg/goods_detail/goods_detail?goods_id=' + id

})

},

gotoGoodsList(query) {

uni.navigateTo({

url: '/subpkg/goods_list/goods_list?query=' + (query.value ||
query)

})

}

},

computed: {

historys() {

return [...this.historyList].reverse()

}

}

}
</script>

<style lang="scss" scoped>
.sugg-list {

padding: 0 5px;

.sugg-item {

display: flex;

justify-content: space-between;

align-items: center;

font-size: 12px;

padding: 13px 0;

border-bottom: 1px solid #efefef;

.goods-name {

white-space: nowrap;

overflow: hidden;

text-overflow: ellipsis;

margin-right: 3px;

}

}

}

.history-box {

padding: 0 5px;

.history-title {

display: flex;

justify-content: space-between;

align-items: center;

height: 40px;

border-bottom: 1px solid #efefef;

font-size: 13px;

}

.history-list {

display: flex;

flex-wrap: wrap;

.uni-tag {

margin-top: 5px;

margin-right: 5px;

}

}

}
</style>
  1. 添加到cate.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
<template>

<my-search></my-search>

</template>

<script>
import MySearch from '../../components/my-search.vue'

export default {

components: {

MySearch

},

onLoad() {

...

this.wh = sysInfo.windowHeight - 50

}

}
</script>
  1. 添加到home.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
<template>

<my-search class="search-box" />

</template>

<script>
import MySearch from '../../components/MySearch.vue'

export default {

components {

MySearch

}

}
</script>

<style>
.search-box {

position: sticky;

top: 0;

z-index: 999;

}
</style>
  1. 实现商品列表
  1. 在main.js中定义全局过滤器tofixed
1
2
3
4
5
Vue.filters('tofixed', n => {

return n.toFixed(2)

})
  1. 创建分包goods-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
<template>

<view class="goods-list-container">

<view class="goods-list">

<view class="goods-item" v-for="(item, i) in goodsList" :key="i" @click="gotoDetail(item)">

<my-goods :goods="item" />

</view>

</view>

</view>

</template>

<script>
import {
getGoodsListAPI
} from '../../api'

import MyGoods from '../../components/my-goods.vue'

export default {

components: {

MyGoods

},

data() {

return {

goodsList: [],

queryObj: {

query: '',

cid: '',

pageNum: 1,

pageSize: 10

}

}

},

onLoad(options) {

this.queryObj.query = options.query || ''

this.queryObj.cid = options.cid || ''

this.getGoodsList()

},

methods: {

async getGoodsList() {

const {
data: res
} = await getGoodsListAPI(this.queryObj)

if (res.meta.status !== 200) return uni.$showMsg(res.meta.msg)

this.goodsList = res.message.goods

},

gotoDetail(item) {

uni.navigateTo({

url: '/subpkg/goods_detail/goods_detail?goods_id=' + item.goods_id

})

}

}

}
</script>
  1. 将goods抽出来作为一个单独的组件my-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
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
<template>

<view class="goods-item">

<image :src="item.goods_small_logo || defaultPic" />

<view class="goods-info">

<text class="goods-title">{{ item.goods_name }}</text>

<text class="goods-price">¥{{ item.goods_price | tofixed
}}</text>

</view>

</view>

</template>

<script>
export default {

data() {

return {

defaultPic: 'https://img3.doubanio.com/f/movie/8dd0c794499fe925ae2ae89ee30cd225750457b4/pics/movie/celebrity-default-medium.png'

}

},

props: {

goods: {

type: Object,

required: true

}

}

}
</script>

<style>
.goods-item {

padding: 10px;

border-bottom: 1px solid #f0f0f0;

width: 750rpx;

box-sizing: border-box;

image {

width: 160rpx;

height: 160rpx;

display: block;

}

goods-info {

display: flex;

flex: 1 flex-direction: column;

justify-content: space-between;

margin-left: 13px;

.goods-title {

font-size: 13px;

}

.goods-price {

font-size: 16px;

color: $main-color;

}

}

}
</style>
  1. 实现上拉下拉刷新

在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
26
27
28
29
"subPackgaes": [

{

"root": "subpkg",

"pages": [

{

"path": "goods-list/goods-list",

"style": {

"onReachBottomDistance": 150,

"enablePullDownRefresh": true,

"backgroundColor": "#F8F8F8"

}

}

]

}

]

修改分包goods-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
<template>

...

<view class="bottom-box" v-if="this.goodsList.length > 0 &&
isBottom && !isLoading">------------见底了------------</view>

</template>

<script>
export default {

data() {

return {

isLoading: false,

total: 0

}

},

onPullDownRefresh() {

this.queryObj.pageNum = 1

this.total = 0

this.goodsList = []

this.isLoading = false

this.getGoodsList(() => uni.stopPullDownRefresh())

},

onReachBottom() {

if (this.isBottom || this.isLoading) return

this.queryObj.pageNum++

this.getGoodsList()

},

methods: {

async getGoodsList(cb) {

this.isLoading = true

...

this.isLoading = false

cb & cb()

...

this.goodsList = [...this.goodsList, ...res.message.goods]

this.total = res.message.total

}

}

computed: {

isBottom() {

return this.pageNum * this.pageSize > this.total

}

}

}
</script>

<style>
.bottom-box {

text-align: center;

font-size: 12px;

margin-top: 10px;

}
</style>
  1. 实现商品详情
  1. 安装插件uni-goods-nav

  2. 创建分包goods-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
<template>

<view class="goods-detail-container">

<swiper indicator-dots circular autoplay :interval="3000" :duration="1000">

<swiper-item v-for="(item, i) in goodsDetail.pics" :key="i">

<image :src="item.pics_big" @click="preview(i)" />

</swiper-item>

</swiper>

<view class="goods-info-box">

<view class="price">¥{{ goodsDetail.goods_price | tofixed
}}</view>

<view class="goods-info-body">

<text class="goods-name">{{ goodsDetail.goods_name }}</text>

<view class="favi">

<uni-icons type="star" size="18" color="gray" />

<text>收藏</text>

</view>

</view>

<view class="goods_yf">快递:免运费</view>

</view>

<rich-text :nodes="goodsDetail.goods_introduce" />

<uni-goods-nav class="goods-nav" fill :options="options" :buttonGroup="buttonGroup" @click="onGoodNavClick" @buttonClick="onGoodNavBtnClick" />

</view>

</template>

<script>
import {
getGoodsDetail
} from '../../api'

import {
mapMutations,
mapGetters
} from 'vuex'

export default {

data() {

return {

goodsDetail: {},

id: 0,

options: [

{

icon: "shop",

text: "店铺"

},

{

icon: "cart",

text: "购物车"

}

],

buttonGroup: [

{

text: "加入购物车",

backgroundColor: "#ff0000",

color: "#fff"

},

{

text: "立即购买",

backgroundColor: "#ffa200",

color: "#fff"

}

]

}

},

onLoad(options) {

this.id = options.goods_id

this.getGoodsDetails()

},

methods: {

...mapMutations('m_cart', ['addToCart']),

async getGoodsDetail() {

const {
data: res
} = await this.getGoodsDetail(this.id)

if (res.meta.status !== 200) return uni.$showMsg(res.meta.msg)

this.goodsDetail = res.message

},

preview(current) {

uni.previewImg({

current,

urls: this.goodsDetail.pics.map(x => x.pics_big)

})

},

onGoodNavClick(e) {

if (e.content.text === "购物车") {

uni.switchTab({

url: "/pages/cart/cart"

})

}

},

onGoodNavBtnClick(e) {

if (e.content.text === "加入购物车") {

const goods = {

goods_id: this.goodsDetail.goods_id,

goods_name: this.goodsDetail.goods_name,

goods_price: this.goodsDetail.goods_price,

goods_count: 1,

goods_small_logo: this.goodsDetail.pics[0].pics_sma,

goods_state: true

}

this.addToCart(goods)

}

}

}

computed: {

...mapGetters('m_cart', ['total'])

},

watch: {

total: {

handler(newVal) {

const findResult = this.options.find(x => x.text === "购物车")

if (findResult) findResult.info = newVal

},

immediate: true

}

}

}
</script>

<style>
swiper {

height: 750rpx;

image {

width: 100%;

height: 100%;

}

}

.goods-detail-container {

padding-bottom: 50px;

}

.goods-info-box {

padding: 10px;

padding-right: 0;

.price {

font-size: 18px;

color: $main-color;

margin: 10px 0;

}

.goods-info-body {

display: flex;

justify-content: space-between;

.goods-name {

font-size: 14px;

padding-right: 10px;

}

.favi {

display: flex;

flex-direction: column;

justify-content: center;

align-items: center;

font-size: 12px;

color: gray;

border-left: 1px solid #efefef;

width: 120px;

}

}

.goods-yf {

color: gray;

font-size: 12px;

margin: 10px 0;

}

}

.goods-nav {

position: fixed;

left: 0;

bottom: 0;

width: 100%;

}
</style>
  1. 实现购物车全局数据共享
    创建store/store.js:

    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
    import Vue from 'vue'

    import Vuex from 'vuex'

    import moduleCart from './cart.js'

    Vue.use(Vuex)

    const store = Vuex.Store({

    modules: {

    m_cart: moduleCart

    }

    })

    export default store

    创建store / cart.js:

    export default {

    namespaced: true,

    state: () => ({

    cart: JSON.parse(uni.getStorageSync('cart') || '[]')

    }),

    mutations: {

    addToCart(state, goods) {

    const findResult = state.cart.find(x => x.goods_id === goods.goods_id)

    if (findResult) {

    findResult.goods_count++

    } else {

    state.cart.push(goods)

    }

    this.commit('m_cart/saveToStorage')

    },

    saveToStorage(state) {

    uni.setStorageSync('cart', JSON.stringify(state.cart))

    }

    },

    getters: {

    total(state) {

    let c = 0

    state.cart.forEach(x => c += x.goods_count)

    return c

    }

    }

    }
  2. 实现购物车页签的角标刷新

创建mixins/tabbar_badge.js:

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
import {
mapGetters
} from 'vuex'

export default {

computed: {

...mapGetters('m_cart', ['total'])

},

watch: {

total: {

handler(newVal) {

this.setBadge()

},

immediate: true

}

},

onShow() {

this.setBadge()

},

methods: {

setBadge() {

if (+this.total === 0) return uni.removeTabBarBadge({
index: 2
})

uni.setTabBarBadge({

index: 2,

text: this.total + ''

})

}

}

}

将mixin混入到home.vue、 cate.vue、 cart.vue、 my.vue之中:

<
script >

import BadgeMix from '@/mixins/tabbar_badge.js'

export default {

mixins: [BadgeMix]

}

<
/script>

六、实现购物车页


  1. 实现购物车清单
  1. 从DCloud下载插件uni-number-box

  2. 修改number-box的源码,支持归零时触发事件的功能

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
export default {

props: {

minustozero: {

type: Function,

default: null

}

},

methods: {

_calcValut(type) {

...

if (type === 'minus') {

if (value <= 0 && this.minustozero) return this.minustozero()

...

}

...

}

}

}
  1. 解决number-box中数据不合法的问题:
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
export default {

methods: {

_onBlur(event) {

let value = parseInt(event.detail.value)

if (!value) return this.value = 1; // 不是数值类型默认赋值为1

...

}

}

watch: {

// 每次数值变化时检查数值,保证传出去的数值合法

inputValue(newVal, oldVal) {

// 数值有变化 && 数值合法 && 不是小数

if (+newVal != +oldVal && Number(newVal) && newVal.indexOf('.') ===
-1) {

this.$emit('change', newVal)

}

}

}

}
  1. 给组件my-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
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
<template>

<view class="goods-item">

<view class="goods-item-left">

<radio v-if="showRadio" :checked="goods.goods_state" style="transform: scale(.7)" color="#C00000" @click="clickRadio" />

<image :src="goods.goods_small_logo || defaultPic" class="goods-pic" />

</view>

<view class="goods-item-right">

<view class="goods-name">

{{ goods.goods_name }}

</view>

<view class="goods-info-box">

<view class="goods-price">¥{{ goods.goods_price | tofixed
}}</view>

<uni-number-box :minustozero="onMinusToZero" :value="goods.goods_count" v-if="showNum" @change="onNumBoxChange" />

</view>

</view>

</view>

</template>

<script>
import {
mapMutations
} from 'vuex'

export default {

props: {

showRadio: {

type: Boolean,

default: false

},

showNum: {

type: Boolean,

default: false

}

},

methods: {

...mapMutations('m_cart', ['removeGoods']),

clickRadio() {

this.$emit('radioChange', {

goods_id: this.goods.goods_id,

goods_state: !this.goods.goods_state

})

},

async onMinusToZero() {

const {
err,
succ
} = await uni.showModal({

content: '确定要删除该商品?',

confirmText: '确定',

cancelText: '取消'

})

if (err) return uni.$showMsg(err)

if (succ.confirm) this.removeGoods(this.goods)

},

onNumBoxChange(val) {

this.$emit('numChange', {

goods_id: this.goods.goods_id,

goods_count: +val

})

}

}

}
</script>

<style lang="scss" scpoed>
.goods-item {

display: flex;

width: 750rpx;

padding: 10px;

box-sizing: border-box;

border-bottom: 1px solid #f0f0f0;

.goods-item-left {

display: flex;

justify-content: space-around;

align-items: center;

image {

width: 160rpx;

height: 160rpx;

display: block;

}

}

.goods-item-right {

display: flex;

flex-direction: column;

justify-content: space-between;

margin-left: 13px;

flex: 1;

.goods-name {

font-size: 13px;

}

.goods-info-box {

display: flex;

justify-content: space-between;

align-items: center;

.goods-price {

font-size: 16px;

color: $main-color;

}

}

}

}
</style>
  1. 在cart.vue中挂载my-goods,并增加左滑删除的功能:
    从DCloud中获取插件uni-swipe-action
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
<template>

<view class="cart-container">

<view class="cart-title">

<uni-icons type="shop" size="18" />

<text class="cart-title-text">购物车</text>

</view>

<uni-swipe-action>

<block v-for="(goods, i) in cart" :key="i">

<uni-swipe-action-item options="options" @click="onSwipeActionClick(goods)">

<my-goods :goods="goods" show-radio show-num @radioChange="onRadioChange" @numChange="onNumChange" />

</uni-swipe-action-item>

</block>

</uni-swipe-action>

</view>

</template>

<script>
import {
mapState,
mapMutations
} from 'vuex'

import MyGoods from '../../components/my-goods.vue'

export default {

data() {

return {

options: [

{

text: '删除',

style: {

backgroundColor: '#C00000'

}

}

]

}

},

components: {

MyGoods

},

methods: {

...mapMutations('m_cart', ['updateGoodsCount',
'updateGoodsState', 'removeGoods'
]),

onSwipeActionClick(e) {

this.removeGoods(e)

},

onRadioChange(e) {

this.updateGoodsState(e)

},

onNumChange(e) {

this.updateGoodsCount(e)

}

},

computed: {

...mapState('m_cart', ['state'])

}

}
</script>

<style>
.cart-title {

padding-left: 5px;

display: flex;

align-items: center;

font-size: 14px;

border-bottom: 1px solid #efefef;

height: 40px;

.cart-title-text {

margin-left: 10px;

}

}
</style>
  1. 在cart.js中增加数据刷新函数:
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
mutations: {

updateGoodsState(state, goods) {

const findResult = state.cart.find(x => x.goods_id === goods.goods_id)

if (findResult) {

findResult.goods_state = goods.goods_state

this.commit('m_cart/saveToStorage')

}

},

updateGoodsCount(state, goods) {

const findResult = state.cart.find(x => x.goods_id === goods.goods_id)

if (findResult) {

findResult.goods_count = goods.goods_count

this.commit('m_cart/saveToStorage')

}

},

removeGoods(state, goods) {

state.cart = state.cart.filter(x => x.goods_id !== goods.goods_id)

this.commit('m_cart/saveToStorage')

}

}
  1. 实现添加地址
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
<template>

<view class="address-choose-box" v-if="JSON.stringify(address) ===
'{}'">

<button type="primary" size="mini" class="btnChooseAddress" @click="onChooseAddress">+添加新地址</button>

</view>

<view class="address-info-box" v-else @click="onChooseAddress">

<view class="row1">

<view class="row-left">

<view class="username">

收货人:<text>{{ address.userName }}</text>

</view>

</view>

<view class="row-right">

<view class="phone">电话:<text>{{ address.telNumber
}}</text></view>

<uni-icons type="arrowright" size="16" />

</view>

</view>

<view class="row2">

<view class="row-left">收货地址</view>

<view class="row-right">{{ addStr }}</view>

</view>

</view>

<image src="../../static/[email protected]" class="address-border" />

</template>

<script>
import {
mapState,
mapMutations,
mapGetters
} from 'vuex'

export default {

methods: {

...mapMutations('m_user', ['updateAddress']),

async onChooseAddress() {

const [err, succ] = await uni.chooseAddress().catch(err => err)

// 一个是安卓的错误信息,一个是iOS的错误信息

if (err && (err.errMsg == 'chooseAddress:fail auth deny' ||
err.errMsg == 'chooseAddress:fail authorize no response')) {

this.reAuth()

}

if (err === null && succ.errMsg === 'chooseAddress:ok') {

this.updateAddress(succ)

}

},

async reAuth() {

const [err, res] = await uni.showModal({

content: '检测到您没有打开地址权限,是否去设置打开?',

confirmText: '确定',

cancelText: '取消'

})

if (err) return uni.$showMsg(err)

if (res.cancel) return uni.$showMsg('您取消了地址授权!')

if (res.confirm) return uni.openSetting({

success: settingRes => {

if (settingRes.authSetting['scope.address']) return
uni.$showMsg('授权成功!请选择地址')

return uni.$showMsg('您取消了地址授权')

}

})

}

},

computed: {

...mapState('m_user', ['address']),

...mapGetters('m_user', ['addStr'])

}

}
</script>

<style>
.address-choose-box {

display: flex;

justify-content: center;

align-items: center;

height: 90px;

}

.address-info-box {

display: flex;

flex-direction: column;

justify-content: center;

height: 90px;

font-size: 12px;

padding: 0 5px;

.row1 {

display: flex;

justify-content: space-between;

.row-right {

display: flex;

align-items: center;

.phone {

margin-right: 5px;

}

}

}

.row2 {

display: flex;

align-items: center;

margin-top: 10px;

.row-left {

white-space: nowrap;

}

}

}

.address-border {

height: 5px;

width: 100%;

display: block;

}
</style>

实现store/user.js中的逻辑:

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
export default {

namespaced: true,

state() {

address: JSON.parse(uni.getStorageSync('address') || '{}')

},

mutations: {

updateAddress(state, address) {

state.address = address

this.commit('m_user/saveToAddrStorage')

},

saveToAddrStorage(state) {

uni.setStorageSync('address', JSON.stringify(state.address))

}

},

getters: {

addStr(state) {

if (!state.address.provinceName) return ''

return state.address.provinceName + state.address.cityName +
state.address.countyName + state.address.detailInfo

}

}

}

在store.js中挂载user.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
import modulerUser from './user.js'

const store = new Vuex.Store({

modules: {

...

m_user: moduleUser

}

})
  1. 实现结算功能
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
<template>

<view class="my-settle-container">

<label class="checkbox" @click="onClickFullCheck">

<radio :checked="isFull" color="#C00000" style="transform:
scale(.7);" /><text>全选</text>

</label>

<view class="amount-box">合计:<text>¥{{ checkedGoodsAmount
}}</text></view>

<view class="btn-settle" @click="onClickSettle">结算({{
checkedCount }})</view>

</view>

</template>

<script>
import {
mapGetters,
mapMutations
} from 'vuex'

export default {

methods: {

...mapMutations('m_cart', ['updateAllGoodsState']),

onClickFullCheck() {

this.updateAllGoodsState(!this.isFull)

}

},

computed: {

...mapGetters('m_cart', ['checkedGoodsAmount', 'checkedCount',
'total'
]),

isFull() {

return this.total === this.checkedCount

}

}

}
</script>

<style lang="scss" scpoed>
.my-settle-container {

display: flex;

justify-content: space-between;

align-items: center;

height: 50px;

position: fixed;

left: 0;

width: 100%;

background-color: white;

padding-left: 5px;

font-size: 14px;

border-top: 1px solid #efefef;

// H5跟小程序中表现不一样,因此要根据情况调整位置

// # ifdef MP

bottom: 0;

// # endif

// # ifndef MP

bottom: 50px;

// # endif

.checkbox {

display: flex;

align-items: center;

padding-left: 5px;

}

.amount-box {

color: $main-color;

}

.btn-settle {

background-color: $main-color;

color: white;

height: 50px;

min-width: 100px;

line-height: 50px;

text-align: center;

padding: 0 10px;

}

}
</style>

在cart.js中实现总价、商品选中状态、商品数量的功能:

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
export default {

mutations: {

updateAllGoodsState(state, curState) {

state.cart.forEach(x => x.goods_state = curState)

}

},

getters: {

checkedGoodsAmout(state) {

return state.cart.filter(x => x.goods_state).reduce((total, item) =>
total += item.goods_price * item.goods_count, 0).toFixed(2)

},

checkedCount(state) {

return state.cart.filter(x => x.goods_state).reduce((total, item) =>
total += item.goods_count, 0)

}

}

}
  1. 无商品界面
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
<template>

<view class="my-settle-container" v-if="cart.length !==
0">...</view>

<view class="empty-cart" v-else>

<image src="../../static/[email protected]" class="empty-img" />

<text class="tip-text">空空如也~</text>

<button class="empty-btn" @click="onClickEmptyBtn">去逛逛</button>

</view>

</template>

<script>
export default {

onClickEmptyBtn() {

uni.switchTab({

url: '/pages/cate/cate'

})

}

}
</script>

<style>
.empty-cart {

display: flex;

flex-direction: column;

align-items: center;

padding-top: 150px;

.empty-img {

width: 90px;

height: 90px;

}

.tip-text {

margin-top: 15px;

font-size: 12px;

color: gray;

}

.empty-btn {

color: white;

width: 120px;

height: 30px;

background-color: $main-color;

margin-top: 20px;

line-height: 30px;

font-size: 14px;

}

}
</style>

七、实现用户页


  1. 实现组件my-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
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
<template>

<view class="login-container">

<uni-icons type="contace-filled" size="100" color="#afafaf" />

<button type="primary" class="btn-login" @click="getUserInfo">一键登录</button>

<view class="tips-text">登录后尽享更享权益</view>

</view>

</template>

<script>
import {
mapMutations
} from 'vuex'

export default {

methods: {

...mapMutations('m_user', ['updateUserInfo', 'updateToken']),

getUserInfo() {

uni.getUserProfile({

desc: '您的授权信息',

success: res => {

this.updateUserInfo(res.userInfo)

this.getToken(res)

},

fail: err => {

return uni.$showMsg(err)

}

})

},

async getToken(info) {

const [err, res] = await uni.login().catch(err => err)

if (err || res.errMsg !== 'login:ok') return
uni.$showMsg('登录失败!')

const {
data: loginRes
} = await
uni.$http.post('https://api.it120.cc/sherwood/user/wxapp/login?code=' +
res.code)

if (loginRes.code !== 0) return uni.$showMsg(loginRes.msg)

this.updateToken(loginRes.data.token)

}

}

}
</script>

<style lang="scss" scoped>
.login-container {

display: flex;

flex-direction: column;

justify-content: center;

align-items: center;

height: 750rpx;

background-color: #f8f8f8;

position: relative;

overflow: hidden;

&::after {

content: ' ';

display: block;

position: absolute;

width: 100%;

height: 40px;

left: 0;

bottom: 0;

background-color: white;

border-radius: 100%;

transform: translateY(50%);

}

.btn-login {

width: 90%;

border-radius: 100px;

margin: 15px 0;

background-color: $main-color;

}

.tips-text {

font-size: 12px;

color: gray;

}

}

<style>

在user.js中保存用户的数据

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
export default {

state() {

userinfo: JSON.parse(uni.getStorageSync('userinfo') || '{}'),

token: uni.getStorageSync('token') || ''

},

mutations: {

updateUserInfo(state, userinfo) {

state.userinfo = JSON.stringify(userinfo)

this.commit('m_user/saveToStorage', 'userinfo',
JSON.stringify(userinfo))

},

saveToStorage(state, name, data) {

uni.setStorageSync(name, data)

},

updateToken(state, token) {

state.token = token

this.commit('m_user/saveToStorage', 'token', token)

}

}

}
  1. 实现组件my-userinfo.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
<template>

<view class="my-userinfo-container">

<view class="top-box">

<image :src="userinfo.avatarUrl" class="avatar" />

<view class="title-name">{{ userinfo.nickName }}</view>

</view>

<view class="panel-list">

<view class="panel">

<view class="panel-body">

<view class="panel-item">

<text>8</text>

<text>收藏的店铺</text>

</view>

<view class="panel-item">

<text>14</text>

<text>收藏的商品</text>

</view>

<view class="panel-item">

<text>18</text>

<text>关注的商品</text>

</view>

<view class="panel-item">

<text>84</text>

<text>足迹</text>

</view>

</view>

</view>

<view class="panel">

<view class="panel-title">我的订单</view>

<view class="panel-body">

<view class="panel-item">

<image src="../static/my-icons/icon1.png" class="icon" />

<text>待付款</text>

</view>

<view class="panel-item">

<image src="../static/my-icons/icon2.png" class="icon" />

<text>待收货</text>

</view>

<view class="panel-item">

<image src="../static/my-icons/icon3.png" class="icon" />

<text>退款/退货</text>

</view>

<view class="panel-item">

<image src="../static/my-icons/icon4.png" class="icon" />

<text>全部订单</text>

</view>

</view>

</view>

<view class="panel">

<view class="panel-list-item">

<text>收货地址</text>

<uni-icons type="arrowright" size="18" />

</view>

<view class="panel-list-item">

<text>联系客服</text>

<uni-icons type="arrowright" size="18" />

</view>

<view class="panel-list-item" @click="logout">

<text>退出登录</text>

<uni-icons type="arrowright" size="18" />

</view>

</view>

</view>

</view>

</template>

<script>
import {
mapMutations
} from 'vuex'

export default {

methods: {

...mapMutations('m_user', ['updateAddress', 'updateUserInfo',
'updateToken'
])

async logout() {

const [err, res] = await uni.showModal({

content: '确定要退出登录?',

confirmText: '确定',

cancelText: '取消'

}).catch(err => err)

if (err) return uni.$showMsg(err)

if (res && res.confirm) {

this.updateAddress({})

this.updateUserInfo({})

this.updateToken('')

}

}

}

}
</script>

<style lang="scss" scpoed>
.my-userinfo-container {

height: 100%;

background-color: #f4f4f4;

.top-box {

background-color: $main-color;

height: 400rpx;

position: relative;

display: flex;

flex-direction: column;

justify-content: center;

align-items: center;

&::after {

content: ' ';

display: block;

width: 100%;

border-radius: 100%;

height: 40px;

position: absolute;

left: 0;

bottom: 0;

transform: translateY(50%);

background-color: $main-color;

}

.avatar {

width: 90px;

height: 90px;

border: 2px solid white;

border-radius: 45px;

border-shadow: 0 1px 5px black;

display: block;

}

.title-name {

margin-top: 10px;

font-size: 16px;

color: white;

font-weight: bold;

}

}

.panel-list {

position: relative;

top: -10px;

padding: 0 10px;

.panel {

margin-bottom: 8px;

border-radius: 3px;

background-color: white;

.panel-title {

border-bottom: 1px solid #f4f4f4;

font-size: 15px;

padding-left: 10px;

line-height: 45px;

}

.panel-body {

display: flex;

justify-content: space-around;

.panel-item {

display: flex;

flex-direction: column;

justify-content: space-around;

align-items: center;

font-size: 13px;

padding: 10px 0;

.icon {

width: 35px;

height: 35px;

}

}

}

.panel-list-item {

display: flex;

justify-content: space-between;

height: 45px;

align-items: center;

padding: 0 10px;

font-size: 15px;

}

}

}

}
</style>
  1. 实现页面my.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
<template>

<view class="my-container">

<my-login v-if="!token" />

<my-userinfo v-else />

</view>

</template>

<script>
import MyLogin from '../../components/MyLogin.vue'

import MyUserInfo from '../../components/MyUserInfo.vue'

import {
mapState
} from 'vuex'

export default {

components: {

MyLogin,

MyUserInfo

},

computed: {

...mapState('m_user', ['token'])

}

}
</script>

<style>
page,
my-container {

height: 100%
}
</style>
  1. 实现支付功能
  1. 未登录跳转到登录页
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
import {
mapState,
mapGetters
} from 'vuex'

export default {

methods: {

data() {

return {

timer: null,

second: 3

}

}

onClickSettle() {

if (!this.checkedCount) return uni.$showMsg('请选择结算的商品!')

if (!this.addStr) return uni.$showMsg('请选择收获地址')

if (!this.token) return this.delayNavigate()

},

delayNavigate() {

this.second = 3

this.showTips(this.second)

this.timer = setInterval(() => {

this.second--

if (this.second <= 0) {

clearInterval(this.timer)

this.timer = null

uni.switchTab({

url: 'pages/my/my',

success: () => {

this.updateRedirectInfo({

open_type: 'switchTab',

from: 'pages/cart/cart'

})

}

})

return

}

this.showTips(this.second)

}, 1000)

},

showTips(n) {

uni.showToast({

icon: 'none',

title: `请登录后结算!{n}秒后自动跳转到登录页`,

mask: true,

durations: 1500

})

}

},

computed: {

...mapState('m_user', ['token']),

...mapGetters('m_user', ['addStr'])

}

}

在user.js中实现redirectInfo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export default {

state() {

redirectInfo: null

},

mutations: {

updateRedirectInfo(state, info) {

state.redirectInfo = info

}

}

}

在my-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
import {
mapState,
mapMutations
} from 'vuex'

export default {

computed: {

...mapState('m_user', ['redirectInfo'])

},

methods: {

...mapMutations('m_user', ['updateRedirectInfo']),

async getToken() {

...

this.navigateBack()

},

navigateBack() {

if (this.redirectInfo && this.redirectInfo.open_type ===
'switchTab') {

uni.switchTab({

url: this.redirectInfo.from,

complete: () => this.updateRedirectInfo(null)

})

}

}

}

}
  1. 创建订单并拉起微信支付

在my-settle.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
import {
getCreateOrderAPI,
getPreOrderAPI,
getChkOrderAPI
} from '../api'

import {
mapGetters,
mapState
} from 'vuex'

export default {

computed: {

...mapGetters('m_user', ['addStr']),

...mapState('m_cart', ['cart'])

},

methods: {

onClickSettle() {

...

this.payOrder()

},

async payOrder() {

const orderInfo = {

order_price: .01,

consignee_add: this.addStr,

goods: this.cart.filter(x => x.goods_state).map(x => {
goods_id: x.goods_id,
goods_count: x.goods_count,
goods_price: x.goods_price
})

}

const {
data: res
} = await getCreateOrderAPI(orderInfo)

if (res.meta.status !== 200) return uni.$showMsg(res.meta.msg)

const orderNumber = res.message.order_number

const {
data: res
} = await getPreOrderAPI(orderNumber)

if (res.meta.status !== 200) return uni.$showMsg(res.meta.msg)

const payInfo = res.message.pay

const [err, succ] = await uni.requestPayment(payInfo)

if (err) return uni.$showMsg(err)

const {
data: res
} = await getChkOrderAPI(orderNumber)

if (res.meta.status !== 200) return uni.$showMsg(res.meta.msg)

uni.showToast({

title: '支付成功!',

icon: 'success'

})

}

}

}

八、发布


  1. 发布小程序
    1. 点击”发行”-> “小程序-微信(仅限uni-app)”

    1. 在弹出框中填写包名跟AppID,点击发行按钮:

    1. 在控制台中查看编译进度

    1. 编译完成后,会自动打开一个新的微信开发者工具界面,此时点击工具栏上的上传按钮:

    1. 填写版本号跟备注后,点击上传按钮

    1. 上传完成后,会出现一下界面:

    1. 在小程序后台中的版本管理->开发版本中可以看到刚上传的小程序

    1. 在“开发管理”->“开发设置”中找到“服务器域名”,给后台API添加白名单[1]

    1. 先填写基本应用信息,然后点”提交审核”,审核完成后,点击”发布”,即可完成小程序的发布上线:

    1. 生成小程序码[2]
      点击右上角“工具”->“生成程序码”,跟着指导输入相关信息即可生成程序码
  1. 发布apk
    1. 登录账户

    1. 在manifest.json中的基础配置,设置App的名称:

    1. 点”发行”->“原生App-云打包”:

    1. 勾选打包配置

    1. 查看打包进度:

    1. 在给出的地址中下载apk安装包,即可安装到手机上



参考:

项目文档

接口文档

Github项目

Gitee项目

Uni-app组件文档

box-shadow各种经典效果

让CSS flex布局最后一行列表左对齐的N种方法

CSS 中实现文本超出部分省略的效果

微信小程序开发平台

微信小程序发布流程