From 6d956677afc063d46ce7ee1481c5b29fb0e4980f Mon Sep 17 00:00:00 2001 From: stu2not Date: Wed, 22 Apr 2026 17:26:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=B0=8F=E7=A8=8B=E5=BA=8F):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E8=AE=BE=E5=A4=87=E5=AE=9A=E4=BD=8D=E4=B8=8E=E8=BD=A8?= =?UTF-8?q?=E8=BF=B9=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- banban-mini/src/pages/location/index.scss | 97 +++-- banban-mini/src/pages/location/index.tsx | 412 ++++++++++++++++++---- banban-mini/src/services/location.ts | 67 ++++ 3 files changed, 474 insertions(+), 102 deletions(-) create mode 100644 banban-mini/src/services/location.ts diff --git a/banban-mini/src/pages/location/index.scss b/banban-mini/src/pages/location/index.scss index 576a317..5edceae 100644 --- a/banban-mini/src/pages/location/index.scss +++ b/banban-mini/src/pages/location/index.scss @@ -14,17 +14,42 @@ 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 { height: 50vh; position: relative; @@ -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; +} diff --git a/banban-mini/src/pages/location/index.tsx b/banban-mini/src/pages/location/index.tsx index 7e0474f..207aa17 100644 --- a/banban-mini/src/pages/location/index.tsx +++ b/banban-mini/src/pages/location/index.tsx @@ -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 => { - // 模拟网络延迟 - await new Promise(resolve => setTimeout(resolve, 800)) +function formatAccuracy(value?: number | null): string { + if (value === undefined || value === null) return '未知' + return `${value} 米` +} - // 在北京范围内生成随机坐标(大致范围) - const baseLat = 39.9042 - const baseLng = 116.4074 - const randomOffset = () => (Math.random() - 0.5) * 0.1 // 约 ±5km 的偏移 +function getTodayStartISOString(): string { + const now = new Date() + now.setHours(0, 0, 0, 0) + return now.toISOString() +} - return { - latitude: baseLat + randomOffset(), - longitude: baseLng + randomOffset() - } +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({ - address: '阳光社区 3期 附近', - updateTime: '刚刚', - locationType: '网络定位' - }) + const [loading, setLoading] = useState(true) + const [deviceLocation, setDeviceLocation] = useState(null) + const [trajectory, setTrajectory] = useState([]) + const [selectedPoint, setSelectedPoint] = useState(null) + const [trajectoryMode, setTrajectoryMode] = useState('current') + const [coordinates, setCoordinates] = useState(DEFAULT_COORDINATES) - const [coordinates, setCoordinates] = useState({ - 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, + }) + 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' }) - } catch (error) { - Taro.showToast({ title: '位置更新失败', 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 ( 设备定位 - 基于运营商网络基站定位 + + + + {MODE_OPTIONS.map((item) => ( + { + if (trajectoryMode === item.key) return + void loadLocation(false, item.key) + }} + > + {item.label} + + ))} @@ -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) + }} /> - - - 🤖 - - - 当前大致位置 - 更新于 {location.updateTime} ({location.locationType}) + {summaryTitle} + + {loading + ? '正在获取设备位置...' + : trajectoryMode === 'current' + ? `更新于 ${formatTime(deviceLocation?.updated_at)}` + : `轨迹点 ${trajectory.length} 个 · 最近更新 ${formatTime(trajectory[trajectory.length - 1]?.device_time)}`} + - + loadLocation(true)}> - - - 📍 + {trajectoryMode === 'current' ? ( + deviceLocation ? ( + + + 📍 + + + + {deviceLocation.lat.toFixed(6)}, {deviceLocation.lng.toFixed(6)} + + + 设备 {deviceLocation.device_id} · {deviceLocation.child_name || '未命名儿童'} + + 上报时间 {formatTime(deviceLocation.device_time)} + + 坐标系 {deviceLocation.coord_type} · 精度 {formatAccuracy(deviceLocation.accuracy_m)} + + {deviceLocation.battery_pct !== null && deviceLocation.battery_pct !== undefined && ( + 设备电量 {deviceLocation.battery_pct}% + )} + + + ) : ( + + 暂无设备位置 + + ) + ) : trajectory.length > 0 ? ( + + {selectedPoint && ( + + + + + + + {selectedPoint.lat.toFixed(6)}, {selectedPoint.lng.toFixed(6)} + + 上报时间 {formatTime(selectedPoint.device_time)} + + 坐标系 {selectedPoint.coord_type} · 精度 {formatAccuracy(selectedPoint.accuracy_m)} + + {selectedPoint.battery_pct !== null && selectedPoint.battery_pct !== undefined && ( + 设备电量 {selectedPoint.battery_pct}% + )} + + + )} + + + + 🛣️ + + + 共 {trajectory.length} 个轨迹点 + + 起点 {formatTime(trajectory[0].device_time)} · {trajectory[0].lat.toFixed(6)}, {trajectory[0].lng.toFixed(6)} + + + 终点 {formatTime(trajectory[trajectory.length - 1].device_time)} ·{' '} + {trajectory[trajectory.length - 1].lat.toFixed(6)}, {trajectory[trajectory.length - 1].lng.toFixed(6)} + + + - - {location.address} - 通过设备 4G 信号基站解析获得,误差范围约 50-200 米。 + ) : ( + + 暂无轨迹 + + {trajectoryMode === 'today' ? '今天还没有新的历史点位。' : '最近时间范围内没有新的历史点位。'} + - + )} ) diff --git a/banban-mini/src/services/location.ts b/banban-mini/src/services/location.ts new file mode 100644 index 0000000..7375451 --- /dev/null +++ b/banban-mini/src/services/location.ts @@ -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 { + const resolvedDeviceId = String(deviceId || '').trim() || (await resolveActiveBinding())?.device_id || '' + if (!resolvedDeviceId) return null + + try { + return await request(`/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 { + 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(`/devices/${resolvedDeviceId}/trajectory${suffix}`) + } catch (error: any) { + if (error?.status === 404) return null + throw error + } +}