528 lines
12 KiB
Vue
528 lines
12 KiB
Vue
<template>
|
||
<view class="search-page">
|
||
<view class="map-wrap">
|
||
<MapComponent :userLocation="userLocation" :filteredPositions="filteredPositions"
|
||
:enableMarkers="true" :customHeight="'100%'" :hideControls="true" :fullWidth="true"
|
||
@mapCenterChange="onMapCenterChange" @relocate="init" @markerTap="goToPositionDetail" />
|
||
<!-- 定位按钮 -->
|
||
<view class="relocate-btn" @click="init">
|
||
<image src="/static/location.png" class="relocate-icon" mode="aspectFit"></image>
|
||
</view>
|
||
</view>
|
||
<view class="list-wrap">
|
||
<view class="panel">
|
||
<view class="filter-tabs">
|
||
<view class="tab" :class="{ active: activeTab === 'rent' }" @click="setTab('rent')">{{ $t('location.rent') }}</view>
|
||
<view class="tab" :class="{ active: activeTab === 'return' }" @click="setTab('return')">{{ $t('location.return') }}</view>
|
||
</view>
|
||
<scroll-view class="list-scroll" scroll-y="true">
|
||
<view class="card" :class="{ available: isRentable(item), invalid: !isValidCoordinate(item.latitude, item.longitude) }"
|
||
v-for="(item, index) in filteredPositions" :key="item.positionId || index"
|
||
@click="goToPositionDetail(item)">
|
||
<view class="thumb">
|
||
<image v-if="item.deviceImg" :src="item.deviceImg" class="thumb-img" mode="aspectFill"></image>
|
||
<image v-else src="/static/device-info.png" class="thumb-img" mode="aspectFit"></image>
|
||
</view>
|
||
<view class="info">
|
||
<view class="row top">
|
||
<view class="name">{{ item.name }}</view>
|
||
|
||
</view>
|
||
<view class="row sub" v-if="item.location">
|
||
<text class="addr">{{ item.location }}</text>
|
||
</view>
|
||
<view class="row meta" v-if="item.workTime && item.workTime !== '0'">
|
||
<text class="time">{{ $t('location.businessHours') }}{{ item.workTime }}</text>
|
||
</view>
|
||
<view class="row meta" v-if="!isValidCoordinate(item.latitude, item.longitude)">
|
||
<text class="time" style="color: #ff6b6b;">{{ $t('location.coordinateError') }}</text>
|
||
</view>
|
||
<view class="row meta" v-if="item.availablePowerBankCount !== undefined && item.availablePowerBankCount !== null">
|
||
<text class="time">可租借:{{ item.availablePowerBankCount }} 台</text>
|
||
</view>
|
||
<view class="row meta" v-if="item.availableEmptyGridCount !== undefined && item.availableEmptyGridCount !== null">
|
||
<text class="time">可归还:{{ item.availableEmptyGridCount }} 个空位</text>
|
||
</view>
|
||
<view class="row meta remark-info" v-if="item.remark">
|
||
<text class="time">💰 {{ item.remark }}</text>
|
||
</view>
|
||
<view class="tags">
|
||
<view class="tag rent" v-if="isRentable(item)">{{ $t('location.rent') }}</view>
|
||
<view class="tag return" v-if="isReturnable(item)">{{ $t('location.return') }}</view>
|
||
</view>
|
||
</view>
|
||
<view class="actions">
|
||
|
||
<view class="nav"
|
||
:class="{ disabled: !isValidCoordinate(item.latitude, item.longitude) }"
|
||
@click.stop="navigateToPosition(item)">
|
||
<image src="/static/luxian.png" class="action-icon" mode="aspectFit"></image>
|
||
</view>
|
||
<view class="distance" v-if="item.distance && isValidCoordinate(item.latitude, item.longitude)">{{ item.distance }}</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="empty-state" v-if="!isLoading && (!positionList || positionList.length === 0)">
|
||
<image class="empty-icon" src="/static/scan-icon.png" mode="aspectFit" />
|
||
<text class="empty-text">{{ $t('home.noNearbyDevice') }}</text>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import {
|
||
ref,
|
||
computed,
|
||
onMounted
|
||
} from 'vue'
|
||
import MapComponent from '../../components/MapComponent.vue'
|
||
import {
|
||
getNearbyDevices,
|
||
transformDeviceData
|
||
} from '../../config/api/device.js'
|
||
import {
|
||
getUserLocation,
|
||
getRegeo,
|
||
calculateDistanceSync
|
||
} from '../../utils/mapUtils.js'
|
||
import { useI18n } from '@/utils/i18n.js'
|
||
|
||
const { t: $t } = useI18n()
|
||
|
||
onMounted(() => {
|
||
uni.setNavigationBarTitle({
|
||
title: $t('search.title')
|
||
})
|
||
init()
|
||
})
|
||
|
||
const userLocation = ref(null)
|
||
const positionList = ref([])
|
||
const filteredPositions = ref([])
|
||
const isLoading = ref(false)
|
||
const activeTab = ref('rent')
|
||
|
||
const isRentable = (item) => {
|
||
if (typeof item?.canRent !== 'undefined') return !!item.canRent
|
||
return String(item?.status || '').toLowerCase() === 'online'
|
||
}
|
||
|
||
const isReturnable = (item) => {
|
||
if (typeof item?.canReturn !== 'undefined') return !!item.canReturn
|
||
return String(item?.status || '').toLowerCase() === 'online'
|
||
}
|
||
|
||
const formatDistance = (meters) => {
|
||
if (meters < 1000) return `${Math.round(meters)}m`
|
||
return `${(meters / 1000).toFixed(1)}km`
|
||
}
|
||
|
||
const setTab = (name) => {
|
||
activeTab.value = name
|
||
// 切换 tab 时重新查询设备列表(因为接口需要不同的 queryType)
|
||
if (userLocation.value) {
|
||
loadPositions(userLocation.value)
|
||
}
|
||
}
|
||
|
||
const applyFilter = () => {
|
||
if (activeTab.value === 'rent') {
|
||
filteredPositions.value = positionList.value.filter(isRentable)
|
||
} else if (activeTab.value === 'return') {
|
||
filteredPositions.value = positionList.value.filter(isReturnable)
|
||
} else {
|
||
filteredPositions.value = [...positionList.value]
|
||
}
|
||
}
|
||
|
||
const isValidCoordinate = (lat, lng) => {
|
||
const latitude = parseFloat(lat)
|
||
const longitude = parseFloat(lng)
|
||
return !isNaN(latitude) && !isNaN(longitude) &&
|
||
latitude >= -90 && latitude <= 90 &&
|
||
longitude >= -180 && longitude <= 180 &&
|
||
!(latitude === 0 && longitude === 0) // 排除 0,0 这种无效坐标
|
||
}
|
||
|
||
const calculateDistances = (center) => {
|
||
positionList.value.forEach(item => {
|
||
if (item.longitude && item.latitude && isValidCoordinate(item.latitude, item.longitude)) {
|
||
try {
|
||
const distanceInMeters = calculateDistanceSync(
|
||
center.latitude,
|
||
center.longitude,
|
||
parseFloat(item.latitude),
|
||
parseFloat(item.longitude)
|
||
)
|
||
item.distance = formatDistance(distanceInMeters)
|
||
item.distanceInMeters = distanceInMeters
|
||
} catch (_) {
|
||
item.distanceInMeters = 999999
|
||
}
|
||
} else {
|
||
item.distanceInMeters = 999999
|
||
}
|
||
})
|
||
positionList.value.sort((a, b) => (a.distanceInMeters || 999999) - (b.distanceInMeters || 999999))
|
||
applyFilter()
|
||
}
|
||
|
||
const loadPositions = async (center) => {
|
||
try {
|
||
isLoading.value = true
|
||
|
||
if (!center || !center.latitude || !center.longitude) {
|
||
console.warn('中心点位置信息不完整,无法查询附近设备')
|
||
return
|
||
}
|
||
|
||
const res = await getNearbyDevices({
|
||
userLatitude: center.latitude,
|
||
userLongitude: center.longitude,
|
||
queryType: activeTab.value, // 使用当前选中的 tab (rent/return)
|
||
radiusKm: 5,
|
||
pageNum: 1,
|
||
pageSize: 100
|
||
})
|
||
|
||
if (res.code === 200) {
|
||
// 新接口返回的是 data.records
|
||
const devices = res.data?.records || []
|
||
|
||
// 将设备数据转换为统一格式
|
||
positionList.value = devices.map(device => {
|
||
const transformed = transformDeviceData(device)
|
||
// 根据当前 tab 设置租借和归还能力
|
||
return {
|
||
...transformed,
|
||
canRent: activeTab.value === 'rent' ? true : (device.availablePowerBankCount > 0),
|
||
canReturn: activeTab.value === 'return' ? true : (device.availableEmptyGridCount > 0)
|
||
}
|
||
})
|
||
|
||
calculateDistances(center)
|
||
} else {
|
||
positionList.value = []
|
||
filteredPositions.value = []
|
||
}
|
||
} catch (e) {
|
||
console.error('查询附近设备失败:', e)
|
||
positionList.value = []
|
||
filteredPositions.value = []
|
||
} finally {
|
||
isLoading.value = false
|
||
}
|
||
}
|
||
|
||
const init = async () => {
|
||
isLoading.value = true
|
||
try {
|
||
const loc = await getUserLocation()
|
||
userLocation.value = {
|
||
longitude: loc.longitude,
|
||
latitude: loc.latitude
|
||
}
|
||
await loadPositions(userLocation.value)
|
||
} catch (e) {
|
||
await loadPositions(userLocation.value || {
|
||
longitude: 0,
|
||
latitude: 0
|
||
})
|
||
} finally {
|
||
isLoading.value = false
|
||
}
|
||
}
|
||
|
||
const onMapCenterChange = (center) => {
|
||
if (center && typeof center.longitude !== 'undefined' && typeof center.latitude !== 'undefined') {
|
||
userLocation.value = {
|
||
longitude: Number(center.longitude),
|
||
latitude: Number(center.latitude)
|
||
}
|
||
loadPositions(userLocation.value)
|
||
}
|
||
}
|
||
|
||
const navigateToPosition = (position) => {
|
||
if (!isValidCoordinate(position.latitude, position.longitude)) {
|
||
uni.showToast({
|
||
title: $t('search.invalidCoordinate'),
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
const latitude = parseFloat(position.latitude)
|
||
const longitude = parseFloat(position.longitude)
|
||
uni.openLocation({
|
||
latitude,
|
||
longitude,
|
||
name: position.name,
|
||
address: position.location
|
||
})
|
||
}
|
||
|
||
const goToPositionDetail = (position) => {
|
||
if (!position.positionId) {
|
||
uni.showToast({
|
||
title: $t('search.positionInfoError'),
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
uni.navigateTo({
|
||
url: `/pages/position/detail?positionId=${position.positionId}`
|
||
})
|
||
}
|
||
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.search-page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100vh;
|
||
}
|
||
|
||
.map-wrap {
|
||
flex: 0 0 48vh;
|
||
padding: 0;
|
||
position: relative;
|
||
overflow: hidden;
|
||
|
||
.relocate-btn {
|
||
position: absolute;
|
||
right: 20rpx;
|
||
bottom: 30rpx;
|
||
width: 72rpx;
|
||
height: 72rpx;
|
||
background: rgba(255, 255, 255, 0.96);
|
||
border-radius: 36rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
// box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.12);
|
||
border: 2rpx solid #e0e0e0;
|
||
z-index: 100;
|
||
|
||
&:active {
|
||
transform: scale(0.95);
|
||
}
|
||
|
||
.relocate-icon {
|
||
width: 44rpx;
|
||
height: 44rpx;
|
||
}
|
||
}
|
||
}
|
||
|
||
.list-wrap {
|
||
flex: 1;
|
||
background: transparent;
|
||
padding: 0 20rpx 20rpx;
|
||
|
||
.panel {
|
||
background: #ffffff;
|
||
border-radius: 24rpx;
|
||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08);
|
||
padding: 16rpx 16rpx 8rpx;
|
||
}
|
||
|
||
.filter-tabs {
|
||
display: flex;
|
||
background: #E8F8EF;
|
||
padding: 8rpx;
|
||
border-radius: 28rpx;
|
||
gap: 8rpx;
|
||
margin: 6rpx 6rpx 12rpx 6rpx;
|
||
|
||
.tab {
|
||
flex: 1;
|
||
height: 64rpx;
|
||
border-radius: 24rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 28rpx;
|
||
color: #3EAB64;
|
||
font-weight: 600;
|
||
|
||
&.active {
|
||
background: #3EAB64;
|
||
color: #ffffff;
|
||
}
|
||
}
|
||
}
|
||
|
||
.list-scroll {
|
||
height: calc(48vh - 140rpx);
|
||
padding: 6rpx 4rpx 12rpx 4rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
}
|
||
|
||
.card {
|
||
display: grid;
|
||
grid-template-columns: 120rpx 1fr 72rpx;
|
||
align-items: center;
|
||
gap: 16rpx;
|
||
padding: 20rpx;
|
||
border-radius: 20rpx;
|
||
border: 2rpx solid #E3EDE7;
|
||
background: #ffffff;
|
||
margin: 12rpx 8rpx;
|
||
|
||
&.available {
|
||
border-color: #BFE2CF;
|
||
background: #F6FBF8;
|
||
}
|
||
|
||
&.invalid {
|
||
opacity: 0.6;
|
||
border-color: #FFE5E5;
|
||
background: #FFF9F9;
|
||
}
|
||
|
||
.thumb {
|
||
width: 120rpx;
|
||
height: 120rpx;
|
||
border-radius: 16rpx;
|
||
background: #F0F2F5;
|
||
overflow: hidden;
|
||
|
||
.thumb-img {
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 16rpx;
|
||
}
|
||
}
|
||
|
||
.info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8rpx;
|
||
|
||
.row.top {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.name {
|
||
font-size: 30rpx;
|
||
font-weight: 700;
|
||
color: #2A2A2A;
|
||
max-width: 70%;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.row.sub {
|
||
color: #888;
|
||
font-size: 24rpx;
|
||
}
|
||
|
||
.addr {
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 1;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.row.meta {
|
||
color: #999;
|
||
font-size: 22rpx;
|
||
|
||
&.remark-info {
|
||
color: #FF6B35;
|
||
font-weight: 500;
|
||
|
||
.time {
|
||
color: #FF6B35;
|
||
}
|
||
}
|
||
}
|
||
|
||
.tags {
|
||
display: flex;
|
||
gap: 10rpx;
|
||
}
|
||
|
||
.tag {
|
||
padding: 6rpx 14rpx;
|
||
border-radius: 16rpx;
|
||
font-size: 22rpx;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.tag.rent {
|
||
background: #E8F5E8;
|
||
color: #3EAB64;
|
||
}
|
||
|
||
.tag.return {
|
||
background: #E8F2FF;
|
||
color: #3578e5;
|
||
}
|
||
}
|
||
|
||
.actions {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.nav {
|
||
width: 56rpx;
|
||
height: 56rpx;
|
||
border-radius: 25%;
|
||
background: #E8F5EE;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #3EAB64;
|
||
|
||
&.disabled {
|
||
background: #F5F5F5;
|
||
opacity: 0.5;
|
||
}
|
||
}
|
||
}
|
||
|
||
.empty-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 60rpx 0;
|
||
}
|
||
|
||
.empty-icon {
|
||
width: 120rpx;
|
||
height: 120rpx;
|
||
margin-bottom: 24rpx;
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.empty-text {
|
||
font-size: 28rpx;
|
||
color: #999;
|
||
}
|
||
|
||
.action-icon {
|
||
width: 40rpx;
|
||
height: 40rpx;
|
||
filter: none;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.distance {
|
||
font-size: 24rpx;
|
||
color: #2A8E5A;
|
||
font-weight: 700;
|
||
}
|
||
</style> |