史上最全神领物流司机端uniapp项目的实操笔记。本文覆盖项目的详细步骤和解决方案并对uniapp开始所涉及到的技术进行总结归纳。

项目主要模块

实名认证:司机扫脸认证,行程数据更加安全可靠

提货管理:提货任务、运输货品、车辆、线路安排的准确合理,提货流程清晰可控

交付管理:凭证、清单缺一不可,货品交付数据详细、实时可查

回车登记:异常信息及时掌握,保障车辆安全运行

Day1 项目初始化

项目拉取:git clone shenling-driver: A project developed with uni-app.

一、公共封装

1.2.1 网络请求

在 uni-app 中通过 uni.request 发起网络请求,在实际的应用中需要结合一些业务场景进行二次封装,比如配置 baseURL、拦截器等,社区有许多封装好的库,我们在项目中可以拿过来直接使用。

uni-app-fetch 是对 uni.request 的封装,通过 npm 来安装该模块

# 安装 uni-app-fetch 模块
npm install uni-app-fetch

然后根据自身的业务需求来配置 uni-app-fetch

// apis/uni-fetch.js

// 导入安装好的 uni-app-fetch 模块
import { createUniFetch } from 'uni-app-fetch'

// 配置符合自身业务的请求对象
export const uniFetch = createUniFetch({
	loading: { title: '正在加载...' },
	baseURL: 'https://slwl-api.itheima.net',
	intercept: {
		// 请求拦截器
		request(options) {
			// 后续补充实际逻辑
      return options
		},
		// 响应拦截器
		response(result) {
			// 后续补充实际逻辑
      return result
		},
	},
})

uni-app-fetch 更详细的使用文档在这里,它的使用逻辑是仿照 axios 来实现的,只是各别具体的用法稍有差异,在具体的应用中会给大家进行说明。

  • loading 是否启动请求加载提示
  • baseURL 配置请求接口的基地址
  • intercept 配置请求和响应拦截器

配置完成后到项目首页(任务)测试是否请求能够发出,在测试时需要求【神领物流】全部接口都需要登录才能访问到数据,因此在测试请求时只要能发出请求即可,不关注返回结果是否有数据。

<!-- pages/task/index.vue -->
<script setup>
  // 导入根据业务需要封装好的网络请求模块
	import { uniFetch } from '@/apis/uni-fetch'
	// 不关注请求结果是否有数据
	uniFetch({
		url: '/driver/tasks/list',
	})
</script>

神领物流接口文档地址在这里

1.2.2 轻提示

为了提升用户的体验,在项目的运行过程中需要给用户一些及时的反馈,比如数据验证错误、接口调用失败等,uni-app 中通过调用 uni.showToast 即可实现,但为了保证其使用的便捷性咱们也对它稍做封装处理。

// utils/utils.js

/**
 * 项目中会用的一系列的工具方法
 */

export const utils = {
	/**
	 * 用户反馈(轻提示)
	 * @param {string} title 提示文字内容
	 * @param {string} icon 提示图标类型
	 */
	toast(title = '数据加载失败!', icon = 'none') {
		uni.showToast({
			title,
			icon,
			mask: true,
		})
	}
}

// 方便全局进行引用
uni.utils = utils

在项目入口 main.js 中导入封装好的工具模块

// main.js
import App from './App'
import '@/utils/utils'

// 省略了其它部分代码...

到项目中测试工具方法是否可用,还是在首页面(task)中测试

// pages/task/index.vue
<script setup>
  // 导入根据业务需要封装好的网络请求模块
	import { uniFetch } from '@/apis/uni-fetch'
	// 不关注请求结果是否有数据
	uniFetch({
		url: '/driver/tasks/list'
	}).then((result) => {
    if(result.statusCode !== 200) uni.utils.toast()
  })
</script>

通过上述代码的演示我们还可以证实一个结论:uni-app-fetch 返加的是 Promise 对象,将来可配合 async/await 来使用。

二、Pinia状态管理

Pinia 是 Vue 专属于状态管理库,是 Vuex 状态管理工具的替代品,其具有一个特点:

  • 提供了更简单的 API(去掉了 mutation)
  • 提供组合式风格的 API
  • 去掉了 modules 的概念,每一个 store 都是独立的模块
  • 配合 TypeScript 更加友好,提供可靠的类型推断

2.1 安装 Pinia

# 安装 pinia 到项目当中
npm install pinia

在 uni-app 中内置集成了 Pinia 的支持,因此可以省略掉安装这个步骤,但如果是在非 uni-app 的 Vue3 项目使用 Pinia 时必须要安装后再使用。

2.2 Pinia 初始化

// main.js
import { createSSRApp } from 'vue'
// 引入 Pinia
import { createPinia } from 'pinia'

import App from './App'
import '@/utils/utils'

export function createApp() {
  const app = createSSRApp(App)

  // 实例化Pinia
  const pinia = createPinia()
  // 传递给项目应用
  app.use(pinia)

  return { app }
}

2.3 定义store

在 Vuex 中我们已经知道 了 store 本质上就是在【定义数据】及【处理数据】的方法,在 Pinia 中 store 也是用来【定义数据】和【处理数据】的方法的,所不同的是定义的方式不同。

  • ref() 就是 state
  • computed 就是 getters
  • function() 就是 actions
// stores/counter.js
import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // 状态数据(相当于 state)
  const count = ref(0)
  // 定义方法(相当于 actions)
  function increment() {
    count.value++
  }

  function decrement() {
    count.value--
  }

  // 一定要将定义的数据和方法返回
  return { count, increment, decrement }
})

此时只需要将 Pinia 定义的 Store 当成普通模块来使用就可以了,详见下面代码:

<!-- pages/pinia/index.vue -->
<script setup>
  import { useCounterStore } from '@/stores/counter'
  // 获取 store 对象
  const store = useCounterStore()
</script>
<template>
  <view class="counter">
    <button @click="store.decrement" class="button" type="primary">-</button>
    <input class="input" v-model="store.count" type="text" />
    <button @click="store.increment" class="button" type="primary">+</button>
  </view>
</template>

2.4 stroreToRefs

使用 storeToRefs 函数可以辅助保持数据(state + getter)的响应式结构,即对数据进行解构处理后仍然能保持响应式的特性。在组件中可以直接使用store数据本身

<!-- pages/pinia/index.vue -->
<script setup>
  import { storeToRefs } from 'pinia'
  import { useCounterStore } from '@/stores/counter'

  // 获取 store 对象
  const store = useCounterStore()
  // 直接使用数据 (state) 本身
  const { count } = storeToRefs(store)
   // store 中的方法直接结构即可
  const { increment, decrement } = store
</script>
<template>
  <view class="counter">
    <button @click="decrement" class="button" type="primary">-</button>
    <input class="input" v-model="count" type="text" />
    <button @click="increment" class="button" type="primary">+</button>
  </view>
</template>

2.5 持久化

在实际开发中我们有写业务需要长期的保存,在 Pinia 中管理数据状态的同时要实现数据的持久化,需要引入pinia-plugin-persistedstate插件

# 安装 pinia-plugin-persistedstate 插件
npm i pinia-plugin-persistedstate

分成3个步骤来使用 pinia-plugin-persistedstate

  1. 将插件添加到 pinia 实例上
// main.js
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

// 省略中间部分代码...

export function createApp() {
  const app = createSSRApp(App)

  // 实例化Pinia
  const pinia = createPinia()
  // Pinia 持久化插件
  pinia.use(piniaPluginPersistedstate)

  // 省略部分代码...
}
  1. 配置需要持久化的数据,创建 Store 时,将 persist 选项设置为 true
import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // 状态数据(相当于 state)
  const count = ref(0)

  // 省略部分代码...

  // 一定要将定义的数据和方法返回
  return { count, increment, decrement }
}, {
  persist: {
    paths: ['count'],
  }
})

当把 persist 设置为 true 表示当前 Store 中全部数据都会被持久化处理, 除此之外还可以通过 paths 来特别指定要持久化的数据。

  1. 自定义存储方法(针对 uni-app的处理)

小程序中和H5中实现本地存储的方法不同,为了解决这个问题需要自定义本地存储的逻辑。

// stores/persist.js
import { createPersistedState } from 'pinia-plugin-persistedstate'
export const piniaPluginPersistedstate = createPersistedState({
  key: (id) => `__persisted__${id}`,
  storage: {
    getItem: (key) => {
      return uni.getStorageSync(key)
    },
    setItem: (key, value) => {
      uni.setStorageSync(key, value)
    },
  },
})

使用 uni-app 的 API uni.getStorageSync 和 uni.setStorageSync 能够同时兼容 H5、小程序以及 App,然后需要调整原来在 main.js 中的部分代码

// main.js
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
// import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import { piniaPluginPersistedstate } from '@/stores/persist'

// 省略中间部分代码...

export function createApp() {
  const app = createSSRApp(App)

  // 实例化Pinia
  const pinia = createPinia()
  // Pinia 持久化插件
  pinia.use(piniaPluginPersistedstate)

  // 省略部分代码...
}

三、uniForm表单验证

不同的组件库有不同的表单验证功能,但是大体上都是相似的。uni-app 的扩展组件中提供了关于表单数据验证

  1. 在表单上使用:model 绑定整体对象
  2. 使用v-model双向绑定数据
  3. 修改name字段
  4. 添加:rule规则
  5. 使用ref操作虚拟DOM出发验证

