Files
2025-11-28 09:21:37 +08:00

557 lines
13 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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')
})
// uni.showLoading({
// title:'11111',
// mask:true
// })
init()
// uni.hideLoading();
})
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
uni.showLoading({
mask: true
})
try {
const loc = await getUserLocation()
if (!loc?.longitude || !loc?.latitude) {
throw new Error('invalid location result')
}
userLocation.value = {
longitude: loc.longitude,
latitude: loc.latitude
}
await loadPositions(userLocation.value)
} catch (e) {
console.error('初始化定位失败:', e)
userLocation.value = null
positionList.value = []
filteredPositions.value = []
uni.showToast({
title: $t('home.getLocationFailed'),
icon: 'none'
})
} finally {
isLoading.value = false
uni.hideLoading();
}
}
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;
line-clamp: 1;
-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>