feat(小程序): 支持设备定位与轨迹展示
This commit is contained in:
@@ -14,17 +14,42 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1A1A1A;
|
color: #1A1A1A;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 12px;
|
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.page-subtitle {
|
.mode-switch {
|
||||||
font-size: 28px;
|
margin: 0 24px 20px;
|
||||||
color: #666666;
|
padding: 8px;
|
||||||
line-height: 1.4;
|
background: #FFFFFF;
|
||||||
|
border-radius: 18px;
|
||||||
|
display: flex;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-chip {
|
||||||
|
flex: 1;
|
||||||
|
height: 68px;
|
||||||
|
border-radius: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #FF8C42;
|
||||||
|
|
||||||
|
.mode-chip-text {
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mode-chip-text {
|
||||||
|
font-size: 26px;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
.map-section {
|
.map-section {
|
||||||
height: 50vh;
|
height: 50vh;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -36,29 +61,6 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.location-marker {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -100%);
|
|
||||||
z-index: 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.marker-pin {
|
|
||||||
width: 72px;
|
|
||||||
height: 72px;
|
|
||||||
background: #FF8C42;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-shadow: 0 6px 20px rgba(255, 140, 66, 0.4);
|
|
||||||
|
|
||||||
.marker-emoji {
|
|
||||||
font-size: 36px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.location-card {
|
.location-card {
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
@@ -127,12 +129,18 @@
|
|||||||
font-size: 32px;
|
font-size: 32px;
|
||||||
margin-right: 18px;
|
margin-right: 18px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background: #FFF0E6;
|
||||||
|
color: #FF8C42;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.location-info {
|
.location-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
.location-address {
|
.location-address,
|
||||||
|
.location-coordinate {
|
||||||
font-size: 30px;
|
font-size: 30px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1A1A1A;
|
color: #1A1A1A;
|
||||||
@@ -148,3 +156,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.location-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-point-card {
|
||||||
|
border: 2px solid #FFD0B1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-empty {
|
||||||
|
background: #F8F9FA;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 28px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-empty-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1A1A1A;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-empty-desc {
|
||||||
|
display: block;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #666666;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,82 +1,285 @@
|
|||||||
import { View, Text, Map } from '@tarojs/components'
|
import { View, Text, Map } from '@tarojs/components'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import Taro, { useDidShow } from '@tarojs/taro'
|
import Taro, { useDidShow } from '@tarojs/taro'
|
||||||
|
import { getToken } from '@/services/auth'
|
||||||
|
import {
|
||||||
|
getCurrentDeviceLocation,
|
||||||
|
getDeviceTrajectory,
|
||||||
|
DeviceLocation,
|
||||||
|
DeviceTrajectoryPoint,
|
||||||
|
} from '@/services/location'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
interface LocationInfo {
|
type TrajectoryMode = 'current' | 'today' | 'recent'
|
||||||
address: string
|
|
||||||
updateTime: string
|
const DEVICE_MARKER_ID = 1
|
||||||
locationType: string
|
const TRAJECTORY_MARKER_BASE_ID = 1000
|
||||||
|
const DEVICE_MARKER_ICON = require('../../assets/tab-icons/location-selected.png')
|
||||||
|
const START_MARKER_ICON = require('../../assets/tab-icons/device-icon.png')
|
||||||
|
const END_MARKER_ICON = require('../../assets/tab-icons/device-selected.png')
|
||||||
|
const POINT_MARKER_ICON = require('../../assets/tab-icons/location-icon.png')
|
||||||
|
|
||||||
|
const DEFAULT_COORDINATES = {
|
||||||
|
latitude: 39.9042,
|
||||||
|
longitude: 116.4074,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Coordinates {
|
const MODE_OPTIONS: Array<{ key: TrajectoryMode; label: string }> = [
|
||||||
latitude: number
|
{ key: 'current', label: '当前位置' },
|
||||||
longitude: number
|
{ key: 'today', label: '今日轨迹' },
|
||||||
|
{ key: 'recent', label: '最近点位' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatTime(value?: string | null): string {
|
||||||
|
if (!value) return '--'
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return value
|
||||||
|
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||||
|
const day = `${date.getDate()}`.padStart(2, '0')
|
||||||
|
const hour = `${date.getHours()}`.padStart(2, '0')
|
||||||
|
const minute = `${date.getMinutes()}`.padStart(2, '0')
|
||||||
|
return `${month}-${day} ${hour}:${minute}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模拟接口:获取设备位置(返回随机经纬度)
|
function formatAccuracy(value?: number | null): string {
|
||||||
const fetchDeviceLocation = async (): Promise<Coordinates> => {
|
if (value === undefined || value === null) return '未知'
|
||||||
// 模拟网络延迟
|
return `${value} 米`
|
||||||
await new Promise(resolve => setTimeout(resolve, 800))
|
}
|
||||||
|
|
||||||
// 在北京范围内生成随机坐标(大致范围)
|
function getTodayStartISOString(): string {
|
||||||
const baseLat = 39.9042
|
const now = new Date()
|
||||||
const baseLng = 116.4074
|
now.setHours(0, 0, 0, 0)
|
||||||
const randomOffset = () => (Math.random() - 0.5) * 0.1 // 约 ±5km 的偏移
|
return now.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
function getRecentStartISOString(hours = 24): string {
|
||||||
latitude: baseLat + randomOffset(),
|
return new Date(Date.now() - hours * 60 * 60 * 1000).toISOString()
|
||||||
longitude: baseLng + randomOffset()
|
}
|
||||||
}
|
|
||||||
|
function getMarkerLabel(point: DeviceTrajectoryPoint | DeviceLocation | null): string {
|
||||||
|
if (!point) return ''
|
||||||
|
return point.child_name || point.device_id
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTrajectoryMarkerId(index: number): number {
|
||||||
|
return TRAJECTORY_MARKER_BASE_ID + index
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Location() {
|
export default function Location() {
|
||||||
const [location, setLocation] = useState<LocationInfo>({
|
const [loading, setLoading] = useState(true)
|
||||||
address: '阳光社区 3期 附近',
|
const [deviceLocation, setDeviceLocation] = useState<DeviceLocation | null>(null)
|
||||||
updateTime: '刚刚',
|
const [trajectory, setTrajectory] = useState<DeviceTrajectoryPoint[]>([])
|
||||||
locationType: '网络定位'
|
const [selectedPoint, setSelectedPoint] = useState<DeviceTrajectoryPoint | null>(null)
|
||||||
})
|
const [trajectoryMode, setTrajectoryMode] = useState<TrajectoryMode>('current')
|
||||||
|
const [coordinates, setCoordinates] = useState(DEFAULT_COORDINATES)
|
||||||
|
|
||||||
const [coordinates, setCoordinates] = useState<Coordinates>({
|
|
||||||
latitude: 39.9042,
|
|
||||||
longitude: 116.4074
|
|
||||||
})
|
|
||||||
|
|
||||||
// 每次页面显示时获取最新位置
|
|
||||||
useDidShow(() => {
|
useDidShow(() => {
|
||||||
loadLocation()
|
void loadLocation()
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadLocation = async () => {
|
const loadLocation = async (showToast = false, nextMode?: TrajectoryMode) => {
|
||||||
try {
|
const targetMode = nextMode || trajectoryMode
|
||||||
const coords = await fetchDeviceLocation()
|
|
||||||
setCoordinates(coords)
|
if (!getToken()) {
|
||||||
} catch (error) {
|
Taro.reLaunch({ url: '/pages/login/index' })
|
||||||
console.error('获取位置失败:', error)
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const refreshLocation = async () => {
|
if (showToast) {
|
||||||
Taro.showToast({ title: '正在刷新位置...', icon: 'loading' })
|
Taro.showLoading({ title: '刷新中...' })
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
if (nextMode) {
|
||||||
|
setTrajectoryMode(nextMode)
|
||||||
|
setSelectedPoint(null)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const coords = await fetchDeviceLocation()
|
const currentLocation = await getCurrentDeviceLocation()
|
||||||
setCoordinates(coords)
|
setDeviceLocation(currentLocation)
|
||||||
setLocation({
|
|
||||||
...location,
|
const nextCoordinates = currentLocation
|
||||||
updateTime: '刚刚'
|
? {
|
||||||
|
latitude: currentLocation.lat,
|
||||||
|
longitude: currentLocation.lng,
|
||||||
|
}
|
||||||
|
: DEFAULT_COORDINATES
|
||||||
|
|
||||||
|
let points: DeviceTrajectoryPoint[] = []
|
||||||
|
if (targetMode === 'today') {
|
||||||
|
const response = await getDeviceTrajectory({
|
||||||
|
startAt: getTodayStartISOString(),
|
||||||
|
limit: 200,
|
||||||
|
})
|
||||||
|
points = response?.items || []
|
||||||
|
} else if (targetMode === 'recent') {
|
||||||
|
const response = await getDeviceTrajectory({
|
||||||
|
startAt: getRecentStartISOString(72),
|
||||||
|
limit: 100,
|
||||||
|
})
|
||||||
|
points = response?.items || []
|
||||||
|
}
|
||||||
|
|
||||||
|
setTrajectory(points)
|
||||||
|
setSelectedPoint(points.length > 0 ? points[points.length - 1] : null)
|
||||||
|
|
||||||
|
const focusPoint = points.length > 0 ? points[points.length - 1] : currentLocation
|
||||||
|
if (focusPoint) {
|
||||||
|
setCoordinates({
|
||||||
|
latitude: focusPoint.lat,
|
||||||
|
longitude: focusPoint.lng,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setCoordinates(nextCoordinates)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showToast) {
|
||||||
|
Taro.showToast({
|
||||||
|
title: currentLocation || points.length > 0 ? '位置已更新' : '暂无设备位置',
|
||||||
|
icon: currentLocation || points.length > 0 ? 'success' : 'none',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[location] load failed:', error)
|
||||||
|
Taro.showToast({
|
||||||
|
title: error?.message || '位置更新失败',
|
||||||
|
icon: 'none',
|
||||||
})
|
})
|
||||||
Taro.showToast({ title: '位置已更新', icon: 'success' })
|
} finally {
|
||||||
} catch (error) {
|
if (showToast) {
|
||||||
Taro.showToast({ title: '位置更新失败', icon: 'none' })
|
Taro.hideLoading()
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleMarkerTap = (event: any) => {
|
||||||
|
const markerId = Number(event?.detail?.markerId)
|
||||||
|
if (markerId === DEVICE_MARKER_ID && deviceLocation) {
|
||||||
|
setCoordinates({
|
||||||
|
latitude: deviceLocation.lat,
|
||||||
|
longitude: deviceLocation.lng,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointIndex = markerId - TRAJECTORY_MARKER_BASE_ID
|
||||||
|
const point = trajectory[pointIndex]
|
||||||
|
if (!point) return
|
||||||
|
|
||||||
|
setSelectedPoint(point)
|
||||||
|
setCoordinates({
|
||||||
|
latitude: point.lat,
|
||||||
|
longitude: point.lng,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentMarker =
|
||||||
|
deviceLocation && trajectoryMode === 'current'
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: DEVICE_MARKER_ID,
|
||||||
|
latitude: deviceLocation.lat,
|
||||||
|
longitude: deviceLocation.lng,
|
||||||
|
title: getMarkerLabel(deviceLocation),
|
||||||
|
iconPath: DEVICE_MARKER_ICON,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
callout: {
|
||||||
|
content: `当前位置\n${formatTime(deviceLocation.device_time)}`,
|
||||||
|
color: '#1A1A1A',
|
||||||
|
fontSize: 13,
|
||||||
|
anchorX: 0,
|
||||||
|
anchorY: -4,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#FF8C42',
|
||||||
|
bgColor: '#FFFFFF',
|
||||||
|
padding: 8,
|
||||||
|
display: 'BYCLICK' as const,
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
|
||||||
|
const trajectoryMarkers =
|
||||||
|
trajectory.length > 0
|
||||||
|
? trajectory.map((point, index) => {
|
||||||
|
const isFirst = index === 0
|
||||||
|
const isLast = index === trajectory.length - 1
|
||||||
|
const isSelected = selectedPoint?.id === point.id
|
||||||
|
return {
|
||||||
|
id: getTrajectoryMarkerId(index),
|
||||||
|
latitude: point.lat,
|
||||||
|
longitude: point.lng,
|
||||||
|
title: `${isFirst ? '起点' : isLast ? '终点' : '轨迹点'} ${formatTime(point.device_time)}`,
|
||||||
|
iconPath: isFirst ? START_MARKER_ICON : isLast ? END_MARKER_ICON : POINT_MARKER_ICON,
|
||||||
|
width: isSelected ? 36 : isFirst || isLast ? 32 : 22,
|
||||||
|
height: isSelected ? 36 : isFirst || isLast ? 32 : 22,
|
||||||
|
zIndex: isSelected ? 20 : isFirst || isLast ? 12 : 8,
|
||||||
|
callout: {
|
||||||
|
content: `${isFirst ? '起点' : isLast ? '终点' : '轨迹点'}\n${formatTime(point.device_time)}`,
|
||||||
|
color: '#1A1A1A',
|
||||||
|
fontSize: 13,
|
||||||
|
anchorX: 0,
|
||||||
|
anchorY: -4,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: isSelected ? '#FF8C42' : '#E6E6E6',
|
||||||
|
bgColor: '#FFFFFF',
|
||||||
|
padding: 8,
|
||||||
|
display: isSelected ? ('ALWAYS' as const) : ('BYCLICK' as const),
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
|
||||||
|
const markers = trajectoryMode === 'current' ? currentMarker : trajectoryMarkers
|
||||||
|
|
||||||
|
const polyline =
|
||||||
|
trajectory.length >= 2
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
points: trajectory.map((item) => ({
|
||||||
|
latitude: item.lat,
|
||||||
|
longitude: item.lng,
|
||||||
|
})),
|
||||||
|
color: '#FF8C42',
|
||||||
|
width: 6,
|
||||||
|
dottedLine: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
|
||||||
|
const summaryTitle =
|
||||||
|
trajectoryMode === 'current'
|
||||||
|
? '当前设备坐标'
|
||||||
|
: trajectoryMode === 'today'
|
||||||
|
? '今日轨迹'
|
||||||
|
: '最近点位'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='location-page'>
|
<View className='location-page'>
|
||||||
<View className='page-header'>
|
<View className='page-header'>
|
||||||
<Text className='page-title'>设备定位</Text>
|
<Text className='page-title'>设备定位</Text>
|
||||||
<Text className='page-subtitle'>基于运营商网络基站定位</Text>
|
</View>
|
||||||
|
|
||||||
|
<View className='mode-switch'>
|
||||||
|
{MODE_OPTIONS.map((item) => (
|
||||||
|
<View
|
||||||
|
key={item.key}
|
||||||
|
className={`mode-chip ${trajectoryMode === item.key ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (trajectoryMode === item.key) return
|
||||||
|
void loadLocation(false, item.key)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='mode-chip-text'>{item.label}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='map-section'>
|
<View className='map-section'>
|
||||||
@@ -85,43 +288,106 @@ export default function Location() {
|
|||||||
latitude={coordinates.latitude}
|
latitude={coordinates.latitude}
|
||||||
longitude={coordinates.longitude}
|
longitude={coordinates.longitude}
|
||||||
scale={15}
|
scale={15}
|
||||||
markers={[{
|
markers={markers}
|
||||||
id: 1,
|
polyline={polyline}
|
||||||
latitude: coordinates.latitude,
|
onMarkerTap={handleMarkerTap}
|
||||||
longitude: coordinates.longitude,
|
onCalloutTap={handleMarkerTap}
|
||||||
title: '设备位置',
|
onError={(event) => {
|
||||||
width: 40,
|
console.error('[location] map error:', event.detail)
|
||||||
height: 40
|
}}
|
||||||
}]}
|
|
||||||
showLocation
|
|
||||||
/>
|
/>
|
||||||
<View className='location-marker'>
|
|
||||||
<View className='marker-pin'>
|
|
||||||
<Text className='marker-emoji'>🤖</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='location-card'>
|
<View className='location-card'>
|
||||||
<View className='card-header'>
|
<View className='card-header'>
|
||||||
<View className='header-left'>
|
<View className='header-left'>
|
||||||
<Text className='location-title'>当前大致位置</Text>
|
<Text className='location-title'>{summaryTitle}</Text>
|
||||||
<Text className='location-update'>更新于 {location.updateTime} ({location.locationType})</Text>
|
<Text className='location-update'>
|
||||||
|
{loading
|
||||||
|
? '正在获取设备位置...'
|
||||||
|
: trajectoryMode === 'current'
|
||||||
|
? `更新于 ${formatTime(deviceLocation?.updated_at)}`
|
||||||
|
: `轨迹点 ${trajectory.length} 个 · 最近更新 ${formatTime(trajectory[trajectory.length - 1]?.device_time)}`}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='refresh-btn' onClick={refreshLocation}>
|
<View className='refresh-btn' onClick={() => loadLocation(true)}>
|
||||||
<Text className='refresh-icon'>↻</Text>
|
<Text className='refresh-icon'>↻</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='location-detail'>
|
{trajectoryMode === 'current' ? (
|
||||||
<View className='location-icon'>
|
deviceLocation ? (
|
||||||
<Text>📍</Text>
|
<View className='location-detail'>
|
||||||
|
<View className='location-icon'>
|
||||||
|
<Text>📍</Text>
|
||||||
|
</View>
|
||||||
|
<View className='location-info'>
|
||||||
|
<Text className='location-coordinate'>
|
||||||
|
{deviceLocation.lat.toFixed(6)}, {deviceLocation.lng.toFixed(6)}
|
||||||
|
</Text>
|
||||||
|
<Text className='location-desc'>
|
||||||
|
设备 {deviceLocation.device_id} · {deviceLocation.child_name || '未命名儿童'}
|
||||||
|
</Text>
|
||||||
|
<Text className='location-desc'>上报时间 {formatTime(deviceLocation.device_time)}</Text>
|
||||||
|
<Text className='location-desc'>
|
||||||
|
坐标系 {deviceLocation.coord_type} · 精度 {formatAccuracy(deviceLocation.accuracy_m)}
|
||||||
|
</Text>
|
||||||
|
{deviceLocation.battery_pct !== null && deviceLocation.battery_pct !== undefined && (
|
||||||
|
<Text className='location-desc'>设备电量 {deviceLocation.battery_pct}%</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className='location-empty'>
|
||||||
|
<Text className='location-empty-title'>暂无设备位置</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
) : trajectory.length > 0 ? (
|
||||||
|
<View className='location-stack'>
|
||||||
|
{selectedPoint && (
|
||||||
|
<View className='location-detail selected-point-card'>
|
||||||
|
<View className='location-icon selected'>
|
||||||
|
<Text>●</Text>
|
||||||
|
</View>
|
||||||
|
<View className='location-info'>
|
||||||
|
<Text className='location-coordinate'>
|
||||||
|
{selectedPoint.lat.toFixed(6)}, {selectedPoint.lng.toFixed(6)}
|
||||||
|
</Text>
|
||||||
|
<Text className='location-desc'>上报时间 {formatTime(selectedPoint.device_time)}</Text>
|
||||||
|
<Text className='location-desc'>
|
||||||
|
坐标系 {selectedPoint.coord_type} · 精度 {formatAccuracy(selectedPoint.accuracy_m)}
|
||||||
|
</Text>
|
||||||
|
{selectedPoint.battery_pct !== null && selectedPoint.battery_pct !== undefined && (
|
||||||
|
<Text className='location-desc'>设备电量 {selectedPoint.battery_pct}%</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View className='location-detail'>
|
||||||
|
<View className='location-icon'>
|
||||||
|
<Text>🛣️</Text>
|
||||||
|
</View>
|
||||||
|
<View className='location-info'>
|
||||||
|
<Text className='location-coordinate'>共 {trajectory.length} 个轨迹点</Text>
|
||||||
|
<Text className='location-desc'>
|
||||||
|
起点 {formatTime(trajectory[0].device_time)} · {trajectory[0].lat.toFixed(6)}, {trajectory[0].lng.toFixed(6)}
|
||||||
|
</Text>
|
||||||
|
<Text className='location-desc'>
|
||||||
|
终点 {formatTime(trajectory[trajectory.length - 1].device_time)} ·{' '}
|
||||||
|
{trajectory[trajectory.length - 1].lat.toFixed(6)}, {trajectory[trajectory.length - 1].lng.toFixed(6)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className='location-info'>
|
) : (
|
||||||
<Text className='location-address'>{location.address}</Text>
|
<View className='location-empty'>
|
||||||
<Text className='location-desc'>通过设备 4G 信号基站解析获得,误差范围约 50-200 米。</Text>
|
<Text className='location-empty-title'>暂无轨迹</Text>
|
||||||
|
<Text className='location-empty-desc'>
|
||||||
|
{trajectoryMode === 'today' ? '今天还没有新的历史点位。' : '最近时间范围内没有新的历史点位。'}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|||||||
67
banban-mini/src/services/location.ts
Normal file
67
banban-mini/src/services/location.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { request } from './api'
|
||||||
|
import { resolveActiveBinding } from './binding'
|
||||||
|
|
||||||
|
export interface DeviceLocation {
|
||||||
|
child_id: number
|
||||||
|
child_name?: string | null
|
||||||
|
device_id: string
|
||||||
|
coord_type: string
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
accuracy_m?: number | null
|
||||||
|
altitude_m?: number | null
|
||||||
|
speed_mps?: number | null
|
||||||
|
heading_deg?: number | null
|
||||||
|
source: number
|
||||||
|
battery_pct?: number | null
|
||||||
|
device_time: string
|
||||||
|
server_time: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceTrajectoryPoint extends DeviceLocation {
|
||||||
|
id: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceTrajectoryResponse {
|
||||||
|
items: DeviceTrajectoryPoint[]
|
||||||
|
total: number
|
||||||
|
start_at?: string | null
|
||||||
|
end_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentDeviceLocation(deviceId?: string): Promise<DeviceLocation | null> {
|
||||||
|
const resolvedDeviceId = String(deviceId || '').trim() || (await resolveActiveBinding())?.device_id || ''
|
||||||
|
if (!resolvedDeviceId) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await request<DeviceLocation>(`/devices/${resolvedDeviceId}/location`)
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.status === 404) return null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDeviceTrajectory(params?: {
|
||||||
|
deviceId?: string
|
||||||
|
startAt?: string
|
||||||
|
endAt?: string
|
||||||
|
limit?: number
|
||||||
|
}): Promise<DeviceTrajectoryResponse | null> {
|
||||||
|
const resolvedDeviceId = String(params?.deviceId || '').trim() || (await resolveActiveBinding())?.device_id || ''
|
||||||
|
if (!resolvedDeviceId) return null
|
||||||
|
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
if (params?.startAt) query.append('start_at', params.startAt)
|
||||||
|
if (params?.endAt) query.append('end_at', params.endAt)
|
||||||
|
if (params?.limit) query.append('limit', String(params.limit))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const suffix = query.toString() ? `?${query.toString()}` : ''
|
||||||
|
return await request<DeviceTrajectoryResponse>(`/devices/${resolvedDeviceId}/trajectory${suffix}`)
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.status === 404) return null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user