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开发充电宝小程序的完整实现方案,涵盖了从项目架构到具体功能实现的各个方面。通过合理的架构设计和代码组织,可以构建出功能完善、性能优良的充电宝租赁小程序。

在实际开发过程中,还需要根据具体的业务需求和技术环境进行相应的调整和优化。