3.1 表单数据

定义表单的数据,指定 v-model 获取用户的数据

<!-- pages/login/components/account.vue -->
<script setup>
  import { ref, reactive } from 'vue'
  
  // 表单数据
  const formData = reactive({
    account: '',
    password: '',
  })
</script>
<template>
  <uni-forms class="login-form" :model="formData">
    <uni-forms-item name="account">
      <input
        type="text"
        v-model="formData.account"
        placeholder="请输入账号"
        class="uni-input-input"
        placeholder-style="color: #818181"
      />
    </uni-forms-item>
    ...
  </uni-forms>
</template>
  1. uni-forms 需要绑定model属性,值为表单的key\value 组成的对象
  2. uni-forms-item 需要设置 name 属性为当前字段名,字段为 String|Array 类型。

3.2 验证规则

uni-app 的表单验证功能需要单独定义验证的规则:

<!-- pages/login/components/account.vue -->
<script setup>
  import { ref, reactive } from 'vue'
  
  // 表单数据
  const formData = reactive({
    account: '',
    password: '',
  })
  
  // 定义表单数据验证规则
  const accountRules = reactive({
    account: {
      rules: [
        { required: true, errorMessage: '请输入登录账号' },
        { pattern: '^[a-zA-Z0-9]{6,8}$', errorMessage: '登录账号格式不正确' },
      ],
    },
    password: {
      rules: [
        { required: true, errorMessage: '请输入登录密码' },
        { pattern: '^\\d{6}$', errorMessage: '登录密码格式不正确' },
      ],
    },
  })
</script>
<template>
  <uni-forms class="login-form" :rules="accountRules" :model="formData">
    <uni-forms-item name="account">
      <input
        type="text"
        v-model="formData.account"
        placeholder="请输入账号"
        class="uni-input-input"
        placeholder-style="color: #818181"
      />
    </uni-forms-item>
    ...
  </uni-forms>
</template>

在定义 accountRules 时,对象中的 key 对应的是待验证的数据项目,如 account 表示要验证表单数据 account,rules 用来指验证的条件和错误信息提示。

3.3 触发验证

调用 validate 方法触发表单验单

<!-- pages/login/components/account.vue -->
<script setup>
  import { ref, reactive } from 'vue'
  
 	// 表单元素的 ref 属性
  const accountForm = ref()
  
  // 表单数据
  const formData = reactive({
    account: '',
    password: '',
  })
  
  // 定义表单数据验证规则
  const accountRules = reactive({
    account: {
      rules: [
        { required: true, errorMessage: '请输入登录账号' },
        { pattern: '^[a-zA-Z0-9]{6,8}$', errorMessage: '登录账号格式不正确' },
      ],
    },
    password: {
      rules: [
        { required: true, errorMessage: '请输入登录密码' },
        { pattern: '^\\d{6}$', errorMessage: '登录密码格式不正确' },
      ],
    },
  })
  
  // 监听表单的提交
  async function onFormSubmit() {
    try {
      // 验证通过
      const formData = await accountForm.value.validate()
      // 表单的数据
      console.log(formData)
    } catch (err) {
      // 验证失败
      console.log(err)
    }
  }
</script>
<template>
  <uni-forms
    class="login-form"
    ref="accountForm"
    :rules="accountRules"
    :model="formData"
  >
    <uni-forms-item name="account">
      <input
        type="text"
        v-model="formData.account"
        placeholder="请输入账号"
        class="uni-input-input"
        placeholder-style="color: #818181"
      />
    </uni-forms-item>
    ...
    <button @click="onFormSubmit" class="submit-button">登录</button>
  </uni-forms>
</template>

Day2 分页功能开发

节流防抖

这个分页的业务重新写一下

  1. 任务列表 getAnnounceList --page = 1 pageSize = 10
  1. 传入messageApi.list(200, page, pageSize)
  • 掌握v-if和v-show的区别
  • 掌握uniapp滚动分页
  • 掌握uniapp条件编译

一、前置知识

1.1 v-if和v-show

控制手段:

v-show隐藏则是为该元素添加css--display:nonedom元素依旧还在。

v-if显示隐藏是将dom元素整个添加或删除

编译过程:

v-show只是简单的基于css切换,不会触发组件的生命周期

v-if切换过程中合适地销毁和重建内部的事件监听和子组件

二、任务通知切换

在 uni-app 跨端开发时,小程序不支持内置组件 Component 和 Keep-Alive,在实现类似标签页切换的功能时就变得十分的麻烦,在本项目中采用的是组合 v-if 和 v-show 指令来实现。

  • v-if 指令保证组件只被加载一次
  • v-show 指令控制组件的显示/隐藏
<!-- pages/message/index.vue -->
<script setup>
  import { ref, reactive } from 'vue'
  
  import slNotify from './components/notify'
  import slAnnounce from './components/announce'

  // Tab 标签页索引
  const tabIndex = ref(0)
  const tabMetas = reactive([
    {title: '任务通知',rendered: true},
    { title: '公告',rendered: false},
  ])
  // 切换标签页
  function onTabChange(index) {
    tabMetas[index].rendered = true
    tabIndex.value = index
  }
</script>

<template>
  <view class="page-container">
    ...
    <view v-show="tabIndex === 0" v-if="tabMetas[0].rendered" class="message-list">
      <sl-notify />
    </view>
    <view v-show="tabIndex === 1" v-if="tabMetas[1].rendered" class="message-list">
      <sl-announce />
    </view>
  </view>
</template>
  • rendered 属性为 true 表明组件加载过,配合 v-if 来使用
  • tabIndex 变量控制当前显示哪个组件,配合 v-show 来使用

二、滚动分页

2.1 监听 scrolltolower 事件

在项目中都会支持分页获取数据,在移动设备是常常配合页面的滚动来分页获取数据,在 scroll-view 组件中通过 scrolltolower 监听页面是否滚动到达底部,进而触发新的请求来获取分页的数据。

<scroll-view
    @scrolltolower="onScrollToLower"
    class="scroll-view"
    refresher-enabled
    scroll-y
  >
    ...
  </scroll-view>

2.2 计算下一页页码

页码是数字具具有连续性,我们只需要热行加 1 操作即可

// 任务列表
  async function getNotifyList(page = 1, pageSize = 10) {
    const { code, data } = await messageApi.list(201, page, pageSize)
    // 检测接口是否调用成功
    if (code !== 200) return uni.utils.toast('获取通知失败,稍后重试!')
    // 渲染数据
    notifyList.value = [...notifyList.value, ...(data.items || [])]
    // 更新下一页页码
    nextPage.value = ++data.page
    // 是否为空列表
    isEmpty.value = notifyList.value.length === 0
  }

上述代码中有两点需要注意:

  • 更新页面是根据返回数据中的 page 加 1 的方式处理的
  • 分页请求来的下一页数据需要追加到原数组中,在这里用的 ... 运算符,也可以使用数组的 concat 方法

2.3 判断是否有更多数据

通过对比总的页码与当前页面的大小来判断,如果没有数据不必发送请求

<!-- pages/message/components/notify.vue -->
<script setup>
  import { ref, onMounted } from 'vue'
  import messageApi from '@/apis/message'

  // 省略中间部分代码...
  
  // 是否还有更多数据
  const hasMore = ref(true)
	
	// 省略中间部分代码...

  // 上拉分页
  function onScrollToLower() {
    if(!hasMore.value) return
    // 获取下一页数据
    getNotifyList(nextPage.value)
  }

  // 任务列表
  async function getNotifyList(page = 1, pageSize = 10) {
   ...
  }
</script>

上述代码中需要注意判断还有没有更多数据,是根据接口返回数据中的总页码 pages 进行判断的,如果下一页的页码 nextPage 小于等于总页码 data.page 时,表明还有更多的数据,否则没有更多数据了。

2.4 下拉刷新

在 scroll-view 中监听 refresherrefresh 事件,能够知道用户是否执行了下拉的动作

// 获取任务通知
  async function getNotifyList(page = 1, pageSize = 5) {
    ...
    // 如果请求的是第1页那么将notifyList 置为空数组
    if (page === 1) notifyList.value = []
    ...
  }

2.5 关闭下拉刷新的动画交互

<!-- pages/message/components/notify.vue -->
<script setup>
  // 下拉刷新
  async function onScrollViewRefresh() {
    isTriggered.value = true
    await getNotifyList()
    isTriggered.value = false
  }
</script>
<template>
  <scroll-view
    @refresherrefresh="onScrollViewRefresh"
    @scrolltolower="onScrollToLower"
    :refresher-triggered="isTriggered"
    class="scroll-view"
    refresher-enabled
    scroll-y
  >
  ...   
  </scroll-view>
</template>

三、任务详情

3.1 条件编译

是 H5 端扫码的功能是受限的,因此可以通过条件编译的方式来针对不同的平台展示不同的页面内容。注:具体的扫码功能暂时还没有支持。

<!-- subpkg_task/detail/index.vue -->
<template>
  <view class="page-container">
    <view class="search-bar">
      <!-- #ifdef H5 -->
      <text class="iconfont icon-search"></text>
      <!-- #endif -->
      <!-- #ifdef APP-PLUS | MP -->
      <text class="iconfont icon-scan"></text>
      <!-- #endif -->
      <input class="input" type="text" placeholder="输入运单号" />
    </view>
    <scroll-view scroll-y class="task-detail">
      ...
    </scroll-view>
    <view class="toolbar" v-if="true">
      ...
  	</view>
  </view>
