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

585 lines
21 KiB
TypeScript

import { getPageStyle } from '@/utils/page-style'
import PageTitle from '@/components/page-title'
import { setActiveTabRoute } from '@/utils/tab-bar'
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 {
getCurrentDeviceLocation,
getDeviceTrajectory,
DeviceLocation,
DeviceTrajectoryPoint,
} from '@/services/location'
import { useSystemBanner } from '@/components/system-banner/use-system-banner'
import { usePageShare } from '@/utils/page-share'
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 getLocationAddress(point: DeviceLocation | DeviceTrajectoryPoint): string {
const address = String(point.address || '').trim()
if (address) return address
return '地址解析中'
}
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() {
usePageShare()
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
)
const [isGuest, setIsGuest] = useState(false)
useDidShow(() => {
setActiveTabRoute('pages/location/index')
void loadLocation(true)
})
const loadLocation = async (showToast = false, nextMode?: TrajectoryMode) => {
const targetMode = nextMode || trajectoryMode
if (!getToken()) {
setDeviceLocation(null)
setTrajectory([])
setSelectedPoint(null)
setCoordinates(DEFAULT_COORDINATES)
setEmptyState(null)
setLocationNotice(null)
setIsGuest(true)
setLoading(false)
return
}
setIsGuest(false)
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/bind/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 currentLocation = await getCurrentDeviceLocation(resolvedDeviceId)
setDeviceLocation(currentLocation)
if (currentLocation?.stale || currentLocation?.realtime === false) {
setLocationNotice('已显示最近一次定位')
}
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) {
if (showToast) {
Taro.hideLoading()
}
setLoading(false)
console.error('[location] load failed:', error)
const message = error?.status === 408 ? '暂时无法刷新定位' : error?.message || '位置更新失败'
Taro.showToast({
title: 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' style={getPageStyle(true)}>
<View className='page-header'>
<PageTitle title='设备定位' />
</View>
{isGuest ? (
<>
<View className='map-section guest-map-section'>
<Map
className='map'
latitude={DEFAULT_COORDINATES.latitude}
longitude={DEFAULT_COORDINATES.longitude}
scale={11}
onError={(event) => {
console.error('[location] guest map error:', event.detail)
}}
/>
</View>
<View className='guest-location-panel'>
<View className='location-empty-card'>
<Text className='location-empty-title'>设备定位与轨迹</Text>
<Text className='location-empty-desc'>这里用于展示设备当前位置、更新时间、定位精度和轨迹点。上方地图为功能预览,真实位置会在绑定设备后显示。</Text>
</View>
<View className='guest-location-card'>
<View className='guest-location-row'>
<Text className='guest-location-label'>当前位置</Text>
<Text className='guest-location-value'>绑定设备后显示</Text>
</View>
<View className='guest-location-row'>
<Text className='guest-location-label'>今日轨迹</Text>
<Text className='guest-location-value'>按上报时间形成轨迹</Text>
</View>
<View className='guest-location-row'>
<Text className='guest-location-label'>定位信息</Text>
<Text className='guest-location-value'>坐标、精度、电量和地址</Text>
</View>
</View>
<View className='guest-location-card'>
<Text className='guest-location-title'>开始使用</Text>
<Text className='guest-location-desc'>浏览地图和功能说明不需要登录;绑定设备或查看真实定位数据时,再进入登录流程。</Text>
<View
className='location-empty-action secondary'
onClick={() => {
Taro.setStorageSync('postLoginRedirect', '/pages/location/index')
Taro.navigateTo({ url: '/pages/login/index' })
}}
>
<Text className='location-empty-action-text'>去登录 / 绑定设备</Text>
</View>
</View>
</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-address'>{getLocationAddress(deviceLocation)}</Text>
<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>
)
}