UniApp充电宝小程序业务实现
# UniApp充电宝小程序业务实现
# 概述
本文档详细介绍基于UniApp框架开发的充电宝租赁小程序的完整实现方案,包括项目架构、核心功能模块、技术实现细节和最佳实践。
# 项目架构
# 技术栈
- 前端框架: UniApp
- 开发语言: Vue.js + JavaScript/TypeScript
- UI框架: uni-ui
- 状态管理: Vuex
- 网络请求: uni.request
- 地图服务: 高德地图/腾讯地图
- 支付: 微信支付/支付宝
# 项目结构
charging-bank-miniapp/
├── pages/ # 页面目录
│ ├── index/ # 首页
│ ├── map/ # 地图页面
│ ├── scan/ # 扫码页面
│ ├── order/ # 订单页面
│ ├── profile/ # 个人中心
│ └── payment/ # 支付页面
├── components/ # 组件目录
│ ├── common/ # 通用组件
│ ├── map-marker/ # 地图标记组件
│ └── order-card/ # 订单卡片组件
├── static/ # 静态资源
├── store/ # Vuex状态管理
├── utils/ # 工具函数
├── api/ # API接口
├── config/ # 配置文件
└── manifest.json # 应用配置
# 核心功能模块
# 1. 用户认证模块
# 微信授权登录
// utils/auth.js
export const wxLogin = () => {
return new Promise((resolve, reject) => {
uni.login({
provider: 'weixin',
success: (loginRes) => {
// 获取用户信息
uni.getUserInfo({
provider: 'weixin',
success: (infoRes) => {
// 发送到后端验证
uni.request({
url: '/api/auth/wxLogin',
method: 'POST',
data: {
code: loginRes.code,
userInfo: infoRes.userInfo
},
success: (res) => {
if (res.data.success) {
// 保存token
uni.setStorageSync('token', res.data.token)
uni.setStorageSync('userInfo', res.data.userInfo)
resolve(res.data)
} else {
reject(res.data.message)
}
},
fail: reject
})
},
fail: reject
})
},
fail: reject
})
})
}
# 实名认证
// pages/profile/realname.vue
<template>
<view class="realname-container">
<view class="form-section">
<uni-forms ref="form" :modelValue="formData" :rules="rules">
<uni-forms-item label="真实姓名" name="realName">
<uni-easyinput v-model="formData.realName" placeholder="请输入真实姓名" />
</uni-forms-item>
<uni-forms-item label="身份证号" name="idCard">
<uni-easyinput v-model="formData.idCard" placeholder="请输入身份证号" />
</uni-forms-item>
</uni-forms>
</view>
<button class="submit-btn" @click="submitRealname">提交认证</button>
</view>
</template>
<script>
export default {
data() {
return {
formData: {
realName: '',
idCard: ''
},
rules: {
realName: {
rules: [{
required: true,
errorMessage: '请输入真实姓名'
}]
},
idCard: {
rules: [{
required: true,
errorMessage: '请输入身份证号'
}, {
pattern: /^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/,
errorMessage: '身份证号格式不正确'
}]
}
}
}
},
methods: {
async submitRealname() {
try {
await this.$refs.form.validate()
uni.showLoading({ title: '提交中...' })
const res = await this.$api.submitRealname(this.formData)
if (res.success) {
uni.showToast({
title: '认证成功',
icon: 'success'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
} else {
uni.showToast({
title: res.message,
icon: 'none'
})
}
} catch (error) {
console.error('实名认证失败:', error)
} finally {
uni.hideLoading()
}
}
}
}
</script>
# 2. 地图定位模块
# 地图组件实现
// components/map-view/map-view.vue
<template>
<view class="map-container">
<map
id="map"
:longitude="longitude"
:latitude="latitude"
:scale="scale"
:markers="markers"
:show-location="true"
@markertap="onMarkerTap"
@regionchange="onRegionChange"
class="map"
>
<!-- 定位按钮 -->
<cover-view class="location-btn" @tap="getCurrentLocation">
<cover-image src="/static/icons/location.png" class="location-icon" />
</cover-view>
</map>
<!-- 充电宝设备列表 -->
<view class="device-list">
<scroll-view scroll-y class="scroll-view">
<view
v-for="device in nearbyDevices"
:key="device.id"
class="device-item"
@tap="selectDevice(device)"
>
<view class="device-info">
<text class="device-name">{{ device.name }}</text>
<text class="device-distance">{{ device.distance }}m</text>
</view>
<view class="device-status">
<text class="available-count">可用: {{ device.availableCount }}</text>
<text class="device-price">{{ device.price }}元/小时</text>
</view>
</view>
</scroll-view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
longitude: 116.397428,
latitude: 39.90923,
scale: 16,
markers: [],
nearbyDevices: []
}
},
onLoad() {
this.getCurrentLocation()
this.loadNearbyDevices()
},
methods: {
// 获取当前位置
getCurrentLocation() {
uni.getLocation({
type: 'gcj02',
success: (res) => {
this.longitude = res.longitude
this.latitude = res.latitude
this.loadNearbyDevices()
},
fail: (error) => {
console.error('获取位置失败:', error)
uni.showToast({
title: '获取位置失败',
icon: 'none'
})
}
})
},
// 加载附近设备
async loadNearbyDevices() {
try {
const res = await this.$api.getNearbyDevices({
longitude: this.longitude,
latitude: this.latitude,
radius: 2000 // 2公里范围
})
if (res.success) {
this.nearbyDevices = res.data
this.updateMarkers()
}
} catch (error) {
console.error('加载设备失败:', error)
}
},
// 更新地图标记
updateMarkers() {
this.markers = this.nearbyDevices.map(device => ({
id: device.id,
longitude: device.longitude,
latitude: device.latitude,
iconPath: device.availableCount > 0 ? '/static/icons/marker-available.png' : '/static/icons/marker-unavailable.png',
width: 30,
height: 30,
callout: {
content: device.name,
fontSize: 12,
borderRadius: 4,
bgColor: '#ffffff',
padding: 5
}
}))
},
// 标记点击事件
onMarkerTap(e) {
const markerId = e.detail.markerId
const device = this.nearbyDevices.find(d => d.id === markerId)
if (device) {
this.selectDevice(device)
}
},
// 选择设备
selectDevice(device) {
if (device.availableCount === 0) {
uni.showToast({
title: '该设备暂无可用充电宝',
icon: 'none'
})
return
}
uni.navigateTo({
url: `/pages/scan/scan?deviceId=${device.id}`
})
},
// 地图区域变化
onRegionChange(e) {
if (e.type === 'end') {
// 地图移动结束,重新加载附近设备
this.loadNearbyDevices()
}
}
}
}
</script>
# 3. 扫码租借模块
# 扫码功能实现
// pages/scan/scan.vue
<template>
<view class="scan-container">
<!-- 扫码区域 -->
<view class="scan-area">
<camera
device-position="back"
flash="off"
@error="onCameraError"
frame="camera"
class="camera"
>
<cover-view class="scan-frame">
<cover-image src="/static/icons/scan-frame.png" class="frame-image" />
</cover-view>
<cover-view class="scan-tip">
<cover-text>将二维码放入框内,即可自动扫描</cover-text>
</cover-view>
</camera>
</view>
<!-- 手动输入 -->
<view class="manual-input">
<button class="input-btn" @tap="showManualInput">手动输入设备编号</button>
</view>
<!-- 手动输入弹窗 -->
<uni-popup ref="inputPopup" type="center">
<view class="input-popup">
<view class="popup-title">输入设备编号</view>
<input
v-model="deviceCode"
placeholder="请输入设备编号"
class="device-input"
/>
<view class="popup-buttons">
<button class="cancel-btn" @tap="closeInputPopup">取消</button>
<button class="confirm-btn" @tap="confirmDeviceCode">确认</button>
</view>
</view>
</uni-popup>
</view>
</template>
<script>
export default {
data() {
return {
deviceId: '',
deviceCode: ''
}
},
onLoad(options) {
this.deviceId = options.deviceId || ''
},
onShow() {
this.startScan()
},
methods: {
// 开始扫码
startScan() {
uni.scanCode({
success: (res) => {
this.handleScanResult(res.result)
},
fail: (error) => {
console.error('扫码失败:', error)
uni.showToast({
title: '扫码失败',
icon: 'none'
})
}
})
},
// 处理扫码结果
async handleScanResult(result) {
try {
// 解析二维码内容
const qrData = JSON.parse(result)
if (qrData.type === 'charging_bank' && qrData.deviceId) {
await this.rentDevice(qrData.deviceId)
} else {
uni.showToast({
title: '无效的二维码',
icon: 'none'
})
}
} catch (error) {
console.error('解析二维码失败:', error)
uni.showToast({
title: '二维码格式错误',
icon: 'none'
})
}
},
// 租借设备
async rentDevice(deviceId) {
try {
uni.showLoading({ title: '租借中...' })
const res = await this.$api.rentDevice({ deviceId })
if (res.success) {
uni.showToast({
title: '租借成功',
icon: 'success'
})
// 跳转到订单页面
setTimeout(() => {
uni.redirectTo({
url: `/pages/order/detail?orderId=${res.data.orderId}`
})
}, 1500)
} else {
uni.showToast({
title: res.message,
icon: 'none'
})
}
} catch (error) {
console.error('租借失败:', error)
uni.showToast({
title: '租借失败,请重试',
icon: 'none'
})
} finally {
uni.hideLoading()
}
},
// 显示手动输入弹窗
showManualInput() {
this.$refs.inputPopup.open()
},
// 关闭输入弹窗
closeInputPopup() {
this.deviceCode = ''
this.$refs.inputPopup.close()
},
// 确认设备编号
async confirmDeviceCode() {
if (!this.deviceCode.trim()) {
uni.showToast({
title: '请输入设备编号',
icon: 'none'
})
return
}
this.closeInputPopup()
await this.rentDevice(this.deviceCode)
},
// 相机错误处理
onCameraError(error) {
console.error('相机错误:', error)
uni.showToast({
title: '相机启动失败',
icon: 'none'
})
}
}
}
</script>
# 4. 订单管理模块
# 订单列表页面
// pages/order/list.vue
<template>
<view class="order-list-container">
<!-- 订单筛选 -->
<view class="filter-tabs">
<view
v-for="(tab, index) in filterTabs"
:key="index"
:class="['tab-item', { active: currentTab === index }]"
@tap="switchTab(index)"
>
{{ tab.name }}
</view>
</view>
<!-- 订单列表 -->
<scroll-view
scroll-y
class="order-scroll"
@scrolltolower="loadMore"
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
>
<view v-if="orderList.length === 0" class="empty-state">
<image src="/static/images/empty-order.png" class="empty-image" />
<text class="empty-text">暂无订单记录</text>
</view>
<view v-else>
<order-card
v-for="order in orderList"
:key="order.id"
:order="order"
@tap="viewOrderDetail(order.id)"
@return="returnDevice"
@pay="payOrder"
/>
</view>
<view v-if="hasMore" class="loading-more">
<uni-load-more :status="loadingStatus" />
</view>
</scroll-view>
</view>
</template>
<script>
import OrderCard from '@/components/order-card/order-card.vue'
export default {
components: {
OrderCard
},
data() {
return {
currentTab: 0,
filterTabs: [
{ name: '全部', status: '' },
{ name: '进行中', status: 'renting' },
{ name: '已完成', status: 'completed' },
{ name: '已取消', status: 'cancelled' }
],
orderList: [],
page: 1,
pageSize: 10,
hasMore: true,
refreshing: false,
loadingStatus: 'more'
}
},
onLoad() {
this.loadOrderList()
},
methods: {
// 切换标签
switchTab(index) {
this.currentTab = index
this.resetList()
this.loadOrderList()
},
// 重置列表
resetList() {
this.orderList = []
this.page = 1
this.hasMore = true
},
// 加载订单列表
async loadOrderList() {
try {
this.loadingStatus = 'loading'
const params = {
page: this.page,
pageSize: this.pageSize,
status: this.filterTabs[this.currentTab].status
}
const res = await this.$api.getOrderList(params)
if (res.success) {
const newOrders = res.data.list || []
if (this.page === 1) {
this.orderList = newOrders
} else {
this.orderList.push(...newOrders)
}
this.hasMore = newOrders.length === this.pageSize
this.loadingStatus = this.hasMore ? 'more' : 'noMore'
}
} catch (error) {
console.error('加载订单失败:', error)
this.loadingStatus = 'more'
} finally {
this.refreshing = false
}
},
// 加载更多
loadMore() {
if (this.hasMore && this.loadingStatus !== 'loading') {
this.page++
this.loadOrderList()
}
},
// 下拉刷新
onRefresh() {
this.refreshing = true
this.resetList()
this.loadOrderList()
},
// 查看订单详情
viewOrderDetail(orderId) {
uni.navigateTo({
url: `/pages/order/detail?orderId=${orderId}`
})
},
// 归还设备
async returnDevice(order) {
try {
uni.showModal({
title: '确认归还',
content: '确定要归还充电宝吗?',
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '归还中...' })
const result = await this.$api.returnDevice({ orderId: order.id })
if (result.success) {
uni.showToast({
title: '归还成功',
icon: 'success'
})
// 刷新列表
this.onRefresh()
} else {
uni.showToast({
title: result.message,
icon: 'none'
})
}
uni.hideLoading()
}
}
})
} catch (error) {
console.error('归还失败:', error)
uni.hideLoading()
}
},
// 支付订单
payOrder(order) {
uni.navigateTo({
url: `/pages/payment/payment?orderId=${order.id}`
})
}
}
}
</script>
# 5. 支付模块
# 微信支付实现
// utils/payment.js
export const wxPay = (paymentData) => {
return new Promise((resolve, reject) => {
uni.requestPayment({
provider: 'wxpay',
timeStamp: paymentData.timeStamp,
nonceStr: paymentData.nonceStr,
package: paymentData.package,
signType: paymentData.signType,
paySign: paymentData.paySign,
success: (res) => {
resolve(res)
},
fail: (error) => {
reject(error)
}
})
})
}
// pages/payment/payment.vue
<template>
<view class="payment-container">
<!-- 订单信息 -->
<view class="order-info">
<view class="info-row">
<text class="label">订单号:</text>
<text class="value">{{ orderInfo.orderNo }}</text>
</view>
<view class="info-row">
<text class="label">租借时长:</text>
<text class="value">{{ orderInfo.duration }}</text>
</view>
<view class="info-row">
<text class="label">应付金额:</text>
<text class="value amount">¥{{ orderInfo.amount }}</text>
</view>
</view>
<!-- 支付方式 -->
<view class="payment-methods">
<view class="method-title">选择支付方式</view>
<view
v-for="method in paymentMethods"
:key="method.type"
:class="['method-item', { selected: selectedMethod === method.type }]"
@tap="selectPaymentMethod(method.type)"
>
<image :src="method.icon" class="method-icon" />
<text class="method-name">{{ method.name }}</text>
<view class="method-radio">
<radio :checked="selectedMethod === method.type" />
</view>
</view>
</view>
<!-- 支付按钮 -->
<view class="payment-footer">
<button class="pay-btn" @tap="submitPayment">确认支付</button>
</view>
</view>
</template>
<script>
import { wxPay } from '@/utils/payment.js'
export default {
data() {
return {
orderId: '',
orderInfo: {},
selectedMethod: 'wxpay',
paymentMethods: [
{
type: 'wxpay',
name: '微信支付',
icon: '/static/icons/wxpay.png'
},
{
type: 'alipay',
name: '支付宝',
icon: '/static/icons/alipay.png'
}
]
}
},
onLoad(options) {
this.orderId = options.orderId
this.loadOrderInfo()
},
methods: {
// 加载订单信息
async loadOrderInfo() {
try {
const res = await this.$api.getOrderDetail({ orderId: this.orderId })
if (res.success) {
this.orderInfo = res.data
}
} catch (error) {
console.error('加载订单信息失败:', error)
}
},
// 选择支付方式
selectPaymentMethod(type) {
this.selectedMethod = type
},
// 提交支付
async submitPayment() {
try {
uni.showLoading({ title: '支付中...' })
// 创建支付订单
const payRes = await this.$api.createPayment({
orderId: this.orderId,
paymentMethod: this.selectedMethod
})
if (payRes.success) {
if (this.selectedMethod === 'wxpay') {
await this.handleWxPay(payRes.data)
} else if (this.selectedMethod === 'alipay') {
await this.handleAliPay(payRes.data)
}
} else {
uni.showToast({
title: payRes.message,
icon: 'none'
})
}
} catch (error) {
console.error('支付失败:', error)
uni.showToast({
title: '支付失败,请重试',
icon: 'none'
})
} finally {
uni.hideLoading()
}
},
// 处理微信支付
async handleWxPay(paymentData) {
try {
await wxPay(paymentData)
uni.showToast({
title: '支付成功',
icon: 'success'
})
// 跳转到订单详情页
setTimeout(() => {
uni.redirectTo({
url: `/pages/order/detail?orderId=${this.orderId}`
})
}, 1500)
} catch (error) {
console.error('微信支付失败:', error)
uni.showToast({
title: '支付取消或失败',
icon: 'none'
})
}
},
// 处理支付宝支付
async handleAliPay(paymentData) {
// 支付宝支付实现
// ...
}
}
}
</script>
# 状态管理
# Vuex Store配置
// store/index.js
import { createStore } from 'vuex'
import user from './modules/user'
import order from './modules/order'
import device from './modules/device'
const store = createStore({
modules: {
user,
order,
device
}
})
export default store
// store/modules/user.js
const state = {
userInfo: null,
token: '',
isLogin: false
}
const mutations = {
SET_USER_INFO(state, userInfo) {
state.userInfo = userInfo
state.isLogin = !!userInfo
},
SET_TOKEN(state, token) {
state.token = token
uni.setStorageSync('token', token)
},
CLEAR_USER_DATA(state) {
state.userInfo = null
state.token = ''
state.isLogin = false
uni.removeStorageSync('token')
uni.removeStorageSync('userInfo')
}
}
const actions = {
// 登录
async login({ commit }, loginData) {
try {
const res = await this.$api.login(loginData)
if (res.success) {
commit('SET_USER_INFO', res.data.userInfo)
commit('SET_TOKEN', res.data.token)
return res
}
} catch (error) {
throw error
}
},
// 登出
logout({ commit }) {
commit('CLEAR_USER_DATA')
}
}
export default {
namespaced: true,
state,
mutations,
actions
}
# API接口封装
// api/index.js
const BASE_URL = 'https://api.chargingbank.com'
class ApiService {
constructor() {
this.baseURL = BASE_URL
}
// 通用请求方法
request(options) {
return new Promise((resolve, reject) => {
const token = uni.getStorageSync('token')
uni.request({
url: this.baseURL + options.url,
method: options.method || 'GET',
data: options.data || {},
header: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
...options.header
},
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data)
} else if (res.statusCode === 401) {
// token过期,跳转登录
uni.navigateTo({
url: '/pages/login/login'
})
reject(new Error('登录已过期'))
} else {
reject(new Error(res.data.message || '请求失败'))
}
},
fail: (error) => {
reject(error)
}
})
})
}
// 用户相关API
login(data) {
return this.request({
url: '/api/auth/login',
method: 'POST',
data
})
}
getUserInfo() {
return this.request({
url: '/api/user/info'
})
}
submitRealname(data) {
return this.request({
url: '/api/user/realname',
method: 'POST',
data
})
}
// 设备相关API
getNearbyDevices(data) {
return this.request({
url: '/api/device/nearby',
data
})
}
getDeviceDetail(deviceId) {
return this.request({
url: `/api/device/${deviceId}`
})
}
// 订单相关API
rentDevice(data) {
return this.request({
url: '/api/order/rent',
method: 'POST',
data
})
}
returnDevice(data) {
return this.request({
url: '/api/order/return',
method: 'POST',
data
})
}
getOrderList(data) {
return this.request({
url: '/api/order/list',
data
})
}
getOrderDetail(data) {
return this.request({
url: '/api/order/detail',
data
})
}
// 支付相关API
createPayment(data) {
return this.request({
url: '/api/payment/create',
method: 'POST',
data
})
}
}
const apiService = new ApiService()
export default apiService
# 最佳实践
# 1. 性能优化
- 使用图片懒加载
- 合理使用缓存机制
- 优化网络请求
- 减少页面层级
# 2. 用户体验
- 添加加载状态提示
- 网络异常处理
- 离线状态处理
- 友好的错误提示
# 3. 安全考虑
- 敏感信息加密传输
- 防止重复提交
- 输入验证
- 权限控制
# 4. 兼容性
- 多端适配
- 不同设备屏幕适配
- 系统版本兼容
# 总结
本文档详细介绍了基于UniApp开发充电宝小程序的完整实现方案,涵盖了从项目架构到具体功能实现的各个方面。通过合理的架构设计和代码组织,可以构建出功能完善、性能优良的充电宝租赁小程序。
在实际开发过程中,还需要根据具体的业务需求和技术环境进行相应的调整和优化。