</template>

以上代码中的编译条件:

  • H5 对应浏览器端
  • APP-PLUS 对应 App 端
  • MP 对应所有小程序端

Day3 主要业务功能开发

学习目标:

  • 掌握模板字符串的使用
  • 页面携带参数传参
  • uniapp声明周期
  • 通过计算属性统计延迟原因字数

一、前置知识

1.1 模板字符串

模板字符串(template string)是增强版的字符串,用反引号(`)标识。它可以当作普通字符串使用,也可以用来定义多行字符串,或者在字符串中嵌入变量。

模板字符串中嵌入变量,需要将变量名写在${}之中。 注:变量名使用前要先声明

function authorize(user, action) {
  if (!user.hasPrivilege(action)) {
    throw new Error(
      // 传统写法为
      // 'User '
      // + user.name
      // + ' is not authorized to do '
      // + action
      // + '.'
      `User ${user.name} is not authorized to do ${action}.`);
  }
}

参考:ES6入门

1.2 获取上个页面参数

在 onLoad 里得到,onLoad 的参数是其他页面打开当前页面所传递的数据

1.3 页面跳转

wx.navigateTo :按顺序向页面栈中放打开过的页面,返回会回到上一级页面

wx.redirectTo:将页面栈的最后一个元素替换,返回会到上级的上级页面

wx.reLaunch:将页面栈的所有元素删除,返回只能回到首页

二、 任务模块开发

2.1 延迟收货功能开发

2.1.1 获取表单数据

在跳转到延迟题目页面携带参数为当前【任务ID】和【原计划提货时间】

<!-- pages/subpkg_task/detail.vue -->
<navigator
:url="`/subpkg_task/delay/index?id=${taskDetail.id}&planDepartureTime=${taskDetail.planDepartureTime}`"
hover-class="none"
class="button secondary"
>
延迟提货
</navigator>

在延迟提货页面获取地址参数

<!-- subpkg_task/delay/index.vue -->
  // 获取地址上的参数
  onLoad((query) => {
    // 原计划提货时间
    planDepartureTime.value = query.planDepartureTime
    // 延迟提货任务的ID
    id.value = query.id
  })
2.1.2 表单数据

1、延迟时间

使用内置组件 picker 将其 mode 属性指定为 time,用户进行选择后会触发 change 事件,监听该事件即可得到用户选择的时间。

<!-- subpkg_task/delay/index.vue -->
<view class="page-container">
  ...
  <picker @change="onPickerChange" class="time-picker" mode="time">
    <text v-if="!delayTime">不可超过2个小时</text>
    <text v-else>{{ delayTime + ':00' }}</text>
  </picker>
  ...
</view>

2、延迟原因

直接使用 v-model 来获取即可,除了获取数据还需要对字数进行统计

通过计算属性来统计延迟原因的字数

// 统计输入的字数
  const wordsCount = computed(() => delayReason.value.length)

3、数据校验

  1. 日期校验:延迟时间不超过2小时
  2. 延迟原因校验:长度<50 || >0
  3. 不符合条件输入框红色提示
  4. 提交按钮disable
  5. 提交成功跳转任务列表
<template>
  <view class="page-container">
    <uni-list :border="false">
      <uni-list-item
        title="原定时间 "
        showArrow
        :right-text="planDepartureTime"
      />
      <uni-list-item title="延迟时间" showArrow>
        <template v-slot:footer>
          <picker @change="onPickChange" class="time-picker" mode="time">
            <text v-if="!delayTime">不可超过2个小时</text>
            <text v-else :class="{ error: !delayTimeValid }">{{
              delayTime + ':00'
            }}</text>
          </picker>
        </template>
      </uni-list-item>
      <uni-list-item direction="column">
        <template v-slot:body>
          <view class="textarea-wrapper">
            <textarea
              class="textarea"
              v-model="delayReason"
              placeholder-style="color: #818181"
              placeholder="请输入延迟提货原因"
            ></textarea>
            <text :class="{ error: wordsCount > 50 }">{{ wordsCount }}/50</text>
          </view>
        </template>
      </uni-list-item>
      <uni-list-item :border="false">
        <template v-slot:body>
          <button @click="onSubmitForm" :disabled="enableSubmit" class="button">
            提交
          </button>
        </template>
      </uni-list-item>
    </uni-list>
  </view>
</template>

2.2 提货

2.2.1 uniCloud
  1. 新建服务空间
  2. 新建 uniCloud 开发环境
  3. 关联服务空间
  4. 获取 AppID
  5. 重新启动项目
2.2.2 uni-file-picker

uni-file-picker 是 uni-app 的扩展组件,用于实现文件上传的功能。

<!-- subpkg_task/pickup/index.vue -->
<script setup>
  import { ref } from 'vue'

	// "https://mp-664d2554-cfa5-4427-9adf-d14025991d5f.cdn.bspapp.com/cloudstorage/40edf9ed-9f29-48b7-ab29-425abe351dea.jpg"
  
  // 提货凭证图片
  const receiptPictrues = ref([])
  // 货品图片
  const goodsPictrues = ref([])

</script>
<template>
  <view class="page-container">
    <view class="receipt-info">
      <uni-file-picker
        v-model="receiptPictrues"
        file-extname="jpg,webp,gif,png"
        limit="3"
        title="请拍照上传回单凭证"
      ></uni-file-picker>
      <uni-file-picker
        file-extname="jpg,webp,gif,png"
        limit="3"
        title="请拍照上传货品照片"
      ></uni-file-picker>
    </view>
    <button disabled class="button">提交</button>
  </view>
</template>
  • title 属性定义标题用于提示上传文件的内容
  • limit 限制上传文件的数量
  • file-extname 限制上传文件的类型
  • v-model 用于回显上传的图片
  • @success 上传成功的回调(了解即可)
  • @fail 上传失败的回调(了解即可)
2.2.3 提交表单

种类型的图片最少1张,最多3张图片,通过计算属性来进行验证,然后根据接口文档传递参数

2.3 在途

司机完成提货后,运输的任务状态即 status 的值会变成 2,获取在途列表的数据时所使用的接口与待提货是相同的,区别是传入的状态值有差异。

任务详情

当任务状态处于在途时,已经完成了提货的操作,因此在详情面面需要展示提货时提交的照片。

<!-- subpkg_task/detail/index.vue -->
<script setup>
  // 
</script>
<template>
  <view class="page-container">
		...
    <scroll-view scroll-y class="task-detail">
      <view class="scroll-view-wrapper">
				...
        <view v-if="taskDetail.status >= 2" class="panel pickup-info">
          <view class="panel-title">提货信息</view>
          <view class="label">提货凭证</view>
          <view class="pictures">
            <image
              v-for="receipt in taskDetail.cargoPictureList"
              :key="receipt.url"
              class="picture"
              :src="receipt.url"
            />
          </view>
          <view class="label">货品照片</view>
          <view class="pictures">
            <image
              v-for="goods in taskDetail.cargoPickUpPictureList"
              :key="goods.url"
              class="picture"
              :src="goods.url"
            />
          </view>
        </view>
				...
      </view>
    </scroll-view>
		...
  </view>
</template>

2.4 异常上报

异常上报异常上报是指司机在运输途中遇到的一些突发状况,这些突发状可能会导致运输到达时间有所延迟,司机需要将遇到的突发情况及时上报,方便管理端进行管理。

异常上报功能模块中包含交互相对要多一些,略为复杂一些,大家一定要耐心。

2.4.1 异常时间

异常时间的获取用到了 uni-app 的扩展组件 uni-datetime-pickeruni-list

  • v-model 获取用户选择的时间
  • v-slot:footer 插槽用于自定义 uni-list-item 右侧展示的内容
<!-- subpkg_task/except/index.vue -->
<script setup>
  import { ref, computed } from 'vue'
  
  // 省略了中间部分代码...
  
  // 获取异常时间
  const exceptionTime = computed(() => {
    return timePicker.value || '请选择'
  })
  // 扩展组件时间初始值
  const timePicker = ref('')
  
</script>
<template>
  <view class="page-container">
    <scroll-view class="scroll-view" scroll-y>
      <view class="scroll-view-wrapper">
        <uni-list :border="false">
          <uni-list-item show-arrow title="异常时间">
            <template v-slot:footer>
              <uni-datetime-picker v-model="timePicker">
                <view class="picker-value">{{ exceptionTime }}</view>
              </uni-datetime-picker>
            </template>
          </uni-list-item>
					...
        </uni-list>
      </view>
    </scroll-view>
    ...
  </view>
</template>
2.4.2 上报位置

上报位置需要调用 wx.chooseLocation 来获取用户所在位置信息,在 uni-app 中调用 wx.chooseLocation 时需要指定地图服务平台申请的 key,并且地图的使用存在一定的兼容性。

地图服务商

App

H5

小程序

高德

Google

仅nvue页面

腾讯

对比后我们选择腾讯位置服务平台,之前在享+生活中介绍过如何创建应用以及申请 key,这里文档就不再缀述了。

拍照上传 Request failed with status code 413 拍照上传违章车辆_前端框架

以上均是相关的配置准备工作,目的是保证 uni.chooseLocation 能够成功调用

<!-- subpkg_task/except/index.vue -->
<script setup>
  import { ref, computed } from 'vue'
  
  // 省略中间部分代码...
  
  // 上报位置数据
  const exceptionPlace = ref('')
  // 打开地图,选择位置
  async function onLocationChoose() {
    try {
      // 获取位置
      const { address } = await uni.chooseLocation({})
      exceptionPlace.value = address
    } catch (err) {}
  }
  
  // 省略中间部分代码...
</script>
<template>
  <view class="page-container">
    <scroll-view class="scroll-view" scroll-y>
      <view class="scroll-view-wrapper">
        <uni-list :border="false">
          ...
					<uni-list-item
            show-arrow
            clickable
            ellipsis="1"
            @click="onLocationChoose"
            title="上报位置"
            :right-text="exceptionPlace || '请选择'"
          />
        </uni-list>
      </view>
    </scroll-view>
    ...
  </view>
</template>
2.4.3 异常类型

异常类型使用到了uni-app的扩展组件 uni-popup

  1. 在实现相关逻辑之前先来整理下 uni-popup 所展示的数据
<!-- subpkg_task/except/index.vue -->
<script setup>
  import { ref, computed, reactive } from 'vue'
  
  // 省略中间部分代码...
  
    // 定义 popup 组件展示的数据
  const exceptionTypes = reactive([
    '发动机启动困难',
    '不着车,漏油',
    '照明失灵',
    '排烟异常、温度异常',
    '其他问题',
  ])
  
  // 省略中间部分代码...
  
</script>

<template>
  <view class="page-container">
    ...
    <uni-popup ref="popup" type="bottom">
      <uni-list class="popup-action-sheet">
        ...
        <uni-list-item
          v-for="exceptionType in exceptionTypes"
          :key="exceptionType"
          :title="exceptionType"
        >
          <template v-slot:footer>
            <checkbox-group class="checkbox">
              <checkbox :value="exceptionType" color="#EF4F3F" />
            </checkbox-group>
          </template>
        </uni-list-item>
        ...
      </uni-list>
    </uni-popup>
  </view>
</template>
  1. 选择并获取异常的类型,允许多选,选项之间使用 | 拼接

在用户选择了 checkbox 内置组件后会触发 change 事件,进而获取所选择的内容,由于允许多选所以先将用户选择的异常类型存一个临时数组中,当用户在点击确定时再将这个临时数组使用 | 拼凑成一个字符串。

  • 将用户选择的异常类型存入数组,注意 change 事件要监听在 checkbox-group 组件上
<!-- subpkg_task/except/index.vue -->
<script setup>
  import { ref, computed } from 'vue'
  
  // 省略中间部分代码...
  
    // 定义 popup 组件展示的数据
  const exceptionTypes = reactive([
    '发动机启动困难',
    '不着车,漏油',
    '照明失灵',
    '排烟异常、温度异常',
    '其他问题',
  ])
  
  // 临时记录异常类型选项(可以不必是响应式)
  const tempException = []
  // 监听用户的选择操作
  function onCheckboxChange(ev) {
    // 将用户选择的异常类型存储到数组中
    tempException.push(ev.detail.value)
  }
  
  // 省略中间部分代码...
  
</script>

<template>
  <view class="page-container">
    ...
    <uni-popup ref="popup" type="bottom">
      <uni-list class="popup-action-sheet">
        ...
        <uni-list-item
          v-for="exceptionType in exceptionTypes"
          :key="exceptionType"
          :title="exceptionType"
        >
          <template v-slot:footer>
            <checkbox-group @change="onCheckboxChange" class="checkbox">
              <checkbox :value="exceptionType" color="#EF4F3F" />
            </checkbox-group>
          </template>
        </uni-list-item>
        ...
      </uni-list>
    </uni-popup>
  </view>
</template>
  • 在用户点击弹层 uni-popup 上的按钮后,不仅要关闭弹层还要将用户选择的异常类型使用 | 拼凑成字符串
<!-- subpkg_task/except/index.vue -->
<script setup>
  import { ref, computed } from 'vue'
  
  // 省略中间部分代码...
  
    // 定义 popup 组件展示的数据
  const exceptionTypes = reactive([
    '发动机启动困难',
    '不着车,漏油',
    '照明失灵',
    '排烟异常、温度异常',
    '其他问题',
  ])
  
  // 异常类型,多个选项间使用 | 拼接
  const exceptionType = ref('')
  
  // 临时记录异常类型选项(可以不必是响应式)
  const tempException = []
  // 监听用户的选择操作
  function onCheckboxChange(ev) {
    // 将用户选择的异常类型存储到数组中
    tempException.push(ev.detail.value)
  }
  
  // 用户点击 popup 确定按钮
  function onPopupConfirm() {
    // 关闭 popup 弹层
    popup.value.close()
    // 将获取的异常类型拼凑成字符串
    exceptionType.value = tempException.join('|')
  }
  
  // 省略中间部分代码...
  
</script>

<template>
  <view class="page-container">
    <scroll-view class="scroll-view" scroll-y>
      <view class="scroll-view-wrapper">
        <uni-list :border="false">
    			...
          <uni-list-item
            show-arrow
            clickable
            @click="onPopupOpen"
            title="异常类型"
            :right-text="exceptionType || '请选择'"
          />
  			</uni-list>
  		</view>
  	</scroll-view>
    ...
    <uni-popup ref="popup" type="bottom">
      <uni-list class="popup-action-sheet">
        ...
        <uni-list-item>
          <template v-slot:body>
            <button @click="onPopupConfirm" class="button">确定</button>
          </template>
        </uni-list-item>
      </uni-list>
    </uni-popup>
  </view>
</template>

=======================================================================================

上述代码存在着bug,下面代码部分为修复优化的代码,只保留了与切换选中状态相关的代码

=======================================================================================

<!-- subpkg_task/except/index.vue -->
<script setup>
  import { ref, computed, reactive } from 'vue'
  import { onLoad } from '@dcloudio/uni-app'
  import taskApi from '@/apis/task'

  // 组件 ref
  const popup = ref(null)
  // 异常日期
  const timePicker = ref('')

  // 定义 popup 组件展示的数据
  const exceptionTypes = reactive([
    { text: '发动机启动困难', checked: true },
    { text: '不着车,漏油', checked: false },
    { text: '照明失灵', checked: false },
    { text: '排烟异常、温度异常', checked: false },
    { text: '其他问题', checked: false },
  ])

	// 省略中间部分代码...
  
  // 异常的类型
  const exceptionType = ref('')

	// 省略中间部分代码...

  // 监听用户选择类型
  function onCheckboxChange(index) {
    // 切换选中状态
    exceptionTypes[index].checked = !exceptionTypes[index].checked
  }

	// 省略了中间部分代码...

  // 关闭弹层
  function onPopupClose() {
    popup.value.close()

    // 获取用户选择的类型并回显到页面,即将 checked 属性为 true 单元取出
    exceptionType.value = exceptionTypes
      .filter((type) => {
        return type.checked // 过滤出选中的类型
      })
      .map((type) => {
        return type.text // 只保留 text 属性
      })
      .join('|') // 将选择的类型用 | 拼接并回显
  }
</script>

由于改变了 exceptionTypes 数组的结构,与之对应的模板也要相对应的调整

<!-- subpkg_task/except/index.vue -->
<template>
  <view class="page-container">
		...
    <uni-popup ref="popup" type="bottom">
      <uni-list class="popup-action-sheet">
				...
        <uni-list-item
          v-for="(exceptionType, index) in exceptionTypes"
          :key="exceptionType.text"
          :title="exceptionType.text"
        >
          <template v-slot:footer>
            <checkbox-group @change="onCheckboxChange(index)" class="checkbox">
              <checkbox :checked="exceptionType.checked" color="#EF4F3F" />
            </checkbox-group>
          </template>
        </uni-list-item>
        <uni-list-item>
          <template v-slot:body>
            <button @click="onPopupClose" class="button">确定</button>
          </template>
        </uni-list-item>
      </uni-list>
    </uni-popup>
  </view>
</template>
2.4.4 异常描述

在获取异常描述时只需要通过 v-model 即可,同时对异常描述的字数进行统计。

<!-- subpkg_task/except/index.vue -->
<script setup>
  import { ref, computed } from 'vue'
  
  // 省略中间部分代码...

  // 异常描述
  const exceptionDescribe = ref('')
  // 异常描述字数统计
  const wordsCount = computed(() => {
    return exceptionDescribe.value.length
  })
  
  // 省略中间部分代码...
  
</script>
<template>
  <view class="page-container">
    <scroll-view class="scroll-view" scroll-y>
      <view class="scroll-view-wrapper">
        <uni-list :border="false">
    			...
					<uni-list-item direction="column" title="异常描述">
            <template v-slot:footer>
              <view class="textarea-wrapper">
                <textarea
                  v-model="exceptionDescribe"
                  class="textarea"
                  placeholder="请输入异常描述"
                /></textarea>
                <view class="words-count">{{ wordsCount }}/50</view>
              </view>
            </template>
          </uni-list-item>
  			</uni-list>
  		</view>
  	</scroll-view>
    ...
  </view>
</template>

注:异常描述的字数限制为50,关于字数的验证在延迟提货的章节已经实现过了,大家可以去参考下。

2.4.5 现场照片

获取现场照片需要司机上传图片到云存储,然后将云存储中的图片地址发送给服务端接口,该部分的实现逻辑与提货是一致的。

<!-- subpkg_task/except/index.vue -->
<script setup>
  import { ref, computed } from 'vue'
  
  // 省略中间部分代码...

  // 货品图片
  const goodsPictrues = ref([])
  // 数据二次处理,只保留 url 属性
  const exceptionImagesList = computed(() => {
    return goodsPictrues.value.map(({ url }) => {
      return { url }
    })
  })
  
  // 省略中间部分代码...
  
</script>
<template>
  <view class="page-container">
    <scroll-view class="scroll-view" scroll-y>
      <view class="scroll-view-wrapper">
        ...
        <uni-list class="upload-picture">
          <uni-list-item direction="column" title="上传图片(最多3张)">
            <template v-slot:footer>
              <uni-file-picker
                v-model="goodsPictrues"
                file-extname="jpg,webp,gif,png"
                limit="3"
              ></uni-file-picker>
            </template>
          </uni-list-item>
        </uni-list>
  		</view>
  	</scroll-view>
    ...
  </view>
</template>
1.4.6 提交数据

将全部的异常数据获取完毕后提交数据给服务端接口,接口文档的详细说明在这里。

  1. 封装调用接口的方法
// apis/task.js
// 引入网络请求模块
import { uniFetch } from './uni-fetch'

export default {
  // 省略中间部分代码...
  
  /**
   * 上报异常
   * @param {Object} data - 接口数据
   */
  except(data) {
    return uniFetch.post('/driver/tasks/reportException', data)
  }
}
  1. 监听用户点击提交按钮
<!-- subpkg_task/except/index.vue -->
<script setup>
  import { ref, computed, reactive } from 'vue'
  import { onLoad } from '@dcloudio/uni-app'
  import taskApi from '@/apis/task'
  
  // 中间省略部分代码...
  
  // 运输任务ID
  const transportTaskId = ref('')
  onLoad((query) => {
    transportTaskId.value = query.transportTaskId
  })
  
  // 中间省略部分代码...
  
  // 提交数据
  async function onFormSubmit() {
    // 待提交的数据
    const formData = {
      transportTaskId: transportTaskId.value,
      exceptionTime: exceptionTime.value,
      exceptionPlace: exceptionPlace.value,
      exceptionType: exceptionType.value,
      exceptionDescribe: exceptionDescribe.value,
      exceptionImagesList: exceptionImagesList.value,
    }
    const { code } = await taskApi.except(formData)
    // 检测接口是否调用成功
    if (code !== 200) return uni.utils.toast('上报数据失败!')
    // 跳转到任务列表页面
    uni.reLaunch({ url: '/pages/task/index' })
  }
  
  // 省略中间部分代码...
  
</script>
<template>
  <view class="page-container">
    <scroll-view class="scroll-view" scroll-y>
      ...
  	</scroll-view>
    <view class="fixbar">
      <button @click="onFormSubmit" class="button disable">提交</button>
    </view>
    ...
  </view>
</template>
2.4.7 任务详情

司机上报异常后,到任务详情当中即可看到上报的异常信息,我来到任务详情中来补充这部分数据的显示

<!-- subpkg_task/detail/index.vue -->
<script setup>
  // 
</script>
<template>
  <view class="page-container">
		...
    <scroll-view scroll-y class="task-detail">
      <view class="scroll-view-wrapper">
				...
        <view v-if="taskDetail.exceptionList" class="except-info panel">
          <view class="panel-title">异常信息</view>
          <view
            v-for="exception in taskDetail.exceptionList"
            :key="exception.exceptionType"
            class="info-list"
          >
            <view class="info-list-item">
              <text class="label">上报时间</text>
              <text class="value">{{ exception.exceptionTime }}</text>
            </view>
            <view class="info-list-item">
              <text class="label">异常类型</text>
              <text class="value">{{ exception.exceptionType }}</text>
            </view>
            <view class="info-list-item">
              <text class="label">处理结果</text>
              <text class="value">{{ exception.handleResult }}</text>
            </view>
          </view>
        </view>
				...
      </view>
    </scroll-view>
		...
  </view>
</template>

由于异常数据可能有也可能没有,因此在进行渲染前需要通过 v-if 条件判断,exceptionList 对应上报的异常数据。

Day4 任务模块开发与打包

一、任务模块开发

1.1 交付

1.1.1上传图片

使用 uni-file-picker 将图片上传到云空间,务必保证已经创建并关联了 uniCloud 空间

<!-- subpkg_task/delivery/index.vue -->
<script setup>
  import { ref } from 'vue'
  import { onLoad } from '@dcloudio/uni-app'
  // 提货凭证照片
  const receiptPictrues = ref([])
  // 提货商品照片
  const goodsPictrues = ref([])
</script>
<template>
  <view class="page-container">
    <view class="receipt-info">
      <uni-file-picker
        v-model="receiptPictrues"
        file-extname="jpg,webp,gif,png"
        limit="3"
        title="请拍照上传回单凭证"
      ></uni-file-picker>
      <uni-file-picker
        v-model="goodsPictrues"
        file-extname="jpg,webp,gif,png"
        limit="3"
        title="请拍照上传货品照片"
      ></uni-file-picker>
    </view>
    <button disabled class="button">提交</button>
  </view>
</template>

使用计算属性实现图片数量和验证,通过map过滤处理表单数据

...
  const receiptPictrues = ref([])
  // 过滤掉多余的数据,只保留 url
  const certificatePictureList = computed(() => {
    return receiptPictrues.value.map(({ url }) => {
      return { url }
    })
  })

  // 提货商品照片
  const goodsPictrues = ref([])
  // 过滤掉多余的数据,只保留 url
  const deliverPictureList = computed(() => {
    return goodsPictrues.value.map(({ url }) => {
      return { url }
    })
  })

  // 凭证和商品都至少上传一张图片
  const enableSubmit = computed(() => {
    return goodsPictrues.value.length > 0 && receiptPictrues.value.length > 0
  })
...
1.1.2 在途列表

司机在完成运输交付后,运输任的状态会变成 4 ,此时在【在途列表】和【任务详情】中显示的操作应该是【回车登记】。

<!-- pages/task/components/delivery/index.vue -->
<script setup>
  // 此处不需要添加代码
</script>
<template>
	<scroll-view scroll-y refresher-enabled class="scroll-view">
    <view class="scroll-view-wrapper">
  		<view v-for="delivery in deliveryList" :key="delivery.id" class="task-card">
        ...
        <view class="footer">
          <view class="label">到货时间</view>
          <view class="time">{{ delivery.planArrivalTime }}</view>
          <navigator
            v-if="delivery.status === 2"
            hover-class="none"
            :url="`/subpkg_task/delivery/index?id=${delivery.id}`"
            class="action"
          >
            交付
          </navigator>
          <navigator
            v-if="delivery.status === 4"
            hover-class="none"
            :url="`/subpkg_task/record/index?transportTaskId=${delivery.transportTaskId}`"
            class="action"
          >
            回车登记
          </navigator>
        </view>
      </view>
      <view v-if="isEmpty" class="task-blank">无在途货物</view>
  	</view>
  </scroll-view>
</template>
1.1.3 任务详情
<!-- subpkg_task/detail/index.vue -->
<script setup>
  // 此时不需要添加代码...
</script>
<template>
  <view class="page-container">
    <view class="search-bar">
      <!-- #ifdef H5 -->
      <text class="iconfont icon-search"></text>
      <!-- #endif -->

      <!-- #ifdef APP-PLUS | MP -->
      <text class="iconfont icon-scan"></text>
      <!-- #endif -->
      <input class="input" type="text" placeholder="输入运单号" />
    </view>
    <scroll-view scroll-y class="task-detail">
      <view class="scroll-view-wrapper">
        <view class="basic-info panel">
          <view class="panel-title">基本信息</view>
          ...
        </view>

        <view v-if="taskDetail.exceptionList?.length" class="except-info panel">
          <view class="panel-title">异常信息</view>
          ...
        </view>

        <view v-if="taskDetail.status >= 2" class="panel pickup-info">
          <view class="panel-title">提货信息</view>
          ...
        </view>

        <view
          v-if="taskDetail.status === 4 || taskDetail.status === 6"
          class="delivery-info panel"
        >
          <view class="panel-title">交货信息</view>
          <view class="label">交货凭证</view>
          <view class="pictures">
            <image
              v-for="certificate in taskDetail.certificatePictureList"
              :key="certificate.url"
              class="picture"
              :src="certificate.url"
            ></image>
            <view v-if="false" class="picture-blank">暂无图片</view>
          </view>
          <view class="label">货品照片</view>
          <view class="pictures">
            <image
              v-for="delivery in taskDetail.deliverPictureList"
              :key="delivery.url"
              class="picture"
              :src="delivery.url"
            ></image>
            <view v-if="false" class="picture-blank">暂无图片</view>
          </view>
        </view>
      </view>
    </scroll-view>
		...
  </view>
</template>

1.2 回车登记

回车登记是在司机完运输交付要完成的最后一项操作,该功能是让司机对整个运输过程情况做补充说明,如运输途中有没有交通违章、交通事故、车辆故障等。

1.2.1 组件交互

交通违章、车辆故障、交通事故都是独立的组件,并且这些组件中包含了两个交互,一个是显示/隐藏选项、另一个是用户点击选择选项,以交通违章为例给大家进行说明。

  1. 显示/隐藏选项
<!-- subpkg_task/record/components/vehicle-violation.vue -->
<script setup>
  import { ref } from 'vue'

  // 是不显示详细的选项
  const show = ref(false)

 // 省略了中间部分代码...

  function onRadioChange(ev) {
    // 展开详细的选项
    show.value = !!parseInt(ev.detail.value)
  }
</script>
<template>
  <view class="vehicle-panel">
    <view class="vehicle-panel-header">
      <view class="label">交通违章</view>
      <radio-group class="radio-group" @change="onRadioChange">
        <label class="label">
          <radio class="radio" value="1" color="#EF4F3F" />
          <text>是</text>
        </label>
        <label class="label">
          <radio class="radio" checked value="0" color="#EF4F3F" />
          <text>否</text>
        </label>
      </radio-group>
    </view>
    <view v-show="show" class="vehicle-panel-body">
      ...
    </view>
  </view>
</template>
  1. 自定义公共组件,在交通违章、车辆故障、交通事件中包含了共同点击选择的交互,我们将这部分的交互封装到组件当中。
<!-- subpkg_task/record/components/vehicle-options.vue -->
<script setup>
  import { ref } from 'vue'

  // 当前被选中选项的索引值
  const tabIndex = ref(-1)

  // 接收传入组件的数据
  const props = defineProps({
    types: Array,
  })

  // 点击选中选项
  function onOptionSelect(index) {
    // 高亮显示选中的选项
    tabIndex.value = index
  }
</script>

<template>
  <view class="vehicle-options">
    <view
      class="option"
      :class="{ active: tabIndex === index }"
      v-for="(option, index) in props.types"
      :key="option.id"
      @click="onOptionSelect(index)"
    >
      {{ option.text }}
    </view>
  </view>
</template>

<style lang="scss" scoped>
  .vehicle-options {
    display: flex;
    flex-wrap: wrap;
    font-size: $uni-font-size-small;

    .option {
      width: 180rpx;
      height: 70rpx;
      text-align: center;
      line-height: 72rpx;
      margin-top: 30rpx;
      margin-right: 38rpx;
      color: $uni-secondary-color;
      border: 2rpx solid $uni-bg-color;
      background-color: $uni-bg-color;
      border-radius: 20rpx;

      &:nth-child(3n) {
        margin-right: 0;
      }

      &.active {
        color: $uni-primary;
        border: 2rpx solid $uni-primary;
        background-color: #ffe0dd;
      }
    }
  }
</style>
1.2.2 组件传参

用户点击选择了选项后,我们需要记录用户所选择的是哪个类型的哪个值。

  1. 用户选择的哪个值 ,在用户进行点击时通过参数传入
<!-- subpkg_task/record/components/vehicle-options.vue -->
<script setup>
  import { ref } from 'vue'

	// 此处省略中间部分代码...

  // 点击选中选项
  function onOptionSelect(index, text) {
    // 高亮显示选中的选项
    tabIndex.value = index
    // 用户选择了哪个值
    console.log(text)
  }
</script>

<template>
  <view class="vehicle-options">
    <view
      class="option"
      :class="{ active: tabIndex === index }"
      v-for="(option, index) in props.types"
      :key="option.id"
      @click="onOptionSelect(index, option.text)"
    >
      {{ option.text }}
    </view>
  </view>
</template>
  1. 确定用户选择了哪个类型,为组件自定义一个属性 dataKey 通过 dataKey 来区分用户当前点击的是哪个类型
<!-- subpkg_task/record/components/vehicle-options.vue -->
<script setup>
  import { ref } from 'vue'

	// 此处省略中间部分代码...
  
  // 接收传入组件的数据
  const props = defineProps({
    types: Array,
    dataKey: String,
  })

  // 点击选中选项
  function onOptionSelect(index, text) {
    // 高亮显示选中的选项
    tabIndex.value = index
    
    // 用户选择的是哪个类型
    console.log(props.dataKey)
    // 用户选择了哪个值
    console.log(text)
  }
</script>

<template>
  <view class="vehicle-options">
    <view
      class="option"
      :class="{ active: tabIndex === index }"
      v-for="(option, index) in props.types"
      :key="option.id"
      @click="onOptionSelect(index, option.text)"
    >
      {{ option.text }}
    </view>
  </view>
</template>

在应用组件 vehicle-options 组件,为其传入一个 data-key 属性,该属性的值用来区分所选择的值是哪个类型的。

<!-- subpkg_task/record/components/vehicle-violation.vue -->
<script setup>
	// 这里不需要添加新代码...
</script>

<template>
  <view class="vehicle-panel">
    <view class="vehicle-panel-header">
      ....
    </view>
    <view v-show="show" class="vehicle-panel-body">
      <uni-list>
        <uni-list-item
          v-for="item in initialData"
          direction="column"
          :border="false"
          :title="item.title"
        >
          <template v-slot:footer>
            <vehicle-options :data-key="item.key" :types="item.types" />
          </template>
        </uni-list-item>
      </uni-list>
    </view>
  </view>
</template>
1.2.3 表单数据

在处理回车登记数时涉及到了组件的数据的传递,我们来通过 Pinia 来解决组件数据通信的问题。

  1. 定义 Store 及数据
// stores/task.js
import { ref } from 'vue'
import { defineStore } from 'pinia'

export const useTaskStore = defineStore('task', () => {
  // 这里定义的数据全部是接口所需要的数据
  const recordData = ref({
    id: '',
    startTime: '',
    endTime: '',
    /*** 违章 ***/
    isBreakRules: false,
    breakRulesType: null,
    penaltyAmount: null,
    deductPoints: null,
    /*** 违章 ***/

    /*** 故障 ***/
    isFault: false,
    faultType: null,
    faultDescription: '',
    faultImagesList: [],
    /*** 故障 ***/

    /*** 事故 ***/
    isAccident: false,
    accidentType: null,
    accidentDescription: '',
    accidentImagesList: [],
    /*** 事故 ***/
  })

  return { recordData }
})
  1. 将用户点击选择的选项存入 Pinia 中
<!-- subpkg_task/record/components/vehicle-options.vue -->
<script setup>
  import { ref } from 'vue'
  import { useTaskStore } from '@/stores/task'

  const taskStore = useTaskStore()

  // 当前被选中选项的索引值
  const tabIndex = ref(-1)

  // 接收传入组件的数据
  const props = defineProps({
    types: Array,
    dataKey: String,
  })

  // 点击选中选项
  function onOptionSelect(index, id, text) {
    // 高亮显示选中的选项
    tabIndex.value = index
    // 用户选择的是哪个类型
    console.log(props.dataKey)
    // 用户选择的是哪个值
    console.log(text)
    // 将数据存入 Pinia
    taskStore.recordData[props.dataKey] = id
  }
</script>
<template>
  <view class="vehicle-options">
    <view
      class="option"
      :class="{ active: tabIndex === index }"
      v-for="(option, index) in props.types"
      :key="option.id"
      @click="onOptionSelect(index, option.id, option.text)"
    >
      {{ option.text }}
    </view>
  </view>
</template>
  1. 是否有车辆故障、交通事故、车辆故障,记录到 Pinia 中
<!-- subpkg_task/record/components/vehicle-violation.vue -->
<script setup>
  import { ref } from 'vue'
  import vehicleOptions from './vehicle-options'
  import { useTaskStore } from '@/stores/task'

  const taskStore = useTaskStore()
	
  // 中间部分代码省略...

  function onRadioChange(ev) {
    // 展开详细的选项
    show.value = !!parseInt(ev.detail.value)
    // 是否有交通违章
    taskStore.recordData.isBreakRules = show.value
  }
</script>

·······

  1. 提交表单数据(地址传参获取出车时间和回车时间)

1.3 已完成

司机的运输任务完成回成登记后状态会变成 6 即已完成的状态。

  1. 任务列表
  2. 上拉分页
  3. 下拉刷新

二、打包发布

通过 HBuilderX 可以非常方便的打包项目到 H5端、小程序端和 App 端。

H5

拍照上传 Request failed with status code 413 拍照上传违章车辆_scss_02

小程序打包

小程序端的代码打包到 unpackage/dist/build/mp-weixin 目录中,并自动打开小程序开发者工具来运行打包好的微信小程序,此时在微信小程序开发工具中选择上传即可。

App端

在发布 App 端时有本地打包和云打包两种方式,本地打包要求本地具有 Android Studio 或 XCode 的环境,这种方式对于前端人员来说成本较高,云打包是由 uni-app 平台提供的免费服务,我 们选择此种方式实进行打包。

配置项都在都在manifest.json

  1. 配置 App 的图标
  2. 配置启动界面(闪屏)
  3. 指定 SDKVersion,使用 Vue3 开发时要求最低为 21
  4. 配置模块

拍照上传 Request failed with status code 413 拍照上传违章车辆_前端_03

  1. 云打包

拍照上传 Request failed with status code 413 拍照上传违章车辆_uni-app_04

拍照上传 Request failed with status code 413 拍照上传违章车辆_前端_05

拍照上传 Request failed with status code 413 拍照上传违章车辆_scss_06

拍照上传 Request failed with status code 413 拍照上传违章车辆_scss_07

拍照上传 Request failed with status code 413 拍照上传违章车辆_scss_08

Day5 拓展与补充

学习目标:

  • 知道如何生成 Android 证书
  • 知道如何配置 App 端地图服务平台
  • 知道如何实现实人认证的功能
  • 知道如何实现一键登录的功能
  • 知道如何实现消息推送的功能

一、自定义调试基座

在使用 HBuilderX 运行到 App 端时,官方提供的 Android 包(标准基座)来对项目进行打包,标准基座提供了日常开发的一系列功能,能够满足大部分日常的业务开发,但是当涉及到一些特殊需求时时就需要自定义调试基座,如消息推送、一键登录、人脸识别等。

生成自定义基座时及正式发布 App 应用时需要使用自有证书,然后再由 HBuilderX 提供的云打包服务生成安卓 APK 安装程序。

1.1 安卓证书

安卓证书是一种数字签名技术,安卓系统要求安装到系统上的 App 必须要使用证书进行签名。签名相当于是 App 的身份证书,该证书能够证明 App 的归属者及合法性。

安卓证书的生成规则是由 Google 公司规定的,由开发者自助生成且免费。

1.1.1 JRE 环境
  1. 安装 JRE 环境

拍照上传 Request failed with status code 413 拍照上传违章车辆_scss_09

拍照上传 Request failed with status code 413 拍照上传违章车辆_前端框架_10

拍照上传 Request failed with status code 413 拍照上传违章车辆_uni-app_11

  1. 配置环境变量

拍照上传 Request failed with status code 413 拍照上传违章车辆_vue_12

拍照上传 Request failed with status code 413 拍照上传违章车辆_前端_13

拍照上传 Request failed with status code 413 拍照上传违章车辆_scss_14

拍照上传 Request failed with status code 413 拍照上传违章车辆_前端框架_15

注:大家在安装 JRE 时默认安装到了 C:\Program Files\Java\jre-1.8\bin 目录当中,因此环境变量添加的路径也是该目录对应的路径。

  1. 验证 JRE 是否安装成功
# 打开命令行窗口
java -version

拍照上传 Request failed with status code 413 拍照上传违章车辆_uni-app_16

1.1.2 生成证书

HBuilderX 给出了证书生成的步骤说明:

# 打开命令行工具
keytool -genkey -alias 证书别名 -keyalg RSA -keysize 2048 -validity 36500 -keystore 证书名字.keystore
  • testalias 是证书别名,可修改为自己想设置的字符,建议使用英文字母和数字
  • test.keystore 是证书文件名称,可修改为自己想设置的文件名称,也可以指定完整文件路径
  • 36500 是证书的有效期,表示100年有效期,单位天,建议时间设置长一点,避免证书过期

回车后会提示:

拍照上传 Request failed with status code 413 拍照上传违章车辆_前端_17

!!!大家务必记住证书别名和密码!!!

创建完证书后给出了一个警告,复制警告中的代码到命令行中执行

# 自已复制自已提示出来的警告代码
keytool -importkeystore -srckeystore test.keystore -destkeystore test.keystore -deststoretype pkcs12

拍照上传 Request failed with status code 413 拍照上传违章车辆_前端框架_18

此时在执行命令的目录中就生成了一个 xxxxx.keystore 文件,即安卓证书了,同时还有一个 xxxxx.keystore.old 的备份文件,这个文件执行警告代码之前的证书文件,我们将这两个文件放在一起保管就可以了。

1.1.3 云端证书

云端证书是由 DCloud 平台提供的生成证书的服务,登录到 DCloud 开者中心平台

拍照上传 Request failed with status code 413 拍照上传违章车辆_前端_19

1.2 云打包

生成好的证书即可以用来打正式的 APK 包,也可以用于自定义调试基座。

1.2.1 自定义基座

拍照上传 Request failed with status code 413 拍照上传违章车辆_前端框架_20

拍照上传 Request failed with status code 413 拍照上传违章车辆_前端_21

1.2.2 正式打包

拍照上传 Request failed with status code 413 拍照上传违章车辆_前端框架_22

正式包与自定义调试基座的区别在于,自定义调试基座用于开发环境,正式包用于上线到安卓应用商店,如360、小米、华为应用商店等。

注意:正式包与自定义调试基座的包名要一致!

1.3 地图服务

在 uni-app 中使用地图服务时,需要注意兼容性:

地图服务商

App

H5

小程序

高德

Google

仅nvue页面

腾讯

H5 和小程序平台咱们选择的是腾讯地图服务平台,App 咱们选择高德地图服务平台,自行注册高德地图开发者账号并创建应用。

1.3.1 创建应用Key

拍照上传 Request failed with status code 413 拍照上传违章车辆_前端_23

  • PackageName 是在自定义调试基座和正式包时定义的包名
  • SHA1 值的获取方式如下所示
# 找到你保管证书的目录中,打开命令行工具
keytool -list -v -keystore 你的证名名.keystore

拍照上传 Request failed with status code 413 拍照上传违章车辆_前端框架_24

1.3.2 配置神领物流

拍照上传 Request failed with status code 413 拍照上传违章车辆_uni-app_25

把在高德地图创建的 Key 填写到图示位置,IOS 的 key 可以省略也可以随便填写一个,然后重新打包自定义基座,打包后运行 App 项目,验证上报异常位置时是否可以打开地图。

注意:没有配置 key 的情况下打包自定义基座运行,打开地图时会报如下错误:

拍照上传 Request failed with status code 413 拍照上传违章车辆_vue_26

二、uniCloud 开发

uniCloud 是 DCloud 联合阿里云、腾讯云,为开发者提供的基于 serverless 模式和 js 编程的云开发平台。提供了许多高效实用的业务解决方案,如消息推送、一键登录、实人认证等。

注:在下列的功能开发过程中所应用到的证书即可以是 DCloud 平台的云端证书,也可以是本地自定义生成的证书,无论选择哪一种切忌不要混用!作为新手建议大家就使用 DCloud 平台的云端证书。

2.1 消息推送

消息推送是服务端管理后台主动向安装了 App 的用户发送消息功能,分为离线推送和在线推送两种。

2.1.1 应用信息

完善平台信息,填写 MD5、SHA1、SHA256 信息。

拍照上传 Request failed with status code 413 拍照上传违章车辆_scss_27

拍照上传 Request failed with status code 413 拍照上传违章车辆_vue_28

IOS 平台信息也需要完善,只需要填写一个包名就行

拍照上传 Request failed with status code 413 拍照上传违章车辆_前端_29

然后找到 uniPush 选择 2.0(支持全端推送)

拍照上传 Request failed with status code 413 拍照上传违章车辆_前端框架_30

2.1.2 离线推送

离线推送即厂商推送设置,这种方式不需要在 App 中添加代码(不引入 SDK)即可实现消息推送的功能。这种方式的弊端是要为不同品品牌的手机分别设置参数。

以华为手机为例,首先注册成为华为开发者账号,需要完成身份认证,华为手机用户可以使用自身的华为账号登录。

个推服务平台文档说明

2.1.3 配置神领物流

拍照上传 Request failed with status code 413 拍照上传违章车辆_vue_31

配置完成后重新打包。

2.1.4 发送消息

拍照上传 Request failed with status code 413 拍照上传违章车辆_vue_32

没有安卓机的同学可以使用 WeTest 平台提供的云手机进行测试,注意选择华为品牌的手机。

2.2 一键登录

一键登录是通过运营商(中国移动、中国电信、中国联通)来获取用户手机号,进而实现用户登录的功能。

2.2.1 开通服务

参见官方文档

2.2.2 配置神领物流

拍照上传 Request failed with status code 413 拍照上传违章车辆_前端框架_33

配置好后重新打包,自定义基座和正式包均可,开发阶段推荐使用自定义基座。

2.2.3 客户端(App)调用

在 uni-app 中通过调用 API uni.login 即可唤起一键登录请求授权

uni.login({
  provider: 'univerify',
  univerifyStyle: {
    fullScreen: true,
  },
})
  • provider: 指定登录服务类型,univerify 表示是一键登录
  • univerifystyle 自定义授权界面的样式
  1. 调用 uni.login

接下来我们将登录的功能整合进神领物流项目中,又由于一键登录仅能用于 App 端,所以咱们需要用到条件编译处理登录页面的执行逻辑:

<!-- pages/login/index.vue -->
<script setup>
  import { ref, reactive, computed } from 'vue'
	
  // 省略中间部分代码...

  // 切换登录类型
  function changeLoginType() {
    // #ifdef APP-PLUS
    uni.login({
      provider: 'univerify',
      univerifyStyle: {
        fullScreen: true,
      },
    })
    // #endif

    // #ifndef APP-PLUS
    tabIndex.value = Math.abs(tabIndex.value - 1)
    // #endif
  }
</script>

上述代码仅会在 App 端才会调用 wx.login 唤起一键登录授权。

  1. 自定授权界面的样式

uni-app 允许开发者对授权界面有限制的定制,即对 univerifyStyle 属性进行配置,详细说明参见文档

const { authResult } = await uni.login({
  provider: 'univerify',
  univerifyStyle: {
    fullScreen: true,
    icon: {
      path: 'static/logo.png', // 自定义显示在授权框中的logo,仅支持本地图片 默认显示App logo
    },
    authButton: {
      normalColor: '#ef4f3f', // 授权按钮正常状态背景颜色 默认值:#3479f5
      highlightColor: '#ef4f3f', // 授权按钮按下状态背景颜色 默认值:#2861c5(仅ios支持)
    },
    privacyTerms: {
      defaultCheckBoxState: false,
      uncheckedImage: 'static/images/uncheckedImage.png',
      checkedImage: 'static/images/checkedImage.png',
      termsColor: '#ef4f3f',
    },
  },
})
2.2.4 云函数

在使用云函数之前需要开通 uniCloud 服务空间。

云函数即在云空间(服务器)上的函数,云函数中封装的逻辑其实就是后端程序的代码,比如可以执行操作数据库的操作等。

在前面的步骤中只是获得了用户的授权,并未真正拿到用户的手机号,需要服务端直接或间接的调用中国移动、中国联通、中国电信的接口才可拿到用户的手机号码,我们将这部分逻辑封装进云函数当中。

  1. 创建云函数

拍照上传 Request failed with status code 413 拍照上传违章车辆_前端框架_34

拍照上传 Request failed with status code 413 拍照上传违章车辆_uni-app_35

默认生成的代码为:

'use strict';
exports.main = async (event, context) => {
	//event为客户端上传的参数
	console.log('event : ', event)
	
	//返回数据给客户端
	return event
}
  1. 调用云函数

在 uni-app 中通过 uni.callFunction 专门调用云端的函数:

uniCloud.callFunction({
  name: '云函数名(即文件名)',
  data: {/* 给云函数传的实参 */},
  success: (result) => {
    // result 是云函数 return 的返回值
  }
})

注:在 App 端调试云函数时要求手机必须与电脑处于相同的网络下。

拍照上传 Request failed with status code 413 拍照上传违章车辆_前端_36

在对云函数进行调试时需要安装 HBuilderX 的插件,点击命令行窗口右上角的【开启断点调试】就会自动对插件进行下载安装了。

  1. 调用 uniCloud.getPhoneNumber 获取用户手机号
// 在云函数中获取
'use strict'
exports.main = async (event, context) => {
  // event里包含着客户端提交的参数
  const res = await uniCloud.getPhoneNumber({
    appid: context.APPID, // 替换成自己开通一键登录的应用的DCloud appid
    provider: 'univerify',
    apiKey: 'fc7aa3fb2672f947a501f2392a22501a', // 在开发者中心开通服务并获取apiKey
    apiSecret: 'b4d9fafeb3c3ed12ff6cc97b9f9a1817', // 在开发者中心开通服务并获取apiSecret
    access_token: event.access_token,
    openid: event.openid,
  })

  console.log(res) // res里包含手机号
  // 执行用户信息入库等操作,正常情况下不要把完整手机号返回给前端
  // 在云函数中也可以调用后端接口将手机号存入后端数据中或云数据库中
  return {
    code: 200,
    message: '获取手机号成功',
  }
}

拍照上传 Request failed with status code 413 拍照上传违章车辆_前端框架_37

如上图所示,在获取用户手机号时需要为云函数指定依赖 uni-cloud-verify,添加至云函数的 package.json 中,另外就是这获取用户手机号码是需要付费的,充值付费后才可以使用。

添加了依赖和充值后手机号码便成功获取到了

拍照上传 Request failed with status code 413 拍照上传违章车辆_vue_38

2.3 实人认证

实人认证是通过视频方式来验证用户身份的技术,uniCloud 提供了简单方便的实现方式。

2.3.1 开通服务

参见官方文档

2.3.2 配置神领物流

拍照上传 Request failed with status code 413 拍照上传违章车辆_uni-app_39

2.3.3 新增页面

为了让【实人认证】的功能更符合实际的业务,我们为神领物流新增加一个页面:

<!-- subpkg_user/verify/index.vue -->
<script setup></script>
<template>
  <uni-forms class="verify-form">
    <uni-forms-item name="account">
      <input
        type="text"
        placeholder="请输入姓名"
        class="uni-input-input"
        placeholder-style="color: #818181"
      />
    </uni-forms-item>
    <uni-forms-item name="password">
      <input
        type="text"
        placeholder="请输入身份证号"
        class="uni-input-input"
        placeholder-style="color: #818181"
      />
    </uni-forms-item>
    <button class="submit-button">认证</button>
  </uni-forms>
</template>

<style lang="scss" scoped>
  .verify-form {
    padding: 120rpx 66rpx 66rpx;
  }

  .uni-forms-item {
    height: 100rpx;
    margin-bottom: 20 !important;
    border-bottom: 2rpx solid #eee;
    box-sizing: border-box;
  }

  ::v-deep .uni-forms-item__content {
    display: flex;
    align-items: center;
  }

  ::v-deep input {
    width: 100%;
    font-size: $uni-font-size-base;
    color: $uni-main-color;
  }

  ::v-deep .uni-forms-item__error {
    width: 100%;
    padding-top: 10rpx;
    border-top: 2rpx solid $uni-primary;
    color: $uni-primary;
    font-size: $uni-font-size-small;
    transition: none;
  }

  .submit-button {
    height: 100rpx;
    line-height: 100rpx;
    /* #ifdef APP */
    padding-top: 4rpx;
    /* #endif */
    margin-top: 80rpx;
    border: none;
    color: #fff;
    background-color: $uni-primary;
    border-radius: 100rpx;
    font-size: $uni-font-size-big;
  }

  button[disabled] {
    background-color: #fadcd9;
    color: #fff;
  }
</style>

新建的页面还必须要到 pages.json 中进行配置,配置在 subpkg_user 这个分包下:

{
  "pages": [],
  "globalStyle": {},
  "tabBar": {},
  "subPackages": [{
      "root": "subpkg_task",
      "pages": []
    },
    {
      "root": "subpkg_message",
      "pages": []
    },
    {
      "root": "subpkg_user",
      "pages": [
        ...
        
        {
          "path": "verify/index",
          "style": {
            "navigationBarTitleText": "实人认证"
          }
        }
        
        ...
      ]
    }
  ],
  "uniIdRouter": {}
}

页面创建好之后到 pages/my/index 页面中添加链接跳转:

<template>
  <view class="page-container">
    <view class="user-profile">
      ...
    </view>
    <view class="month-overview">
      ...
    </view>
    <view class="entry-list">
      <uni-list :border="false">
        ...
        <!-- #ifdef APP-PLUS -->
        <uni-list-item
          to="/subpkg_user/verify/index"
          showArrow
          title="实人认证"
        />
        <!-- #endif -->
      </uni-list>
    </view>
  </view>
</template>
2.3.4 客户端(App)调用
  1. 获取设备信息
<script setup>
  function onFormSubmit() {
    // 1. 获取设备信息
    const metaInfo = uni.getFacialRecognitionMetaInfo()
    console.log(metaInfo)
  }
</script>
<template>
  <uni-forms class="verify-form">
		...
    <button @click="onFormSubmit" class="submit-button">认证</button>
  </uni-forms>
</template>
  1. 创建云函数获取 certifyId

拍照上传 Request failed with status code 413 拍照上传违章车辆_scss_40

拍照上传 Request failed with status code 413 拍照上传违章车辆_uni-app_41

拍照上传 Request failed with status code 413 拍照上传违章车辆_前端_42

创建好云函数之后,添加如下代码:

'use strict'
exports.main = async (event, context) => {
  // 云函数获取实人认证实例
  const frvManager = uniCloud.getFacialRecognitionVerifyManager({
    requestId: context.requestId,
  })

  // 云函数提交姓名、身份证号以获取认证服务的certifyId
  const result = await frvManager.getCertifyId({
    realName: event.realName,
    idCard: event.idCard,
    metaInfo: event.metaInfo,
  })

  //返回数据给客户端
  return result
}

在调用云函数时需要 App 客户端传入 3 个参数:

  • realName 验证用户的真实姓名
  • idCard 验证用户的身份证号
  • metaInfo 设备信息
  1. 客户端 App 调用云函数
<script setup>
  import { ref } from 'vue'
  
  const realName = ref('用户姓名')
  const idCard = ref('用户身份证号')
  
  function onFormSubmit() {
    // 1. 获取设备信息
    const metaInfo = uni.getFacialRecognitionMetaInfo()
    // 2. 调用云函数
    uniCloud.callFunction({
      name: 'uni-verify',
      data: { metaInfo, realName: realName.value, idCard: idCard.value },
      success() {},
      fail() {}
    })
  }
</script>
<template>
  <uni-forms class="verify-form">
		...
    <button @click="onFormSubmit" class="submit-button">认证</button>
  </uni-forms>
</template>
  1. 开始验证
<script setup>
  import { ref } from 'vue'
  
  const realName = ref('用户姓名')
  const idCard = ref('用户身份证号')
  
  function onFormSubmit() {
    // 1. 获取设备信息
    const metaInfo = uni.getFacialRecognitionMetaInfo()
    console.log(metaInfo)
    // 2. 调用云函数
    uniCloud.callFunction({
      name: 'uni-verify',
      data: { metaInfo, realName, idCard },
      success({ result }) {
        // 3. 客户端调起sdk刷脸认证
        uni.startFacialRecognitionVerify({
          certifyId: result.certifyId,
          success() {
            uni.utils.toast('认证成功!')
          },
          fail() {
            uni.utils.toast('认证失败!')
          },
        })
      },
      fail() {}
    })
  }
</script>
<template>
  <uni-forms class="verify-form">
		...
    <button @click="onFormSubmit" class="submit-button">认证</button>
  </uni-forms>
</template>