feat(小程序): 支持设备定位与轨迹展示

This commit is contained in:
stu2not
2026-04-22 17:26:06 +08:00
parent 6947ead855
commit 6d956677af
3 changed files with 474 additions and 102 deletions

View File

@@ -14,15 +14,40 @@
font-weight: 700;
color: #1A1A1A;
display: block;
margin-bottom: 12px;
line-height: 1.2;
}
.page-subtitle {
font-size: 28px;
color: #666666;
line-height: 1.4;
}
.mode-switch {
margin: 0 24px 20px;
padding: 8px;
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 {
@@ -36,29 +61,6 @@
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 {
background: #FFFFFF;
border-radius: 20px;
@@ -127,12 +129,18 @@
font-size: 32px;
margin-right: 18px;
flex-shrink: 0;
&.selected {
background: #FFF0E6;
color: #FF8C42;
}
}
.location-info {
flex: 1;
.location-address {
.location-address,
.location-coordinate {
font-size: 30px;
font-weight: 600;
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;
}

View File

@@ -1,82 +1,285 @@
import { View, Text, Map } from '@tarojs/components'
import { useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import { getToken } from '@/services/auth'
import {
getCurrentDeviceLocation,
getDeviceTrajectory,
DeviceLocation,
DeviceTrajectoryPoint,
} from '@/services/location'
import './index.scss'
interface LocationInfo {
address: string
updateTime: string
locationType: string
type TrajectoryMode = 'current' | 'today' | 'recent'
const DEVICE_MARKER_ID = 1
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 {
latitude: number
longitude: number
const MODE_OPTIONS: Array<{ key: TrajectoryMode; label: string }> = [
{ key: 'current', label: '当前位置' },
{ 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}`
}
// 模拟接口:获取设备位置(返回随机经纬度)
const fetchDeviceLocation = async (): Promise<Coordinates> => {
// 模拟网络延迟
await new Promise(resolve => setTimeout(resolve, 800))
// 在北京范围内生成随机坐标(大致范围)
const baseLat = 39.9042
const baseLng = 116.4074
const randomOffset = () => (Math.random() - 0.5) * 0.1 // 约 ±5km 的偏移
return {
latitude: baseLat + randomOffset(),
longitude: baseLng + randomOffset()
function formatAccuracy(value?: number | null): string {
if (value === undefined || value === null) return '未知'
return `${value}`
}
function getTodayStartISOString(): string {
const now = new Date()
now.setHours(0, 0, 0, 0)
return now.toISOString()
}
function getRecentStartISOString(hours = 24): string {
return new Date(Date.now() - hours * 60 * 60 * 1000).toISOString()
}
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() {
const [location, setLocation] = useState<LocationInfo>({
address: '阳光社区 3期 附近',
updateTime: '刚刚',
locationType: '网络定位'
})
const [loading, setLoading] = useState(true)
const [deviceLocation, setDeviceLocation] = useState<DeviceLocation | null>(null)
const [trajectory, setTrajectory] = useState<DeviceTrajectoryPoint[]>([])
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(() => {
loadLocation()
void loadLocation()
})
const loadLocation = async () => {
try {
const coords = await fetchDeviceLocation()
setCoordinates(coords)
} catch (error) {
console.error('获取位置失败:', error)
}
const loadLocation = async (showToast = false, nextMode?: TrajectoryMode) => {
const targetMode = nextMode || trajectoryMode
if (!getToken()) {
Taro.reLaunch({ url: '/pages/login/index' })
return
}
const refreshLocation = async () => {
Taro.showToast({ title: '正在刷新位置...', icon: 'loading' })
if (showToast) {
Taro.showLoading({ title: '刷新中...' })
}
setLoading(true)
if (nextMode) {
setTrajectoryMode(nextMode)
setSelectedPoint(null)
}
try {
const coords = await fetchDeviceLocation()
setCoordinates(coords)
setLocation({
...location,
updateTime: '刚刚'
const currentLocation = await getCurrentDeviceLocation()
setDeviceLocation(currentLocation)
const nextCoordinates = currentLocation
? {
latitude: currentLocation.lat,
longitude: currentLocation.lng,
}
: DEFAULT_COORDINATES
let points: DeviceTrajectoryPoint[] = []
if (targetMode === 'today') {
const response = await getDeviceTrajectory({
startAt: getTodayStartISOString(),
limit: 200,
})
Taro.showToast({ title: '位置已更新', icon: 'success' })
} catch (error) {
Taro.showToast({ title: '位置更新失败', icon: 'none' })
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',
})
} finally {
if (showToast) {
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 (
<View className='location-page'>
<View className='page-header'>
<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 className='map-section'>
@@ -85,43 +288,106 @@ export default function Location() {
latitude={coordinates.latitude}
longitude={coordinates.longitude}
scale={15}
markers={[{
id: 1,
latitude: coordinates.latitude,
longitude: coordinates.longitude,
title: '设备位置',
width: 40,
height: 40
}]}
showLocation
markers={markers}
polyline={polyline}
onMarkerTap={handleMarkerTap}
onCalloutTap={handleMarkerTap}
onError={(event) => {
console.error('[location] map error:', event.detail)
}}
/>
<View className='location-marker'>
<View className='marker-pin'>
<Text className='marker-emoji'>🤖</Text>
</View>
</View>
</View>
<View className='location-card'>
<View className='card-header'>
<View className='header-left'>
<Text className='location-title'></Text>
<Text className='location-update'> {location.updateTime} ({location.locationType})</Text>
<Text className='location-title'>{summaryTitle}</Text>
<Text className='location-update'>
{loading
? '正在获取设备位置...'
: trajectoryMode === 'current'
? `更新于 ${formatTime(deviceLocation?.updated_at)}`
: `轨迹点 ${trajectory.length} 个 · 最近更新 ${formatTime(trajectory[trajectory.length - 1]?.device_time)}`}
</Text>
</View>
<View className='refresh-btn' onClick={refreshLocation}>
<View className='refresh-btn' onClick={() => loadLocation(true)}>
<Text className='refresh-icon'></Text>
</View>
</View>
{trajectoryMode === 'current' ? (
deviceLocation ? (
<View className='location-detail'>
<View className='location-icon'>
<Text>📍</Text>
</View>
<View className='location-info'>
<Text className='location-address'>{location.address}</Text>
<Text className='location-desc'> 4G 50-200 </Text>
<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 className='location-empty'>
<Text className='location-empty-title'></Text>
<Text className='location-empty-desc'>
{trajectoryMode === 'today' ? '今天还没有新的历史点位。' : '最近时间范围内没有新的历史点位。'}
</Text>
</View>
)}
</View>
</View>
)

View 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
}
}