这一篇主要介绍小程序开发所用的前端技术,为后续的实际应用做好铺垫,上节课学习的vue3和typescript,在此次uniapp小程序开发中将大有所用。
根据涉及的知识点,将从以下几个方面来学习:
项目框架
项目采用vite、vue3、typescript、setup、uniapp等技术,ui框架采用uview-plus下面将做详细的介绍。
UI框架
UI框架先介绍下uview-plus,uview-plus官方网站,虽举例用的vue3但最底部git源代码则是vue2方式,而本文将以vue3 setup写法来举例<script setup lang="ts"></script>,安装步骤参照官网中npm的方式和配置流程。下面先以常用的表单为例,介绍下如何使用uview-plus的ui组件,代码在支付宝小程序上运行效果如图1所示:(提示:以reactive解构获取属性变量时,除对象、数组属性外,如number、string等变量属性,在reactive解构时会丢失响应性)
<template>
<view class="main-content">
<up-form
labelPosition="left"
:model="state.formModel"
:rules="state.rules"
ref="formRef"
>
<up-form-item
label="姓名"
prop="name"
borderBottom
>
<up-input
v-model="state.formModel.name"
border="none"
></up-input>
</up-form-item>
<up-form-item
label="性别"
prop="sex"
borderBottom
@click="state.showSex = true;"
>
<up-input
v-model="state.formModel.sex"
disabled
disabledColor="#ffffff"
placeholder="请选择性别"
border="none"
></up-input>
<template #right>
<up-icon
name="arrow-right"
></up-icon>
</template>
</up-form-item>
<up-button type="primary" @click="submit()">确认</up-button>
</up-form>
<up-action-sheet
:show="state.showSex"
:actions="state.actions"
title="请选择性别"
description="如果选择保密会报错"
@close="state.showSex = false"
@select="sexSelect"
>
</up-action-sheet>
</view>
</template>
<script setup lang="ts">
import { ref, reactive } from "vue";
import type UniFormRef from "uview-plus";
const state = reactive({
showSex: false,
formModel: {
name: 'uview-plus UI',
sex: ''
},
actions: [
{ name: '男' },
{ name: '女' },
{ name: '保密' },
],
rules: {
'name': {
type: 'string',
required: true,
message: '请填写姓名',
trigger: ['blur', 'change'],
},
'sex': {
type: 'string',
max: 1,
required: true,
message: '请选择男或女',
trigger: ['blur', 'change'],
},
},
radio: '',
switchVal: false,
});
const formRef = ref<UniFormRef | null>(null);
// 定义方法
function sexSelect(e: any) {
state.formModel.sex = e.name;
if (formRef.value) {
(formRef.value as any).validateField("sex");
}
}
function submit() {
formRef.value?.validate().then((valid: any) => {
if (valid) {
uni.$u.toast(JSON.stringify(state.formModel));
} else {
uni.$u.toast("校验失败");
}
});
}
</script>
<style lang="scss">
.main-content {
padding: 0 40rpx;
}
</style>
图1
在上面的基础上,再介绍下uview-plus中的"加载更多"组件的使用,它在后续的列表和数据加载中会经常使用到,对up-loadmore组件封装成XtxGuess.vue组件如下,使'加载更多'组件功能上手即用:
<script setup lang="ts">
import { reactive, ref, withDefaults } from 'vue'
import type { GoodsItem } from '@/types/global'
import { onReachBottom, onReady } from '@dcloudio/uni-app'
import { getHomeGoodsGuessLikeAPI } from '@/services/home'
const props = withDefaults(defineProps<{
busType?: string,
pagination: {
pages: number
pageNo: number,
pageSize: number
},
apiFunc?: (params: { page: number, pageSize: number }) => Promise<any>
}>(),{
apiFunc: getHomeGoodsGuessLikeAPI
});
const more = ref();
const loadmoreText = ref('加载更多');
const emits = defineEmits(["queryMore"]);
let list: GoodsItem[] = reactive([]);
const STATUS = {IDLE: 'loadmore', LOADING: 'loading', NO_MORE: 'nomore'};
let status = ref(STATUS.IDLE);
/***
*计算页数
*page
*/
const calculatePages = (total: number, pageSize: number) => {
if (total === 0 || pageSize === 0) {
return 0
}
return Math.ceil(total / pageSize)
}
const initFuc = async () => {
if (status.value === STATUS.LOADING) return;
// 将状态设置为'loading'
status.value = STATUS.LOADING;
await props.apiFunc({ page: props.pagination.pageNo, pageSize: props.pagination.pageSize }).then(res=>{
const pages = calculatePages(res.result.counts, props.pagination.pageSize);
list.push(...res.result.items);
emits("queryMore", pages);
}).catch(e=>{
status.value = STATUS.IDLE;
loadmoreText.value = "重新加载"
});
status.value = STATUS.IDLE;
}
const query = async () => {
// 如果页码大于总页数,设置状态为'nomore'并返回
if (props.pagination.pages > 0 && props.pagination.pageNo > props.pagination.pages) {
status.value = STATUS.NO_MORE;
return;
}
await initFuc();
}
onReady(async () => {
await query()
})
onReachBottom(async () => { await query() })
</script>
<template>
<view class="guess">
<!-- 列表类型-->
<template v-if="busType=='list'">
<navigator
class="guess-item guess-item1"
v-for="item in list"
:key="item.id"
:url="`/pages/goods/goods?id=${item.id}`"
>
<view> {{ item.name }} </view>
</navigator>
</template>
<!-- 其它类型-->
<template v-else>
<navigator
class="guess-item"
v-for="item in list"
:key="item.id"
:url="`/pages/goods/goods?id=${item.id}`"
>
<image class="image" mode="aspectFill" :src="item.picture"></image>
<view class="name"> {{ item.name }} </view>
<view class="price">
<text class="small">¥</text>
<text>{{ item.price }}</text>
</view>
</navigator>
</template>
<up-loadmore ref="more" :loadmore-text="loadmoreText" :status="status" icon-color="#028BF7" marginTop="20" />
</view>
</template>
<style lang="scss">
:host {
display: block;
}
/* 猜你喜欢 */
.guess {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
padding: 0 20rpx;
.guess-item {
width: 345rpx;
padding: 24rpx 20rpx 20rpx;
margin-bottom: 20rpx;
border-radius: 10rpx;
background-color: #fff;
}
.guess-item1{
width: 100%;
}
.image {
width: 304rpx;
height: 304rpx;
}
.name {
height: 75rpx;
margin: 10rpx 0;
font-size: 26rpx;
color: #262626;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.price {
line-height: 1;
padding-top: 4rpx;
color: #cf4444;
font-size: 26rpx;
}
.small {
font-size: 80%;
}
}
</style>
使用:let pagination = reactive({ pageNo: 1, pageSize: 20, pages: 0 });getMemberOrderAPI即对应接口名称的promise <XtxGuess busType="list" :api-func="getMemberOrderAPI" :pagination="pagination" @queryMore="(p)=>{pagination.pageNo++; pagination.pages = p;}"> </XtxGuess>
路由page.json文件
在easycom中配置uview-plus内容,工程里所有的页面在pages属性里面定义,小程序中的下导航tab在属性tabBar中定义,全局样式在globalStyle属性中编写。
{
"easycom": {
"autoscan": true,
"custom": {
"^u--(.*)": "uview-plus/components/u-$1/u-$1.vue",
"^up-(.*)": "uview-plus/components/u-$1/u-$1.vue",
"^u-([^-].*)": "uview-plus/components/u-$1/u-$1.vue"
}
},
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "首页"
}
},
{
"path": "pages/my/index",
"style": {
"navigationBarTitleText": "我的"
}
},
{
"path": "pages/componentsB/tag/index",
"style": {
"navigationBarTitleText": "测试"
}
},
{
"path": "pages/componentsB/alipay/index",
"style": {
"navigationBarTitleText": "支付宝授权登录"
}
}
],
"tabBar": {
"color": "#333333",
"selectedColor": "#207DFF",
"borderStyle": "black",
"backgroundColor": "#ffffff",
"list": [{
"pagePath": "pages/index/index",
"text": "首页",
"iconPath": "/static/tabBar/un_home.png",
"selectedIconPath": "/static/tabBar/home.png"
}, {
"pagePath": "pages/my/index",
"text": "我的",
"iconPath": "/static/tabBar/un_user.png",
"selectedIconPath": "/static/tabBar/user.png"
}]
},
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "uni-app",
"navigationBarBackgroundColor": "#ffffff",
"backgroundColor": "#ffffff"
}
}
变量存储
比如项目中需要存储用户登录和授权状态,此时就需要考虑到状态变量存储,我们使用vuex来管理状态变量。
接口请求
uni自带http,uni.$u.http中有get和post方法直接可用无需封装。 提示:项目中可以直接使用uni.$u,需要你配置相应的type,为了方便使用支付宝小程序自带my变量,my变量类型约束也需要一起定义下,在src下env.d.ts文件中写入如下类型约束即可
const my: any;
interface Uni { $u: any };
declare module 'uview-plus';
授权登录
不同的小程序比如微信、支付宝小程序授权登录流程稍有不同,我们以支付宝小程序为例-授权需另起页面,在首页中添加如下代码,跳转到支付宝小程序授权页,页面效果如图2
uni.getProvider({
service: "oauth", //service String 服务类型 oauth授权登录 share分享 payment支付 push推送
success: function(res) {
//判断是否完成授权登陆
const isLogin = false;
const provider = res.provider[0] as "weixin"||"alipay";
if (isLogin) {
uni.getUserInfo({
provider: provider,
success: (res) => {
const userInfo = res.userInfo;
console.log(userInfo);
},
fail: (error) => {
console.log(error);
}
});
} else {
// #ifdef MP-ALIPAY
uni.navigateTo({
url: "/pages/componentsB/alipay/index"
});
// #endif
// #ifdef MP-WEIXIN
uni.login({
provider: provider,
success: function(loginRes) {
console.log("uniapp登录凭证", loginRes);
}
});
// #endif
}
}
});
支付宝小程序授权页面详细代码如下,根据获取到的authCode,调用后台接口完成授权登录,返回用户基础信息。
<template>
<view class="page">
<view class="page-section">
<view>请不要一进入小程序就弹框授权,建议先了解小程序的服务内容</view>
<button class="signin-btn" open-type="getUserInfo" @click="getAuthCode">授权登录</button>
</view>
</view>
</template>
<script setup lang="ts">
function getAuthCode() {
my.getAuthCode({
scopes: 'auth_user',
success: (authCode:string) => {
//根据authCode完成后续操作
},
});
}
</script>
<style scoped lang="scss">
.page {
font-family: -apple-system-font,Helvetica Neue,Helvetica,sans-serif;
font-size: 24rpx;
padding: 32rpx;
flex: 1;
.page-section {
background: #fff;
margin-bottom: 32rpx;
button {
margin-top:20rpx;
margin-bottom:20rpx;
background-color: #49A9EE;
color: white;
}
}
}
</style>
图2
实际开发
以'点击更多'跳转到选择附近的地址为例,页面布局上面是搜索栏,下面是地图和地址选择框,假设需求设计稿如图3所示,下面将介绍如何开发这样的一个需求(门店列表),涉及的知识点有页面布局、地图使用、地址选择框的封装、点击联系店铺调起打电话、定位、搜索框等:
1使用地图
首先在高德开发者平台创建应用,选择web服务 切记不是微信小程序,因为在获取到经纬度以后需要调用高德地图web服务(https://restapi.amap.com接口)来反推出当前详细地址,而此时用真机调试上述接口会出现未配置域名白名单错误,点击链接控制台首页 - 开放平台 (alipay.com),在开发设置中配置服务器域名白名单可解决此问题,高德地图web服务API接口需要在真机上才起作用,如下图所示:
详细代码如下,其中scrollView.vue即上面封装的加载更多组件
<template>
<view class="address">
<view class="search-top">
<up-search v-if="!focusFlag"
:show-action="false"
placeholder="搜索地点"
v-model="condition"
@focus="inputFocus"
>
</up-search>
<up-search v-else
:show-action="true"
actionText="取消"
:focus="true"
@custom="cancel()"
:animation="true"
v-model="condition"
@search="searchClick"
@clickIcon="searchClick"
></up-search>
</view>
<view class="search-map" v-show="!focusFlag">
<map style="width:100%; height:100%" id="map" scale="13" :latitude="latitude" show-location :longitude="longitude"
:markers="markers"></map>
</view>
<scrollView
:list="list"
:pagination="pagination"
:pages="pages"
@queryList="searchList">
<view class="search-list" :class="item.checked?'selected-list':''" v-for="(item, index) in list" :key="index"
@click="bindPointClick(item)">
<view class="status">
<image class="logo" src="../../../static/drk.png"/>
<text class="yyz">营业中</text>
<text class="picker">{{ item.cityname }}</text>
</view>
<view>
<text class="name">{{ item.name }}</text>
</view>
<view>
<text class="detail">{{ item.location + 'km' }}</text>
</view>
<view class="phone">
<up-tag text="联系店铺" icon="phone-fill" plain size="mini" @click="callNumber" type="warning"></up-tag>
</view>
</view>
</scrollView>
</view>
</template>
<script setup lang="ts">
import {onMounted, reactive, ref} from "vue";
import scrollView from "../common/scrollView.vue";
interface Interface {
name: string,
location: string,
checked: boolean,
cityname: string
}
interface InterfaceMarkers {
id: number,
longitude: number,
latitude: number,
width: number,
height: number,
iconPath: string,
customCallout: object
}
const focusFlag = ref(false);
const condition = ref("");
let list = ref<Interface[]>([]);
let markers = ref<InterfaceMarkers[]>([]);
let latitude = ref();
let longitude = ref();
let pagination = reactive({pageNo: 1, pageSize: 20});
let pages = ref(0);
const inputFocus = () => {
focusFlag.value = true;
pagination.pageNo = 1;
list.value = [];
};
const cancel = () => {
focusFlag.value = false;
openMap({latitude: '', longitude: '', name: ''});
}
const searchClick = () => {
inputFocus();
searchList(pagination)
}
const searchList = async (params:any) => {
await uni.$u.http.get('http://47.110.131.107/admin-api/drawdowns/records', {
data: {
current: params.pageNo,
limit: params.pageSize,
listType: 2
}
}).then((res: any) => {
let data = res.data.data;
pages.value = calculatePages(data.total, params.pageSize);
list.value.push(...data.records);
});
//页面新增一页
pagination.pageNo = ++pagination.pageNo;
};
const calculatePages = (total: number, pageSize: number) => {
if (total === 0 || pageSize === 0) {
return 0;
}
return Math.ceil(total / pageSize);
}
const callNumber = () => {
uni.makePhoneCall({
phoneNumber: "10086",
success: () => {
console.log("拨打电话成功!");
},
fail: () => {
console.error("拨打电话失败!");
}
});
};
const bindPointClick = (e: any) => {
openMap({latitude: e.latitude, longitude: e.longitude, name: e.name});
};
const truncateString = (str: string, len: number) => {
if (str.length > len) {
return str.substring(0, len).concat("...");
} else {
return str;
}
};
const openMap = (params: { latitude: "", longitude: "", name: "" }) => {
uni.getLocation({
success: res => {
longitude.value = res.longitude;
latitude.value = res.latitude;
uni.openLocation({
latitude: latitude.value,
longitude: longitude.value
});
my.httpRequest({
url: "https://restapi.amap.com/v3/geocode/regeo?key=f941ea8ff6ea10c2b83db08ba5296f39&location=" + longitude.value + "," + latitude.value,
method: "GET",
success: function (res: any) {
let address = res.data.regeocode.addressComponent.district + res.data.regeocode.addressComponent.township;
markers.value = [{
id: 3,
latitude: params.latitude ? params.latitude : latitude.value,
longitude: params.longitude ? params.longitude : longitude.value,
width: 25,
height: 31,
iconPath: "https://gw.alipayobjects.com/mdn/rms_dfc0fe/afts/img/A*x9yERpemTRsAAAAAAAAAAAAAARQnAQ",
"customCallout": {
"type": 2,
"descList": [{
"desc": params.name ? truncateString(params.name, 20) : truncateString(address, 20),
"descColor": "#000000"
}],
"isShow": 1
}
}];
my.createMapContext("map").moveToLocation({
latitude: params.latitude ? params.latitude : latitude.value,
longitude: params.longitude ? params.longitude : longitude.value
});
},
fail: function (err: any) {
console.log("高德地图API调用失败", err);
}
});
}
});
};
onMounted(() => {
openMap({latitude: "", longitude: "", name: ""});
})
</script>
<style scoped lang="scss">
.address {
font-family: -apple-system-font, Helvetica Neue, Helvetica, sans-serif;
font-size: 24rpx;
padding: 32rpx;
flex: 1;
background: #fff;
.search-top {
background-color: #FFFFFF;
padding: 8rpx 0 8rpx 0;
display: flex;
flex-direction: row;
align-items: center;
}
.search-map {
height: 600rpx;
}
.search-list {
display: flex;
flex-direction: column;
width: 650rpx;
height: 210rpx;
margin-bottom: 14rpx;
padding-left: 30rpx;
padding-top: 30rpx;
border: 1px solid #dcdcdc;
border-radius: 14rpx;
.status {
display: flex;
flex-direction: row;
height: 48rpx;
line-height: 48rpx;
.yyz {
background: #f6b62d;
width: 78rpx;
text-align: center;
margin-top: 7rpx;
height: 34rpx;
line-height: 34rpx;
color: white;
}
.picker {
font-size: 28rpx;
}
.logo {
width: 48rpx;
height: 48rpx
}
}
.phone {
width: 180rpx;
margin-top: 10rpx;
}
.name {
font-size: 28rpx;
margin-left: 46rpx;
}
.hook {
font-size: 26rpx;
}
.detail {
font-size: 24rpx;
margin-left: 46rpx;
color: #cecece;
}
.selected-list {
border: 1px solid #fd8e02;
}
}
}
</style>
因为在支付宝小程序上地图不支持cover-view嵌套使用,于是去官方网站上查看marker使用教程小程序文档 - 支付宝文档中心,实现了需求中的页面布局、地图使用、根据选择地址定位、拨打电话等功能,效果图如下图所示
打包发布
打包,根据uni配置对应的小程序和app打包指令,如支付宝小程序代码,打包后代码在dist>mp-alipay文件夹下,发布,还没发过生产后续再重新补上流程。
GIT源代码
未完待续