Files
uni-fans-score/pages/search/index.vue
T
2025-10-15 14:56:34 +08:00

472 lines
11 KiB
Vue
Raw 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')">可租借</view>
<view class="tab" :class="{ active: activeTab === 'return' }" @click="setTab('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">营业时间{{ item.workTime }}</text>
</view>
<view class="row meta" v-if="!isValidCoordinate(item.latitude, item.longitude)">
<text class="time" style="color: #ff6b6b;">坐标信息异常</text>
</view>
<view class="tags">
<view class="tag rent" v-if="isRentable(item)">可租借</view>
<view class="tag return" v-if="isReturnable(item)">可归还</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">附近暂无设备</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'
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) {
positionList.value = res.data.rows || []
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: '该位置坐标无效',
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: '场地信息异常',
icon: 'none'
})
return
}
uni.navigateTo({
url: `/pages/position/detail?positionId=${position.positionId}`
})
}
onMounted(() => {
init()
})
</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>