517 lines
18 KiB
TypeScript
517 lines
18 KiB
TypeScript
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>
|
||
)
|
||
}
|