UniApp充电桩小程序业务实现
# UniApp充电桩小程序业务实现
# 概述
本文档详细介绍基于UniApp框架开发的充电桩服务小程序的完整实现方案,包括充电桩查找、预约充电、充电监控、支付结算等核心功能模块的技术实现。
# 项目架构
# 技术栈
- 前端框架: UniApp
- 开发语言: Vue.js + TypeScript
- UI框架: uni-ui + uView
- 状态管理: Pinia
- 网络请求: uni.request + 拦截器
- 地图服务: 高德地图API
- 实时通信: WebSocket
- 支付: 微信支付/支付宝
# 项目结构
charging-station-miniapp/
├── pages/ # 页面目录
│ ├── index/ # 首页
│ ├── map/ # 地图找桩
│ ├── station/ # 充电站详情
│ ├── charging/ # 充电中页面
│ ├── reservation/ # 预约页面
│ ├── order/ # 订单管理
│ ├── wallet/ # 钱包页面
│ └── profile/ # 个人中心
├── components/ # 组件目录
│ ├── station-card/ # 充电站卡片
│ ├── charging-gun/ # 充电枪组件
│ ├── real-time-chart/ # 实时图表
│ └── payment-modal/ # 支付弹窗
├── static/ # 静态资源
├── stores/ # Pinia状态管理
├── utils/ # 工具函数
├── api/ # API接口
├── types/ # TypeScript类型定义
└── config/ # 配置文件
# 核心功能模块
# 1. 充电站地图模块
# 地图找桩页面
<!-- pages/map/map.vue -->
<template>
<view class="map-container">
<!-- 搜索栏 -->
<view class="search-bar">
<uni-search-bar
v-model="searchKeyword"
placeholder="搜索充电站"
@confirm="searchStations"
@clear="clearSearch"
/>
<view class="filter-btn" @tap="showFilterModal">
<uni-icons type="tune" size="20" />
</view>
</view>
<!-- 地图 -->
<map
id="chargingMap"
:longitude="mapCenter.longitude"
:latitude="mapCenter.latitude"
:scale="mapScale"
:markers="stationMarkers"
:show-location="true"
@markertap="onMarkerTap"
@regionchange="onRegionChange"
@tap="onMapTap"
class="map"
>
<!-- 定位按钮 -->
<cover-view class="location-btn" @tap="getCurrentLocation">
<cover-image src="/static/icons/location.png" class="location-icon" />
</cover-view>
<!-- 图例 -->
<cover-view class="map-legend">
<cover-view class="legend-item">
<cover-image src="/static/icons/available.png" class="legend-icon" />
<cover-text class="legend-text">可用</cover-text>
</cover-view>
<cover-view class="legend-item">
<cover-image src="/static/icons/busy.png" class="legend-icon" />
<cover-text class="legend-text">繁忙</cover-text>
</cover-view>
<cover-view class="legend-item">
<cover-image src="/static/icons/offline.png" class="legend-icon" />
<cover-text class="legend-text">离线</cover-text>
</cover-view>
</cover-view>
</map>
<!-- 充电站列表 -->
<view class="station-list" :class="{ expanded: showStationList }">
<view class="list-header" @tap="toggleStationList">
<text class="list-title">附近充电站 ({{ nearbyStations.length }})</text>
<uni-icons
:type="showStationList ? 'chevron-down' : 'chevron-up'"
size="16"
/>
</view>
<scroll-view v-if="showStationList" scroll-y class="station-scroll">
<station-card
v-for="station in nearbyStations"
:key="station.id"
:station="station"
@tap="selectStation(station)"
@navigate="navigateToStation"
@reserve="reserveStation"
/>
</scroll-view>
</view>
<!-- 筛选弹窗 -->
<uni-popup ref="filterPopup" type="bottom">
<view class="filter-modal">
<view class="filter-header">
<text class="filter-title">筛选条件</text>
<text class="filter-reset" @tap="resetFilter">重置</text>
</view>
<view class="filter-content">
<!-- 充电类型 -->
<view class="filter-section">
<text class="section-title">充电类型</text>
<view class="option-group">
<view
v-for="type in chargingTypes"
:key="type.value"
:class="['option-item', { selected: filterOptions.chargingType.includes(type.value) }]"
@tap="toggleChargingType(type.value)"
>
{{ type.label }}
</view>
</view>
</view>
<!-- 功率范围 -->
<view class="filter-section">
<text class="section-title">功率范围</text>
<uni-slider
v-model="filterOptions.powerRange"
:min="0"
:max="350"
:step="10"
show-value
@change="onPowerRangeChange"
/>
</view>
<!-- 距离范围 -->
<view class="filter-section">
<text class="section-title">距离范围</text>
<view class="distance-options">
<view
v-for="distance in distanceOptions"
:key="distance.value"
:class="['distance-item', { selected: filterOptions.distance === distance.value }]"
@tap="selectDistance(distance.value)"
>
{{ distance.label }}
</view>
</view>
</view>
</view>
<view class="filter-footer">
<button class="confirm-btn" @tap="applyFilter">确定</button>
</view>
</view>
</uni-popup>
</view>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { useStationStore } from '@/stores/station'
import { useLocationStore } from '@/stores/location'
import StationCard from '@/components/station-card/station-card.vue'
import type { ChargingStation, MapCenter, FilterOptions } from '@/types/station'
const stationStore = useStationStore()
const locationStore = useLocationStore()
// 响应式数据
const searchKeyword = ref('')
const showStationList = ref(false)
const mapScale = ref(16)
const selectedStation = ref<ChargingStation | null>(null)
const mapCenter = reactive<MapCenter>({
longitude: 116.397428,
latitude: 39.90923
})
const filterOptions = reactive<FilterOptions>({
chargingType: [],
powerRange: [0, 350],
distance: 5000,
availability: 'all'
})
// 筛选选项
const chargingTypes = [
{ label: '直流快充', value: 'dc_fast' },
{ label: '交流慢充', value: 'ac_slow' },
{ label: '超级快充', value: 'super_fast' }
]
const distanceOptions = [
{ label: '1公里内', value: 1000 },
{ label: '3公里内', value: 3000 },
{ label: '5公里内', value: 5000 },
{ label: '10公里内', value: 10000 }
]
// 计算属性
const nearbyStations = computed(() => stationStore.nearbyStations)
const stationMarkers = computed(() => {
return nearbyStations.value.map(station => ({
id: station.id,
longitude: station.longitude,
latitude: station.latitude,
iconPath: getStationIcon(station),
width: 32,
height: 32,
callout: {
content: `${station.name}\n可用: ${station.availableGuns}/${station.totalGuns}`,
fontSize: 12,
borderRadius: 4,
bgColor: '#ffffff',
padding: 8,
display: 'BYCLICK'
}
}))
})
// 生命周期
onMounted(() => {
getCurrentLocation()
loadNearbyStations()
})
// 方法
const getCurrentLocation = () => {
uni.getLocation({
type: 'gcj02',
success: (res) => {
mapCenter.longitude = res.longitude
mapCenter.latitude = res.latitude
locationStore.updateLocation({
longitude: res.longitude,
latitude: res.latitude
})
loadNearbyStations()
},
fail: (error) => {
console.error('获取位置失败:', error)
uni.showToast({
title: '获取位置失败',
icon: 'none'
})
}
})
}
const loadNearbyStations = async () => {
try {
await stationStore.loadNearbyStations({
longitude: mapCenter.longitude,
latitude: mapCenter.latitude,
radius: filterOptions.distance,
...filterOptions
})
} catch (error) {
console.error('加载充电站失败:', error)
}
}
const getStationIcon = (station: ChargingStation): string => {
const availabilityRate = station.availableGuns / station.totalGuns
if (station.status === 'offline') {
return '/static/icons/station-offline.png'
} else if (availabilityRate > 0.5) {
return '/static/icons/station-available.png'
} else if (availabilityRate > 0) {
return '/static/icons/station-busy.png'
} else {
return '/static/icons/station-full.png'
}
}
const onMarkerTap = (e: any) => {
const stationId = e.detail.markerId
const station = nearbyStations.value.find(s => s.id === stationId)
if (station) {
selectStation(station)
}
}
const selectStation = (station: ChargingStation) => {
selectedStation.value = station
// 移动地图中心到选中的充电站
mapCenter.longitude = station.longitude
mapCenter.latitude = station.latitude
// 显示充电站详情
uni.navigateTo({
url: `/pages/station/detail?stationId=${station.id}`
})
}
const searchStations = async () => {
if (!searchKeyword.value.trim()) return
try {
await stationStore.searchStations({
keyword: searchKeyword.value,
longitude: mapCenter.longitude,
latitude: mapCenter.latitude
})
} catch (error) {
console.error('搜索失败:', error)
}
}
const showFilterModal = () => {
;(this.$refs.filterPopup as any).open()
}
const applyFilter = async () => {
;(this.$refs.filterPopup as any).close()
await loadNearbyStations()
}
const toggleStationList = () => {
showStationList.value = !showStationList.value
}
const navigateToStation = (station: ChargingStation) => {
// 调用地图导航
uni.openLocation({
longitude: station.longitude,
latitude: station.latitude,
name: station.name,
address: station.address
})
}
const reserveStation = (station: ChargingStation) => {
uni.navigateTo({
url: `/pages/reservation/reservation?stationId=${station.id}`
})
}
</script>
# 2. 充电站详情模块
# 充电站详情页面
<!-- pages/station/detail.vue -->
<template>
<view class="station-detail">
<!-- 充电站基本信息 -->
<view class="station-info">
<view class="info-header">
<text class="station-name">{{ stationDetail.name }}</text>
<view class="station-status" :class="stationDetail.status">
{{ getStatusText(stationDetail.status) }}
</view>
</view>
<view class="info-content">
<view class="info-item">
<uni-icons type="location" size="16" color="#666" />
<text class="info-text">{{ stationDetail.address }}</text>
</view>
<view class="info-item">
<uni-icons type="phone" size="16" color="#666" />
<text class="info-text">{{ stationDetail.phone }}</text>
</view>
<view class="info-item">
<uni-icons type="clock" size="16" color="#666" />
<text class="info-text">{{ stationDetail.operatingHours }}</text>
</view>
</view>
<view class="action-buttons">
<button class="nav-btn" @tap="navigateToStation">
<uni-icons type="map" size="16" />
导航
</button>
<button class="call-btn" @tap="callStation">
<uni-icons type="phone" size="16" />
电话
</button>
<button class="reserve-btn" @tap="showReservationModal">
<uni-icons type="calendar" size="16" />
预约
</button>
</view>
</view>
<!-- 充电枪列表 -->
<view class="charging-guns">
<view class="section-header">
<text class="section-title">充电枪状态</text>
<text class="available-count">可用 {{ availableGunsCount }}/{{ stationDetail.chargingGuns?.length || 0 }}</text>
</view>
<view class="guns-grid">
<charging-gun
v-for="gun in stationDetail.chargingGuns"
:key="gun.id"
:gun="gun"
@select="selectChargingGun"
/>
</view>
</view>
<!-- 实时数据 -->
<view class="real-time-data">
<view class="section-header">
<text class="section-title">实时数据</text>
<text class="update-time">更新时间: {{ formatTime(lastUpdateTime) }}</text>
</view>
<view class="data-cards">
<view class="data-card">
<text class="data-value">{{ stationDetail.totalPower }}kW</text>
<text class="data-label">总功率</text>
</view>
<view class="data-card">
<text class="data-value">{{ stationDetail.currentLoad }}%</text>
<text class="data-label">当前负载</text>
</view>
<view class="data-card">
<text class="data-value">{{ stationDetail.todayEnergy }}kWh</text>
<text class="data-label">今日充电量</text>
</view>
<view class="data-card">
<text class="data-value">{{ stationDetail.todayOrders }}</text>
<text class="data-label">今日订单</text>
</view>
</view>
</view>
<!-- 价格信息 -->
<view class="pricing-info">
<view class="section-header">
<text class="section-title">计费标准</text>
</view>
<view class="pricing-table">
<view class="pricing-row header">
<text class="time-col">时段</text>
<text class="price-col">电费(元/kWh)</text>
<text class="service-col">服务费(元/kWh)</text>
</view>
<view
v-for="price in stationDetail.pricingRules"
:key="price.id"
class="pricing-row"
>
<text class="time-col">{{ price.timeRange }}</text>
<text class="price-col">{{ price.electricityPrice }}</text>
<text class="service-col">{{ price.servicePrice }}</text>
</view>
</view>
</view>
<!-- 用户评价 -->
<view class="reviews">
<view class="section-header">
<text class="section-title">用户评价</text>
<view class="rating-summary">
<uni-rate :value="stationDetail.averageRating" readonly size="16" />
<text class="rating-text">{{ stationDetail.averageRating }}/5.0</text>
</view>
</view>
<view class="review-list">
<view
v-for="review in stationDetail.reviews"
:key="review.id"
class="review-item"
>
<view class="review-header">
<text class="reviewer-name">{{ review.userName }}</text>
<uni-rate :value="review.rating" readonly size="12" />
<text class="review-time">{{ formatTime(review.createTime) }}</text>
</view>
<text class="review-content">{{ review.content }}</text>
</view>
</view>
</view>
<!-- 预约弹窗 -->
<uni-popup ref="reservationPopup" type="bottom">
<view class="reservation-modal">
<view class="modal-header">
<text class="modal-title">预约充电</text>
<uni-icons type="close" size="20" @tap="closeReservationModal" />
</view>
<view class="reservation-form">
<view class="form-item">
<text class="form-label">选择充电枪</text>
<picker
:range="availableGuns"
range-key="name"
@change="onGunChange"
>
<view class="picker-value">
{{ selectedGun?.name || '请选择充电枪' }}
</view>
</picker>
</view>
<view class="form-item">
<text class="form-label">预约时间</text>
<picker
mode="datetime"
:value="reservationTime"
@change="onTimeChange"
>
<view class="picker-value">
{{ formatDateTime(reservationTime) }}
</view>
</picker>
</view>
<view class="form-item">
<text class="form-label">预计充电时长</text>
<picker
:range="durationOptions"
range-key="label"
@change="onDurationChange"
>
<view class="picker-value">
{{ selectedDuration?.label || '请选择时长' }}
</view>
</picker>
</view>
</view>
<view class="modal-footer">
<button class="cancel-btn" @tap="closeReservationModal">取消</button>
<button class="confirm-btn" @tap="confirmReservation">确认预约</button>
</view>
</view>
</uni-popup>
</view>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useStationStore } from '@/stores/station'
import ChargingGun from '@/components/charging-gun/charging-gun.vue'
import type { ChargingStation, ChargingGun as Gun } from '@/types/station'
const props = defineProps<{
stationId: string
}>()
const stationStore = useStationStore()
// 响应式数据
const stationDetail = ref<ChargingStation>({} as ChargingStation)
const lastUpdateTime = ref(new Date())
const selectedGun = ref<Gun | null>(null)
const reservationTime = ref('')
const selectedDuration = ref<any>(null)
const durationOptions = [
{ label: '30分钟', value: 30 },
{ label: '1小时', value: 60 },
{ label: '2小时', value: 120 },
{ label: '3小时', value: 180 }
]
// 计算属性
const availableGunsCount = computed(() => {
return stationDetail.value.chargingGuns?.filter(gun => gun.status === 'available').length || 0
})
const availableGuns = computed(() => {
return stationDetail.value.chargingGuns?.filter(gun => gun.status === 'available') || []
})
// 生命周期
onMounted(() => {
loadStationDetail()
startRealTimeUpdate()
})
// 方法
const loadStationDetail = async () => {
try {
const detail = await stationStore.getStationDetail(props.stationId)
stationDetail.value = detail
} catch (error) {
console.error('加载充电站详情失败:', error)
uni.showToast({
title: '加载失败',
icon: 'none'
})
}
}
const startRealTimeUpdate = () => {
// 每30秒更新一次实时数据
setInterval(() => {
updateRealTimeData()
}, 30000)
}
const updateRealTimeData = async () => {
try {
const realTimeData = await stationStore.getRealTimeData(props.stationId)
Object.assign(stationDetail.value, realTimeData)
lastUpdateTime.value = new Date()
} catch (error) {
console.error('更新实时数据失败:', error)
}
}
const selectChargingGun = (gun: Gun) => {
if (gun.status !== 'available') {
uni.showToast({
title: '该充电枪不可用',
icon: 'none'
})
return
}
// 直接开始充电
uni.navigateTo({
url: `/pages/charging/charging?stationId=${props.stationId}&gunId=${gun.id}`
})
}
const navigateToStation = () => {
uni.openLocation({
longitude: stationDetail.value.longitude,
latitude: stationDetail.value.latitude,
name: stationDetail.value.name,
address: stationDetail.value.address
})
}
const callStation = () => {
uni.makePhoneCall({
phoneNumber: stationDetail.value.phone
})
}
const showReservationModal = () => {
if (availableGuns.value.length === 0) {
uni.showToast({
title: '暂无可用充电枪',
icon: 'none'
})
return
}
;(this.$refs.reservationPopup as any).open()
}
const closeReservationModal = () => {
;(this.$refs.reservationPopup as any).close()
}
const confirmReservation = async () => {
if (!selectedGun.value || !reservationTime.value || !selectedDuration.value) {
uni.showToast({
title: '请完善预约信息',
icon: 'none'
})
return
}
try {
uni.showLoading({ title: '预约中...' })
await stationStore.createReservation({
stationId: props.stationId,
gunId: selectedGun.value.id,
reservationTime: reservationTime.value,
duration: selectedDuration.value.value
})
uni.showToast({
title: '预约成功',
icon: 'success'
})
closeReservationModal()
// 跳转到预约详情页
setTimeout(() => {
uni.navigateTo({
url: '/pages/reservation/list'
})
}, 1500)
} catch (error) {
console.error('预约失败:', error)
uni.showToast({
title: '预约失败',
icon: 'none'
})
} finally {
uni.hideLoading()
}
}
const getStatusText = (status: string): string => {
const statusMap: Record<string, string> = {
online: '正常运营',
offline: '暂停服务',
maintenance: '维护中'
}
return statusMap[status] || '未知状态'
}
const formatTime = (time: string | Date): string => {
const date = new Date(time)
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
}
const formatDateTime = (time: string): string => {
if (!time) return '请选择时间'
const date = new Date(time)
return `${date.getMonth() + 1}月${date.getDate()}日 ${formatTime(date)}`
}
</script>
# 3. 充电监控模块
# 充电中页面
<!-- pages/charging/charging.vue -->
<template>
<view class="charging-container">
<!-- 充电状态卡片 -->
<view class="charging-status">
<view class="status-header">
<text class="station-name">{{ chargingInfo.stationName }}</text>
<text class="gun-number">{{ chargingInfo.gunNumber }}号枪</text>
</view>
<view class="status-circle">
<view class="circle-progress" :style="{ '--progress': chargingProgress }">
<view class="circle-content">
<text class="progress-text">{{ Math.round(chargingProgress) }}%</text>
<text class="status-text">{{ getChargingStatusText() }}</text>
</view>
</view>
</view>
<view class="charging-info">
<view class="info-row">
<text class="info-label">当前功率</text>
<text class="info-value">{{ chargingInfo.currentPower }}kW</text>
</view>
<view class="info-row">
<text class="info-label">充电电压</text>
<text class="info-value">{{ chargingInfo.voltage }}V</text>
</view>
<view class="info-row">
<text class="info-label">充电电流</text>
<text class="info-value">{{ chargingInfo.current }}A</text>
</view>
<view class="info-row">
<text class="info-label">已充电量</text>
<text class="info-value">{{ chargingInfo.chargedEnergy }}kWh</text>
</view>
</view>
</view>
<!-- 充电时间和费用 -->
<view class="charging-summary">
<view class="summary-item">
<text class="summary-label">充电时长</text>
<text class="summary-value">{{ formatDuration(chargingInfo.duration) }}</text>
</view>
<view class="summary-item">
<text class="summary-label">当前费用</text>
<text class="summary-value cost">¥{{ chargingInfo.currentCost.toFixed(2) }}</text>
</view>
</view>
<!-- 实时图表 -->
<view class="chart-section">
<view class="chart-tabs">
<view
v-for="(tab, index) in chartTabs"
:key="index"
:class="['chart-tab', { active: currentChartTab === index }]"
@tap="switchChartTab(index)"
>
{{ tab.name }}
</view>
</view>
<view class="chart-container">
<real-time-chart
:type="chartTabs[currentChartTab].type"
:data="chartData"
:options="chartOptions"
/>
</view>
</view>
<!-- 充电设置 -->
<view class="charging-settings">
<view class="setting-item">
<text class="setting-label">目标电量</text>
<view class="setting-control">
<uni-slider
v-model="targetSOC"
:min="chargingInfo.currentSOC"
:max="100"
:step="5"
show-value
@change="updateTargetSOC"
/>
</view>
</view>
<view class="setting-item">
<text class="setting-label">充电模式</text>
<picker
:range="chargingModes"
range-key="label"
:value="selectedModeIndex"
@change="onModeChange"
>
<view class="picker-value">
{{ chargingModes[selectedModeIndex]?.label }}
</view>
</picker>
</view>
</view>
<!-- 操作按钮 -->
<view class="action-buttons">
<button
v-if="chargingInfo.status === 'charging'"
class="pause-btn"
@tap="pauseCharging"
>
暂停充电
</button>
<button
v-else-if="chargingInfo.status === 'paused'"
class="resume-btn"
@tap="resumeCharging"
>
继续充电
</button>
<button class="stop-btn" @tap="showStopConfirm">结束充电</button>
</view>
<!-- 结束充电确认弹窗 -->
<uni-popup ref="stopConfirmPopup" type="dialog">
<uni-popup-dialog
type="confirm"
title="确认结束充电"
content="确定要结束充电吗?结束后将进行费用结算。"
@confirm="stopCharging"
@close="closeStopConfirm"
/>
</uni-popup>
</view>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useChargingStore } from '@/stores/charging'
import RealTimeChart from '@/components/real-time-chart/real-time-chart.vue'
import type { ChargingInfo } from '@/types/charging'
const props = defineProps<{
stationId: string
gunId: string
orderId?: string
}>()
const chargingStore = useChargingStore()
// 响应式数据
const chargingInfo = ref<ChargingInfo>({} as ChargingInfo)
const targetSOC = ref(80)
const selectedModeIndex = ref(0)
const currentChartTab = ref(0)
const chartData = ref([])
const wsConnection = ref<any>(null)
const chartTabs = [
{ name: '功率', type: 'power' },
{ name: '电压', type: 'voltage' },
{ name: '电流', type: 'current' },
{ name: '温度', type: 'temperature' }
]
const chargingModes = [
{ label: '快速充电', value: 'fast' },
{ label: '标准充电', value: 'standard' },
{ label: '慢速充电', value: 'slow' }
]
const chartOptions = {
responsive: true,
animation: false,
scales: {
x: {
type: 'time',
time: {
unit: 'minute'
}
}
}
}
// 计算属性
const chargingProgress = computed(() => {
if (!chargingInfo.value.currentSOC || !chargingInfo.value.targetSOC) return 0
return (chargingInfo.value.currentSOC / chargingInfo.value.targetSOC) * 100
})
// 生命周期
onMounted(() => {
initCharging()
connectWebSocket()
})
onUnmounted(() => {
disconnectWebSocket()
})
// 方法
const initCharging = async () => {
try {
if (props.orderId) {
// 从订单恢复充电状态
const info = await chargingStore.getChargingInfo(props.orderId)
chargingInfo.value = info
} else {
// 开始新的充电
const result = await chargingStore.startCharging({
stationId: props.stationId,
gunId: props.gunId,
targetSOC: targetSOC.value
})
chargingInfo.value = result.chargingInfo
}
} catch (error) {
console.error('初始化充电失败:', error)
uni.showToast({
title: '充电启动失败',
icon: 'none'
})
}
}
const connectWebSocket = () => {
const wsUrl = `wss://api.charging.com/ws/charging/${chargingInfo.value.orderId}`
wsConnection.value = uni.connectSocket({
url: wsUrl,
success: () => {
console.log('WebSocket连接成功')
},
fail: (error) => {
console.error('WebSocket连接失败:', error)
}
})
wsConnection.value.onMessage((message: any) => {
try {
const data = JSON.parse(message.data)
updateChargingData(data)
} catch (error) {
console.error('解析WebSocket消息失败:', error)
}
})
wsConnection.value.onError((error: any) => {
console.error('WebSocket错误:', error)
})
wsConnection.value.onClose(() => {
console.log('WebSocket连接关闭')
// 尝试重连
setTimeout(() => {
if (chargingInfo.value.status === 'charging') {
connectWebSocket()
}
}, 5000)
})
}
const disconnectWebSocket = () => {
if (wsConnection.value) {
wsConnection.value.close()
wsConnection.value = null
}
}
const updateChargingData = (data: any) => {
// 更新充电信息
Object.assign(chargingInfo.value, data.chargingInfo)
// 更新图表数据
if (data.chartData) {
chartData.value = data.chartData
}
}
const pauseCharging = async () => {
try {
uni.showLoading({ title: '暂停中...' })
await chargingStore.pauseCharging(chargingInfo.value.orderId)
uni.showToast({
title: '已暂停充电',
icon: 'success'
})
} catch (error) {
console.error('暂停充电失败:', error)
uni.showToast({
title: '暂停失败',
icon: 'none'
})
} finally {
uni.hideLoading()
}
}
const resumeCharging = async () => {
try {
uni.showLoading({ title: '恢复中...' })
await chargingStore.resumeCharging(chargingInfo.value.orderId)
uni.showToast({
title: '已恢复充电',
icon: 'success'
})
} catch (error) {
console.error('恢复充电失败:', error)
uni.showToast({
title: '恢复失败',
icon: 'none'
})
} finally {
uni.hideLoading()
}
}
const showStopConfirm = () => {
;(this.$refs.stopConfirmPopup as any).open()
}
const closeStopConfirm = () => {
;(this.$refs.stopConfirmPopup as any).close()
}
const stopCharging = async () => {
try {
uni.showLoading({ title: '结束充电中...' })
const result = await chargingStore.stopCharging(chargingInfo.value.orderId)
disconnectWebSocket()
uni.showToast({
title: '充电已结束',
icon: 'success'
})
// 跳转到结算页面
setTimeout(() => {
uni.redirectTo({
url: `/pages/payment/settlement?orderId=${chargingInfo.value.orderId}`
})
}, 1500)
} catch (error) {
console.error('结束充电失败:', error)
uni.showToast({
title: '结束失败',
icon: 'none'
})
} finally {
uni.hideLoading()
closeStopConfirm()
}
}
const updateTargetSOC = async () => {
try {
await chargingStore.updateChargingSettings({
orderId: chargingInfo.value.orderId,
targetSOC: targetSOC.value
})
} catch (error) {
console.error('更新目标电量失败:', error)
}
}
const switchChartTab = (index: number) => {
currentChartTab.value = index
}
const getChargingStatusText = (): string => {
const statusMap: Record<string, string> = {
charging: '充电中',
paused: '已暂停',
completed: '已完成',
error: '异常'
}
return statusMap[chargingInfo.value.status] || '未知状态'
}
const formatDuration = (seconds: number): string => {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
</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 }}
<text v-if="tab.count > 0" class="tab-count">{{ tab.count }}</text>
</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>
<view
v-for="order in orderList"
:key="order.id"
class="order-item"
@tap="viewOrderDetail(order.id)"
>
<!-- 订单头部 -->
<view class="order-header">
<view class="station-info">
<text class="station-name">{{ order.stationName }}</text>
<text class="gun-number">{{ order.gunNumber }}号枪</text>
</view>
<view class="order-status" :class="order.status">
{{ getOrderStatusText(order.status) }}
</view>
</view>
<!-- 订单内容 -->
<view class="order-content">
<view class="order-info">
<view class="info-row">
<text class="info-label">订单号:</text>
<text class="info-value">{{ order.orderNo }}</text>
</view>
<view class="info-row">
<text class="info-label">充电时间:</text>
<text class="info-value">{{ formatDateTime(order.startTime) }}</text>
</view>
<view class="info-row">
<text class="info-label">充电时长:</text>
<text class="info-value">{{ formatDuration(order.duration) }}</text>
</view>
<view class="info-row">
<text class="info-label">充电电量:</text>
<text class="info-value">{{ order.chargedEnergy }}kWh</text>
</view>
</view>
<view class="order-amount">
<text class="amount-label">总费用</text>
<text class="amount-value">¥{{ order.totalAmount.toFixed(2) }}</text>
</view>
</view>
<!-- 订单操作 -->
<view class="order-actions">
<button
v-if="order.status === 'charging'"
class="action-btn primary"
@tap.stop="continueCharging(order)"
>
继续充电
</button>
<button
v-if="order.status === 'completed' && !order.reviewed"
class="action-btn"
@tap.stop="reviewOrder(order)"
>
评价
</button>
<button
v-if="order.status === 'completed'"
class="action-btn"
@tap.stop="downloadInvoice(order)"
>
下载发票
</button>
<button
v-if="order.status === 'cancelled' || order.status === 'failed'"
class="action-btn"
@tap.stop="deleteOrder(order)"
>
删除
</button>
</view>
</view>
</view>
<view v-if="hasMore" class="loading-more">
<uni-load-more :status="loadingStatus" />
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useOrderStore } from '@/stores/order'
import type { ChargingOrder } from '@/types/order'
const orderStore = useOrderStore()
// 响应式数据
const currentTab = ref(0)
const orderList = ref<ChargingOrder[]>([])
const page = ref(1)
const pageSize = ref(10)
const hasMore = ref(true)
const refreshing = ref(false)
const loadingStatus = ref('more')
const filterTabs = reactive([
{ name: '全部', status: '', count: 0 },
{ name: '充电中', status: 'charging', count: 0 },
{ name: '已完成', status: 'completed', count: 0 },
{ name: '已取消', status: 'cancelled', count: 0 }
])
// 生命周期
onMounted(() => {
loadOrderList()
loadOrderCounts()
})
// 方法
const switchTab = (index: number) => {
currentTab.value = index
resetList()
loadOrderList()
}
const resetList = () => {
orderList.value = []
page.value = 1
hasMore.value = true
}
const loadOrderList = async () => {
try {
loadingStatus.value = 'loading'
const params = {
page: page.value,
pageSize: pageSize.value,
status: filterTabs[currentTab.value].status
}
const res = await orderStore.getOrderList(params)
if (res.success) {
const newOrders = res.data.list || []
if (page.value === 1) {
orderList.value = newOrders
} else {
orderList.value.push(...newOrders)
}
hasMore.value = newOrders.length === pageSize.value
loadingStatus.value = hasMore.value ? 'more' : 'noMore'
}
} catch (error) {
console.error('加载订单失败:', error)
loadingStatus.value = 'more'
} finally {
refreshing.value = false
}
}
const loadOrderCounts = async () => {
try {
const counts = await orderStore.getOrderCounts()
filterTabs.forEach(tab => {
if (tab.status) {
tab.count = counts[tab.status] || 0
} else {
tab.count = Object.values(counts).reduce((sum: number, count: number) => sum + count, 0)
}
})
} catch (error) {
console.error('加载订单统计失败:', error)
}
}
const loadMore = () => {
if (hasMore.value && loadingStatus.value !== 'loading') {
page.value++
loadOrderList()
}
}
const onRefresh = () => {
refreshing.value = true
resetList()
loadOrderList()
loadOrderCounts()
}
const viewOrderDetail = (orderId: string) => {
uni.navigateTo({
url: `/pages/order/detail?orderId=${orderId}`
})
}
const continueCharging = (order: ChargingOrder) => {
uni.navigateTo({
url: `/pages/charging/charging?orderId=${order.id}`
})
}
const reviewOrder = (order: ChargingOrder) => {
uni.navigateTo({
url: `/pages/review/review?orderId=${order.id}`
})
}
const downloadInvoice = async (order: ChargingOrder) => {
try {
uni.showLoading({ title: '生成发票中...' })
const result = await orderStore.generateInvoice(order.id)
if (result.success) {
uni.downloadFile({
url: result.data.invoiceUrl,
success: (res) => {
uni.showToast({
title: '发票下载成功',
icon: 'success'
})
},
fail: (error) => {
console.error('下载发票失败:', error)
uni.showToast({
title: '下载失败',
icon: 'none'
})
}
})
}
} catch (error) {
console.error('生成发票失败:', error)
uni.showToast({
title: '生成发票失败',
icon: 'none'
})
} finally {
uni.hideLoading()
}
}
const deleteOrder = (order: ChargingOrder) => {
uni.showModal({
title: '确认删除',
content: '确定要删除这个订单吗?',
success: async (res) => {
if (res.confirm) {
try {
await orderStore.deleteOrder(order.id)
uni.showToast({
title: '删除成功',
icon: 'success'
})
// 刷新列表
onRefresh()
} catch (error) {
console.error('删除订单失败:', error)
uni.showToast({
title: '删除失败',
icon: 'none'
})
}
}
}
})
}
const getOrderStatusText = (status: string): string => {
const statusMap: Record<string, string> = {
pending: '待支付',
charging: '充电中',
completed: '已完成',
cancelled: '已取消',
failed: '失败'
}
return statusMap[status] || '未知状态'
}
const formatDateTime = (time: string): string => {
const date = new Date(time)
return `${date.getMonth() + 1}月${date.getDate()}日 ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
}
const formatDuration = (seconds: number): string => {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours > 0) {
return `${hours}小时${minutes}分钟`
} else {
return `${minutes}分钟`
}
}
</script>
# 状态管理 (Pinia)
# 充电站状态管理
// stores/station.ts
import { defineStore } from 'pinia'
import type { ChargingStation, StationFilter } from '@/types/station'
import api from '@/api'
export const useStationStore = defineStore('station', {
state: () => ({
nearbyStations: [] as ChargingStation[],
currentStation: null as ChargingStation | null,
searchResults: [] as ChargingStation[],
loading: false,
error: null as string | null
}),
getters: {
availableStations: (state) => {
return state.nearbyStations.filter(station =>
station.status === 'online' && station.availableGuns > 0
)
},
stationById: (state) => {
return (id: string) => state.nearbyStations.find(station => station.id === id)
}
},
actions: {
async loadNearbyStations(params: {
longitude: number
latitude: number
radius: number
filter?: StationFilter
}) {
try {
this.loading = true
this.error = null
const response = await api.station.getNearbyStations(params)
if (response.success) {
this.nearbyStations = response.data
} else {
this.error = response.message
}
} catch (error) {
this.error = '加载充电站失败'
console.error('加载充电站失败:', error)
} finally {
this.loading = false
}
},
async getStationDetail(stationId: string): Promise<ChargingStation> {
try {
const response = await api.station.getStationDetail(stationId)
if (response.success) {
this.currentStation = response.data
return response.data
} else {
throw new Error(response.message)
}
} catch (error) {
console.error('获取充电站详情失败:', error)
throw error
}
},
async searchStations(params: {
keyword: string
longitude: number
latitude: number
}) {
try {
const response = await api.station.searchStations(params)
if (response.success) {
this.searchResults = response.data
}
} catch (error) {
console.error('搜索充电站失败:', error)
}
},
async createReservation(params: {
stationId: string
gunId: string
reservationTime: string
duration: number
}) {
try {
const response = await api.reservation.create(params)
if (response.success) {
return response.data
} else {
throw new Error(response.message)
}
} catch (error) {
console.error('创建预约失败:', error)
throw error
}
},
async getRealTimeData(stationId: string) {
try {
const response = await api.station.getRealTimeData(stationId)
if (response.success) {
return response.data
}
} catch (error) {
console.error('获取实时数据失败:', error)
}
}
}
})
# API接口封装
// api/station.ts
import request from '@/utils/request'
import type { ChargingStation, StationFilter } from '@/types/station'
export const stationApi = {
// 获取附近充电站
getNearbyStations(params: {
longitude: number
latitude: number
radius: number
filter?: StationFilter
}) {
return request({
url: '/api/station/nearby',
method: 'GET',
data: params
})
},
// 获取充电站详情
getStationDetail(stationId: string) {
return request({
url: `/api/station/${stationId}`,
method: 'GET'
})
},
// 搜索充电站
searchStations(params: {
keyword: string
longitude: number
latitude: number
}) {
return request({
url: '/api/station/search',
method: 'GET',
data: params
})
},
// 获取实时数据
getRealTimeData(stationId: string) {
return request({
url: `/api/station/${stationId}/realtime`,
method: 'GET'
})
}
}
// api/charging.ts
export const chargingApi = {
// 开始充电
startCharging(params: {
stationId: string
gunId: string
targetSOC: number
}) {
return request({
url: '/api/charging/start',
method: 'POST',
data: params
})
},
// 获取充电信息
getChargingInfo(orderId: string) {
return request({
url: `/api/charging/${orderId}`,
method: 'GET'
})
},
// 暂停充电
pauseCharging(orderId: string) {
return request({
url: `/api/charging/${orderId}/pause`,
method: 'POST'
})
},
// 恢复充电
resumeCharging(orderId: string) {
return request({
url: `/api/charging/${orderId}/resume`,
method: 'POST'
})
},
// 停止充电
stopCharging(orderId: string) {
return request({
url: `/api/charging/${orderId}/stop`,
method: 'POST'
})
}
}
# 工具函数
# 网络请求封装
// utils/request.ts
interface RequestOptions {
url: string
method: 'GET' | 'POST' | 'PUT' | 'DELETE'
data?: any
header?: Record<string, string>
timeout?: number
}
interface ApiResponse<T = any> {
success: boolean
data: T
message: string
code: number
}
const BASE_URL = 'https://api.charging.com'
const DEFAULT_TIMEOUT = 10000
// 请求拦截器
const requestInterceptor = (options: RequestOptions) => {
// 添加认证token
const token = uni.getStorageSync('access_token')
if (token) {
options.header = {
...options.header,
'Authorization': `Bearer ${token}`
}
}
// 添加公共请求头
options.header = {
'Content-Type': 'application/json',
...options.header
}
return options
}
// 响应拦截器
const responseInterceptor = (response: any): ApiResponse => {
const { statusCode, data } = response
if (statusCode === 200) {
return data
} else if (statusCode === 401) {
// token过期,跳转到登录页
uni.removeStorageSync('access_token')
uni.navigateTo({
url: '/pages/login/login'
})
throw new Error('登录已过期')
} else {
throw new Error(data?.message || '请求失败')
}
}
export default function request<T = any>(options: RequestOptions): Promise<ApiResponse<T>> {
return new Promise((resolve, reject) => {
const requestOptions = requestInterceptor({
timeout: DEFAULT_TIMEOUT,
...options,
url: BASE_URL + options.url
})
uni.request({
...requestOptions,
success: (response) => {
try {
const result = responseInterceptor(response)
resolve(result)
} catch (error) {
reject(error)
}
},
fail: (error) => {
console.error('请求失败:', error)
reject(new Error('网络请求失败'))
}
})
})
}
# 地理位置工具
// utils/location.ts
export interface LocationInfo {
longitude: number
latitude: number
address?: string
city?: string
}
// 获取当前位置
export const getCurrentLocation = (): Promise<LocationInfo> => {
return new Promise((resolve, reject) => {
uni.getLocation({
type: 'gcj02',
success: (res) => {
resolve({
longitude: res.longitude,
latitude: res.latitude
})
},
fail: (error) => {
reject(error)
}
})
})
}
// 计算两点间距离(米)
export const calculateDistance = (
lat1: number,
lon1: number,
lat2: number,
lon2: number
): number => {
const R = 6371e3 // 地球半径(米)
const φ1 = lat1 * Math.PI / 180
const φ2 = lat2 * Math.PI / 180
const Δφ = (lat2 - lat1) * Math.PI / 180
const Δλ = (lon2 - lon1) * Math.PI / 180
const a = Math.sin(Δφ/2) * Math.sin(Δφ/2) +
Math.cos(φ1) * Math.cos(φ2) *
Math.sin(Δλ/2) * Math.sin(Δλ/2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a))
return R * c
}
// 格式化距离显示
export const formatDistance = (distance: number): string => {
if (distance < 1000) {
return `${Math.round(distance)}m`
} else {
return `${(distance / 1000).toFixed(1)}km`
}
}
# TypeScript类型定义
// types/station.ts
export interface ChargingStation {
id: string
name: string
address: string
longitude: number
latitude: number
phone: string
operatingHours: string
status: 'online' | 'offline' | 'maintenance'
totalGuns: number
availableGuns: number
chargingGuns: ChargingGun[]
totalPower: number
currentLoad: number
todayEnergy: number
todayOrders: number
pricingRules: PricingRule[]
reviews: Review[]
averageRating: number
distance?: number
}
export interface ChargingGun {
id: string
name: string
type: 'dc_fast' | 'ac_slow' | 'super_fast'
maxPower: number
status: 'available' | 'charging' | 'offline' | 'reserved'
currentPower?: number
voltage?: number
current?: number
}
export interface PricingRule {
id: string
timeRange: string
electricityPrice: number
servicePrice: number
}
export interface Review {
id: string
userName: string
rating: number
content: string
createTime: string
}
export interface StationFilter {
chargingType: string[]
powerRange: [number, number]
distance: number
availability: 'all' | 'available' | 'fast'
}
// types/charging.ts
export interface ChargingInfo {
orderId: string
stationId: string
stationName: string
gunId: string
gunNumber: string
status: 'charging' | 'paused' | 'completed' | 'error'
startTime: string
duration: number
currentPower: number
voltage: number
current: number
chargedEnergy: number
currentSOC: number
targetSOC: number
currentCost: number
estimatedCost: number
}
export interface ChargingOrder {
id: string
orderNo: string
stationId: string
stationName: string
gunId: string
gunNumber: string
status: 'pending' | 'charging' | 'completed' | 'cancelled' | 'failed'
startTime: string
endTime?: string
duration: number
chargedEnergy: number
totalAmount: number
electricityFee: number
serviceFee: number
reviewed: boolean
}
# 最佳实践
# 1. 性能优化
- 地图优化: 使用地图聚合功能,避免同时显示过多标记点
- 数据缓存: 缓存充电站基础信息,减少重复请求
- 图片懒加载: 充电站图片使用懒加载技术
- 分页加载: 订单列表采用分页加载,提升加载速度
# 2. 用户体验
- 离线提示: 网络断开时显示友好提示
- 加载状态: 所有异步操作都有加载状态提示
- 错误处理: 完善的错误处理和用户提示
- 操作反馈: 重要操作提供明确的成功/失败反馈
# 3. 安全考虑
- 数据加密: 敏感数据传输使用HTTPS加密
- 身份验证: 完善的用户身份验证机制
- 权限控制: 基于角色的权限控制
- 数据校验: 前端和后端双重数据校验
# 4. 代码规范
- TypeScript: 全面使用TypeScript提供类型安全
- 组件化: 合理拆分组件,提高代码复用性
- 状态管理: 使用Pinia进行统一状态管理
- 错误边界: 设置错误边界,防止应用崩溃
# 部署配置
# 1. 小程序配置
// manifest.json
{
"name": "充电桩服务",
"appid": "your_app_id",
"description": "智能充电桩服务小程序",
"versionName": "1.0.0",
"versionCode": "100",
"transformPx": false,
"app-plus": {
"usingComponents": true,
"nvueStyleCompiler": "uni-app",
"compilerVersion": 3,
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
}
},
"mp-weixin": {
"appid": "your_wechat_appid",
"setting": {
"urlCheck": false,
"es6": true,
"enhance": true,
"postcss": true,
"preloadBackgroundData": false,
"minified": true,
"newFeature": false,
"coverView": true,
"nodeModules": false,
"autoAudits": false,
"showShadowRootInWxmlPanel": true,
"scopeDataCheck": false,
"uglifyFileName": false,
"checkInvalidKey": true,
"checkSiteMap": true,
"uploadWithSourceMap": true,
"compileHotReLoad": false,
"lazyloadPlaceholderEnable": false,
"useMultiFrameRuntime": true,
"useApiHook": true,
"useApiHostProcess": true,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
}
},
"usingComponents": true,
"permission": {
"scope.userLocation": {
"desc": "您的位置信息将用于查找附近的充电站"
}
},
"requiredPrivateInfos": [
"getLocation"
]
}
}
# 2. 页面配置
// pages.json
{
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "充电桩服务",
"enablePullDownRefresh": true
}
},
{
"path": "pages/map/map",
"style": {
"navigationBarTitleText": "地图找桩",
"navigationStyle": "custom"
}
},
{
"path": "pages/station/detail",
"style": {
"navigationBarTitleText": "充电站详情"
}
},
{
"path": "pages/charging/charging",
"style": {
"navigationBarTitleText": "充电中",
"navigationStyle": "custom"
}
}
],
"tabBar": {
"color": "#7A7E83",
"selectedColor": "#3cc51f",
"borderStyle": "black",
"backgroundColor": "#ffffff",
"list": [
{
"pagePath": "pages/index/index",
"iconPath": "static/tab-icons/home.png",
"selectedIconPath": "static/tab-icons/home-active.png",
"text": "首页"
},
{
"pagePath": "pages/map/map",
"iconPath": "static/tab-icons/map.png",
"selectedIconPath": "static/tab-icons/map-active.png",
"text": "地图"
},
{
"pagePath": "pages/order/list",
"iconPath": "static/tab-icons/order.png",
"selectedIconPath": "static/tab-icons/order-active.png",
"text": "订单"
},
{
"pagePath": "pages/profile/profile",
"iconPath": "static/tab-icons/profile.png",
"selectedIconPath": "static/tab-icons/profile-active.png",
"text": "我的"
}
]
},
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "充电桩服务",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8"
}
}
# 总结
本文档详细介绍了基于UniApp开发充电桩服务小程序的完整实现方案,涵盖了从项目架构设计到具体功能实现的各个方面。通过模块化的设计和完善的状态管理,确保了应用的可维护性和扩展性。
主要特性包括:
- 实时地图找桩功能
- 充电站详情展示
- 实时充电监控
- 完整的订单管理
- 优秀的用户体验
- 完善的错误处理
该方案可以作为充电桩行业小程序开发的参考模板,开发者可以根据具体业务需求进行定制和扩展。