481 lines
11 KiB
Vue
481 lines
11 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"></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="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/location.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 {
|
|
URL
|
|
} from '../../config/url.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
|
|
applyFilter()
|
|
}
|
|
|
|
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
|
|
const res = await uni.request({
|
|
url: `${URL}/device/position/app/list`,
|
|
method: 'GET',
|
|
header: {
|
|
'Authorization': 'Bearer ' + uni.getStorageSync('token'),
|
|
'Clientid': uni.getStorageSync('client_id')
|
|
}
|
|
})
|
|
if (res.statusCode === 200 && res.data?.code === 200) {
|
|
// 过滤掉特定的 positionId
|
|
const filteredData = (res.data.rows || []).filter(item => item.positionId !== '1979008434641670145')
|
|
positionList.value = filteredData
|
|
calculateDistances(center)
|
|
} else if (res.statusCode === 401 || res.data?.code === 401 || res.data?.code === 40101) {
|
|
uni.reLaunch({
|
|
url: '/pages/login/index'
|
|
})
|
|
} else {
|
|
positionList.value = []
|
|
filteredPositions.value = []
|
|
}
|
|
} catch (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: 20rpx;
|
|
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;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.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> |