Files
banban/banban-mini/src/pages/location/index.tsx

517 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { View, Text, Map } from '@tarojs/components'
import { useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import { getToken } from '@/services/auth'
import { loadCurrentChildBindingContext } from '@/services/binding'
import { getDeviceOnlineStatus } from '@/services/device'
import {
getCurrentDeviceLocation,
getDeviceTrajectory,
DeviceLocation,
DeviceTrajectoryPoint,
} from '@/services/location'
import { useSystemBanner } from '@/components/system-banner/use-system-banner'
import './index.scss'
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,
}
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}`
}
function formatAccuracy(value?: number | null): string {
if (value === undefined || value === null) return '未知'
return `${value}`
}
function formatOptionalNumber(value?: number | null, digits = 1): string | null {
if (value === undefined || value === null) return null
return Number(value).toFixed(digits)
}
function buildLocationDetailLines(
point: DeviceLocation | DeviceTrajectoryPoint,
options?: { includeIdentity?: boolean }
): string[] {
const lines: string[] = []
if (options?.includeIdentity) {
lines.push(`设备 ${point.device_id} · ${point.child_name || '未命名儿童'}`)
}
lines.push(`上报时间 ${formatTime(point.device_time)}`)
if (point.server_time) {
lines.push(`服务端时间 ${formatTime(point.server_time)}`)
}
if (point.coord_type) {
lines.push(`坐标系 ${point.coord_type}`)
}
if (point.accuracy_m !== null && point.accuracy_m !== undefined) {
lines.push(`定位精度 ${formatAccuracy(point.accuracy_m)}`)
}
const altitudeText = formatOptionalNumber(point.altitude_m)
if (altitudeText) {
lines.push(`海拔 ${altitudeText}`)
}
const speedText = formatOptionalNumber(point.speed_mps)
if (speedText) {
lines.push(`速度 ${speedText} 米/秒`)
}
if (point.heading_deg !== null && point.heading_deg !== undefined) {
lines.push(`方向 ${point.heading_deg}°`)
}
if (point.source !== null && point.source !== undefined && point.source > 0) {
lines.push(`定位来源 ${point.source}`)
}
if (point.battery_pct !== null && point.battery_pct !== undefined) {
lines.push(`设备电量 ${point.battery_pct}%`)
}
return lines
}
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 systemBanner = useSystemBanner()
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 [locationNotice, setLocationNotice] = useState<string | null>(null)
const [emptyState, setEmptyState] = useState<{ title: string; desc: string; actionText: string; actionUrl: string } | null>(
null
)
useDidShow(() => {
void loadLocation()
})
const loadLocation = async (showToast = false, nextMode?: TrajectoryMode) => {
const targetMode = nextMode || trajectoryMode
if (!getToken()) {
Taro.reLaunch({ url: '/pages/login/index' })
return
}
if (showToast) {
Taro.showLoading({ title: '刷新中...' })
}
setLoading(true)
setEmptyState(null)
setLocationNotice(null)
if (nextMode) {
setTrajectoryMode(nextMode)
setSelectedPoint(null)
}
try {
const context = await loadCurrentChildBindingContext()
if (!context.currentChild) {
setDeviceLocation(null)
setTrajectory([])
setSelectedPoint(null)
setCoordinates(DEFAULT_COORDINATES)
setEmptyState({
title: '还没有当前孩子',
desc: '请先去设置与管理页创建或选择一个孩子,再查看定位。',
actionText: '去设置与管理',
actionUrl: '/pages/sleep/index',
})
return
}
const resolvedDeviceId = context.currentBinding?.device_id || ''
if (!resolvedDeviceId) {
setDeviceLocation(null)
setTrajectory([])
setSelectedPoint(null)
setCoordinates(DEFAULT_COORDINATES)
setEmptyState({
title: '当前孩子还没有绑定设备',
desc: '先给当前孩子绑定一台设备,定位和轨迹页才会有内容。',
actionText: '去绑定设备',
actionUrl: '/pages/bind/index',
})
return
}
const onlineStatus = await getDeviceOnlineStatus(resolvedDeviceId)
if (onlineStatus && !onlineStatus.online) {
setDeviceLocation(null)
setTrajectory([])
setSelectedPoint(null)
setCoordinates(DEFAULT_COORDINATES)
setLocationNotice(onlineStatus.message || '设备不在线或暂时无法上报位置')
return
}
const currentLocation = await getCurrentDeviceLocation(resolvedDeviceId)
setDeviceLocation(currentLocation)
const nextCoordinates = currentLocation
? {
latitude: currentLocation.lat,
longitude: currentLocation.lng,
}
: DEFAULT_COORDINATES
let points: DeviceTrajectoryPoint[] = []
if (targetMode === 'today') {
const response = await getDeviceTrajectory({
deviceId: resolvedDeviceId,
startAt: getTodayStartISOString(),
limit: 200,
})
points = response?.items || []
} else if (targetMode === 'recent') {
const response = await getDeviceTrajectory({
deviceId: resolvedDeviceId,
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>
</View>
{emptyState ? (
<View className='location-empty-shell'>
<View className='location-empty-card'>
<Text className='location-empty-title'>{emptyState.title}</Text>
<Text className='location-empty-desc'>{emptyState.desc}</Text>
<View
className='location-empty-action'
onClick={() => {
if (emptyState.actionUrl === '/pages/sleep/index') {
Taro.switchTab({ url: emptyState.actionUrl })
return
}
Taro.navigateTo({ url: emptyState.actionUrl })
}}
>
<Text className='location-empty-action-text'>{emptyState.actionText}</Text>
</View>
</View>
</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'>
<Map
className='map'
latitude={coordinates.latitude}
longitude={coordinates.longitude}
scale={15}
markers={markers}
polyline={polyline}
onMarkerTap={handleMarkerTap}
onCalloutTap={handleMarkerTap}
onError={(event) => {
console.error('[location] map error:', event.detail)
}}
/>
</View>
<View className='location-card'>
<View className='card-header'>
<View className='header-left'>
<Text className='location-title'>{summaryTitle}</Text>
<Text className='location-update'>
{locationNotice
? locationNotice
: loading
? '正在获取设备位置...'
: trajectoryMode === 'current'
? `更新于 ${formatTime(deviceLocation?.updated_at)}`
: `轨迹点 ${trajectory.length} 个 · 最近更新 ${formatTime(trajectory[trajectory.length - 1]?.device_time)}`}
</Text>
</View>
<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-coordinate'>
{deviceLocation.lat.toFixed(6)}, {deviceLocation.lng.toFixed(6)}
</Text>
{buildLocationDetailLines(deviceLocation, { includeIdentity: true }).map((line, index) => (
<Text key={`current-${index}`} className='location-desc'>
{line}
</Text>
))}
</View>
</View>
) : (
<View className='location-empty'>
<Text className='location-empty-title'>{locationNotice || '当前孩子暂无设备位置'}</Text>
{locationNotice && <Text className='location-empty-desc'></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>
{buildLocationDetailLines(selectedPoint).map((line, index) => (
<Text key={`selected-${index}`} className='location-desc'>
{line}
</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>
</>
)}
{systemBanner}
</View>
)
}