完善登录绑定定位与管理页交互
This commit is contained in:
@@ -27,19 +27,39 @@
|
||||
|
||||
.bind-header {
|
||||
padding: 80px 32px 32px;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
|
||||
.back-btn {
|
||||
position: absolute;
|
||||
left: 32px;
|
||||
top: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 18px;
|
||||
border-radius: 999px;
|
||||
background: #FFFFFF;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.back-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.back-text {
|
||||
font-size: 26px;
|
||||
color: #1A1A1A;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 44px;
|
||||
font-weight: 700;
|
||||
color: #1A1A1A;
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 28px;
|
||||
color: #666666;
|
||||
padding: 0 120px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,14 @@ export default function Bind() {
|
||||
Taro.reLaunch({ url: '/pages/device/index' })
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (getCurrentPages().length > 1) {
|
||||
Taro.navigateBack()
|
||||
return
|
||||
}
|
||||
Taro.switchTab({ url: '/pages/device/index' })
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollingRef.current) {
|
||||
clearTimeout(pollingRef.current)
|
||||
@@ -123,10 +131,10 @@ export default function Bind() {
|
||||
}
|
||||
}
|
||||
|
||||
const schedulePoll = () => {
|
||||
const schedulePoll = (nextBindToken?: string) => {
|
||||
stopPolling()
|
||||
pollingRef.current = setTimeout(() => {
|
||||
void pollBindSession()
|
||||
void pollBindSession(nextBindToken)
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
@@ -138,17 +146,18 @@ export default function Bind() {
|
||||
setBindHint('')
|
||||
}
|
||||
|
||||
const pollBindSession = async () => {
|
||||
if (!bindToken) return
|
||||
const pollBindSession = async (nextBindToken?: string) => {
|
||||
const activeBindToken = (nextBindToken || bindToken).trim()
|
||||
if (!activeBindToken) return
|
||||
|
||||
try {
|
||||
const session = await getNFCBindSession(bindToken)
|
||||
const session = await getNFCBindSession(activeBindToken)
|
||||
setBindStatus(session.status)
|
||||
setCardUUID(session.card_uuid || '')
|
||||
|
||||
if (session.status === SESSION_STATUS_PENDING) {
|
||||
setBindHint('已发送绑卡指令,请拿自己的卡去设备上贴一下')
|
||||
schedulePoll()
|
||||
schedulePoll(activeBindToken)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -303,7 +312,7 @@ export default function Bind() {
|
||||
setBindToken(session.bind_token)
|
||||
setBindStatus(session.status)
|
||||
setBindHint('已发送绑卡指令,请去设备上贴自己的卡')
|
||||
schedulePoll()
|
||||
schedulePoll(session.bind_token)
|
||||
Taro.showToast({ title: '请去设备上贴卡', icon: 'none' })
|
||||
} catch (error: any) {
|
||||
console.error('[bind] submit failed:', error)
|
||||
@@ -331,12 +340,11 @@ export default function Bind() {
|
||||
return (
|
||||
<View className='bind-page'>
|
||||
<View className='bind-header'>
|
||||
<View className='back-btn' onClick={handleBack}>
|
||||
<Image className='back-icon' src={require('../../assets/tab-icons/arrow-left.png')} mode='aspectFit' />
|
||||
<Text className='back-text'>返回</Text>
|
||||
</View>
|
||||
<Text className='title'>{isPendingBinding ? '补全绑定' : '扫码贴卡绑定设备'}</Text>
|
||||
<Text className='subtitle'>
|
||||
{isPendingBinding
|
||||
? '当前设备已存在待补全关系,请先补充儿童资料'
|
||||
: '先选择儿童,再扫码设备,系统会下发绑卡指令,随后去设备上贴卡确认'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className='scan-area'>
|
||||
|
||||
@@ -157,6 +157,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
.location-empty-shell {
|
||||
margin: 0 24px;
|
||||
}
|
||||
|
||||
.location-empty-card {
|
||||
background: #FFFFFF;
|
||||
border-radius: 20px;
|
||||
padding: 32px 24px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.location-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -187,3 +198,19 @@
|
||||
color: #666666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.location-empty-action {
|
||||
margin-top: 24px;
|
||||
height: 88px;
|
||||
border-radius: 16px;
|
||||
background: #FF8C42;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.location-empty-action-text {
|
||||
font-size: 30px;
|
||||
color: #FFFFFF;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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,
|
||||
@@ -46,6 +47,56 @@ function formatAccuracy(value?: number | null): string {
|
||||
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)
|
||||
@@ -72,6 +123,9 @@ export default function Location() {
|
||||
const [selectedPoint, setSelectedPoint] = useState<DeviceTrajectoryPoint | null>(null)
|
||||
const [trajectoryMode, setTrajectoryMode] = useState<TrajectoryMode>('current')
|
||||
const [coordinates, setCoordinates] = useState(DEFAULT_COORDINATES)
|
||||
const [emptyState, setEmptyState] = useState<{ title: string; desc: string; actionText: string; actionUrl: string } | null>(
|
||||
null
|
||||
)
|
||||
|
||||
useDidShow(() => {
|
||||
void loadLocation()
|
||||
@@ -90,13 +144,44 @@ export default function Location() {
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setEmptyState(null)
|
||||
if (nextMode) {
|
||||
setTrajectoryMode(nextMode)
|
||||
setSelectedPoint(null)
|
||||
}
|
||||
|
||||
try {
|
||||
const currentLocation = await getCurrentDeviceLocation()
|
||||
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 currentLocation = await getCurrentDeviceLocation(resolvedDeviceId)
|
||||
setDeviceLocation(currentLocation)
|
||||
|
||||
const nextCoordinates = currentLocation
|
||||
@@ -109,12 +194,14 @@ export default function Location() {
|
||||
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,
|
||||
})
|
||||
@@ -267,128 +354,144 @@ export default function Location() {
|
||||
<Text className='page-title'>设备定位</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'>
|
||||
<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'>
|
||||
{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>
|
||||
{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>
|
||||
|
||||
{trajectoryMode === 'current' ? (
|
||||
deviceLocation ? (
|
||||
<View className='location-detail'>
|
||||
<View className='location-icon'>
|
||||
<Text>📍</Text>
|
||||
<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'>
|
||||
{loading
|
||||
? '正在获取设备位置...'
|
||||
: trajectoryMode === 'current'
|
||||
? `更新于 ${formatTime(deviceLocation?.updated_at)}`
|
||||
: `轨迹点 ${trajectory.length} 个 · 最近更新 ${formatTime(trajectory[trajectory.length - 1]?.device_time)}`}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='location-info'>
|
||||
<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 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'>当前孩子暂无设备位置</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>
|
||||
) : (
|
||||
<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 className='location-empty'>
|
||||
<Text className='location-empty-title'>暂无轨迹</Text>
|
||||
<Text className='location-empty-desc'>
|
||||
{trajectoryMode === 'today' ? '当前孩子今天还没有新的历史点位。' : '当前孩子最近没有新的历史点位。'}
|
||||
</Text>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -72,6 +72,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
.nickname-card {
|
||||
background: #FFFFFF;
|
||||
border-radius: 20px;
|
||||
padding: 28px 24px;
|
||||
margin-bottom: 32px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
|
||||
.nickname-label {
|
||||
display: block;
|
||||
font-size: 28px;
|
||||
color: #1A1A1A;
|
||||
font-weight: 600;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.nickname-input {
|
||||
width: 100%;
|
||||
height: 88px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 24px;
|
||||
border-radius: 16px;
|
||||
background: #F5F7FA;
|
||||
font-size: 30px;
|
||||
color: #1A1A1A;
|
||||
}
|
||||
}
|
||||
|
||||
.login-btn-wrapper {
|
||||
margin-bottom: 32px;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { View, Text, Button } from '@tarojs/components'
|
||||
import { View, Text, Button, Input } from '@tarojs/components'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { getToken, wechatLogin } from '@/services/auth'
|
||||
@@ -6,16 +6,31 @@ import './index.scss'
|
||||
|
||||
export default function Login() {
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [nickname, setNickname] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (getToken()) {
|
||||
Taro.reLaunch({ url: '/pages/device/index' })
|
||||
return
|
||||
}
|
||||
|
||||
const cachedUserInfo = (Taro.getStorageSync('userInfo') || {}) as {
|
||||
nickname?: string
|
||||
}
|
||||
if (cachedUserInfo.nickname) {
|
||||
setNickname(cachedUserInfo.nickname)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (submitting) return
|
||||
|
||||
const cachedUserInfo = (Taro.getStorageSync('userInfo') || {}) as {
|
||||
nickname?: string
|
||||
avatar_url?: string
|
||||
}
|
||||
const normalizedNickname = nickname.trim() || cachedUserInfo.nickname?.trim() || ''
|
||||
|
||||
setSubmitting(true)
|
||||
Taro.showLoading({ title: '登录中...' })
|
||||
|
||||
@@ -25,35 +40,26 @@ export default function Login() {
|
||||
throw new Error('未获取到微信登录凭证')
|
||||
}
|
||||
|
||||
let userInfo:
|
||||
| {
|
||||
nickName?: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
| null = null
|
||||
|
||||
try {
|
||||
const profileRes = await Taro.getUserProfile({
|
||||
desc: '用于完善家长资料展示',
|
||||
})
|
||||
userInfo = profileRes.userInfo || null
|
||||
} catch (error) {
|
||||
console.log('[login] getUserProfile skipped:', error)
|
||||
}
|
||||
|
||||
await wechatLogin({
|
||||
const session = await wechatLogin({
|
||||
code: loginRes.code,
|
||||
nickname: userInfo?.nickName,
|
||||
avatar_url: userInfo?.avatarUrl,
|
||||
nickname: normalizedNickname || undefined,
|
||||
avatar_url: cachedUserInfo.avatar_url,
|
||||
})
|
||||
|
||||
if (userInfo) {
|
||||
const nextUserInfo = {
|
||||
nickname: session.nickname || normalizedNickname,
|
||||
avatar_url: cachedUserInfo.avatar_url,
|
||||
}
|
||||
|
||||
if (nextUserInfo.nickname || nextUserInfo.avatar_url) {
|
||||
Taro.setStorageSync('userInfo', {
|
||||
nickname: userInfo.nickName,
|
||||
avatar_url: userInfo.avatarUrl,
|
||||
nickname: nextUserInfo.nickname,
|
||||
avatar_url: nextUserInfo.avatar_url,
|
||||
})
|
||||
setNickname(nextUserInfo.nickname || '')
|
||||
} else {
|
||||
Taro.removeStorageSync('userInfo')
|
||||
setNickname('')
|
||||
}
|
||||
|
||||
Taro.showToast({ title: '登录成功', icon: 'success' })
|
||||
@@ -100,6 +106,18 @@ export default function Login() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='nickname-card'>
|
||||
<Text className='nickname-label'>家长昵称</Text>
|
||||
<Input
|
||||
className='nickname-input'
|
||||
type='nickname'
|
||||
maxlength={64}
|
||||
placeholder='请输入家长昵称'
|
||||
value={nickname}
|
||||
onInput={(event) => setNickname(event.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='login-btn-wrapper'>
|
||||
<Button className='login-btn' loading={submitting} disabled={submitting} onClick={handleLogin}>
|
||||
<Text className='btn-icon'>🌐</Text>
|
||||
|
||||
@@ -50,12 +50,6 @@
|
||||
font-weight: 600;
|
||||
color: #1A1A1A;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 26px;
|
||||
color: #666666;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,6 +294,22 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.device-switch-footer {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.device-switch-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 84px;
|
||||
border-radius: 18px;
|
||||
background: #FFF2E7;
|
||||
font-size: 28px;
|
||||
color: #FF8C42;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.device-switch-item {
|
||||
padding: 24px;
|
||||
border-radius: 18px;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { View, Text, Image, Input } from '@tarojs/components'
|
||||
import { useState } from 'react'
|
||||
import Taro, { useDidShow } from '@tarojs/taro'
|
||||
import { clearToken, getCurrentUserId, getToken } from '@/services/auth'
|
||||
import { clearToken, getToken } from '@/services/auth'
|
||||
import {
|
||||
BindingListItem,
|
||||
clearSelectedBindingDeviceId,
|
||||
@@ -95,6 +95,11 @@ export default function Sleep() {
|
||||
})
|
||||
}
|
||||
|
||||
const handleOpenAddChildFromSwitch = () => {
|
||||
setShowChildModal(false)
|
||||
handleOpenModal('add')
|
||||
}
|
||||
|
||||
const handleSubmitModal = async () => {
|
||||
const normalizedName = childName.trim()
|
||||
if (!normalizedName) {
|
||||
@@ -181,6 +186,11 @@ export default function Sleep() {
|
||||
return
|
||||
}
|
||||
|
||||
if (item.name === '新增孩子') {
|
||||
handleOpenModal('add')
|
||||
return
|
||||
}
|
||||
|
||||
if (item.name === '绑定设备') {
|
||||
if (!currentChild) {
|
||||
handleOpenModal('add')
|
||||
@@ -209,7 +219,7 @@ export default function Sleep() {
|
||||
)
|
||||
}
|
||||
|
||||
const userId = getCurrentUserId()
|
||||
const parentDisplayName = parentInfo.nickname?.trim() || '家长'
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
icon: require('../../assets/tab-icons/orange-robot.png'),
|
||||
@@ -218,6 +228,13 @@ export default function Sleep() {
|
||||
value: currentChildName,
|
||||
arrow: true,
|
||||
},
|
||||
{
|
||||
icon: require('../../assets/tab-icons/orange-robot.png'),
|
||||
iconBgClass: 'orange',
|
||||
name: '新增孩子',
|
||||
value: '',
|
||||
arrow: true,
|
||||
},
|
||||
{
|
||||
icon: require('../../assets/tab-icons/orange-robot.png'),
|
||||
iconBgClass: 'orange',
|
||||
@@ -257,8 +274,7 @@ export default function Sleep() {
|
||||
)}
|
||||
</View>
|
||||
<View className='user-info'>
|
||||
<Text className='user-name'>{parentInfo.nickname || '家长用户'}</Text>
|
||||
<Text className='user-role'>用户 ID: {userId || '--'}</Text>
|
||||
<Text className='user-name'>{parentDisplayName}</Text>
|
||||
</View>
|
||||
<View className='verified-badge'>
|
||||
<Text>{currentChild ? '已选择当前孩子' : '未选择孩子'}</Text>
|
||||
@@ -342,6 +358,11 @@ export default function Sleep() {
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
<View className='device-switch-footer'>
|
||||
<Text className='device-switch-add' onClick={handleOpenAddChildFromSwitch}>
|
||||
+ 新增孩子
|
||||
</Text>
|
||||
</View>
|
||||
<View className='modal-actions'>
|
||||
<Text className='modal-action cancel' onClick={() => setShowChildModal(false)}>
|
||||
关闭
|
||||
|
||||
@@ -4,6 +4,10 @@ import { getCurrentUserId, getToken, handleUnauthorized } from './session'
|
||||
|
||||
const BASE_URL = API_BASE_URL
|
||||
const REQUEST_TIMEOUT_MS = 10000
|
||||
const ERROR_DETAIL_MAP: Record<string, string> = {
|
||||
'Request failed': '请求失败',
|
||||
'device is already bound, unbind it before binding again': '设备已绑定,请先解绑后再重新绑定',
|
||||
}
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
@@ -12,14 +16,14 @@ class ApiError extends Error {
|
||||
}
|
||||
|
||||
function getErrorDetail(data: any): string {
|
||||
if (!data) return 'Request failed'
|
||||
if (!data) return ERROR_DETAIL_MAP['Request failed']
|
||||
if (typeof data.errMsg === 'string') return data.errMsg
|
||||
if (typeof data.detail === 'string') return data.detail
|
||||
if (typeof data.detail === 'string') return ERROR_DETAIL_MAP[data.detail] || data.detail
|
||||
if (Array.isArray(data.detail)) {
|
||||
return data.detail.map((item: any) => String(item?.msg || item)).join('; ')
|
||||
}
|
||||
if (typeof data.message === 'string') return data.message
|
||||
return 'Request failed'
|
||||
return ERROR_DETAIL_MAP['Request failed']
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface LoginSession {
|
||||
token_type: string
|
||||
expires_in: number
|
||||
user_id: number
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
export interface Parent {
|
||||
|
||||
@@ -34,6 +34,20 @@ class BindingDAO(BaseDAO):
|
||||
)
|
||||
).mappings().first()
|
||||
|
||||
async def get_active_binding_by_device(self, device_id: str) -> Optional[Mapping]:
|
||||
return (
|
||||
await self.execute(
|
||||
"""
|
||||
SELECT id, device_id, owner_user_id, child_id, status
|
||||
FROM device_bindings
|
||||
WHERE device_id = :device_id
|
||||
AND status = 1
|
||||
LIMIT 1
|
||||
""",
|
||||
{"device_id": device_id},
|
||||
)
|
||||
).mappings().first()
|
||||
|
||||
async def _clear_child_from_binding(self, binding_row: Mapping[str, object]) -> None:
|
||||
row_id = int(binding_row["id"])
|
||||
status = int(binding_row["status"])
|
||||
@@ -372,6 +386,8 @@ class BindingDAO(BaseDAO):
|
||||
row = await self.get_by_device(device_id=device_id, user_id=user_id)
|
||||
if not row:
|
||||
return False
|
||||
if row["child_id"] is not None:
|
||||
return False
|
||||
|
||||
await self._upsert_parent_child_relation(user_id=user_id, child_id=child_id)
|
||||
await self._bind_device(device_id=device_id, user_id=user_id, child_id=child_id)
|
||||
|
||||
@@ -90,19 +90,17 @@ class ChildDAO(BaseDAO):
|
||||
await self.commit()
|
||||
|
||||
async def has_access(self, child_id: int, user_id: int) -> bool:
|
||||
return (
|
||||
await self.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM children AS c
|
||||
JOIN parent_child_relations AS pcr
|
||||
ON pcr.child_id = c.child_id
|
||||
WHERE c.child_id = :child_id
|
||||
AND pcr.user_id = :user_id
|
||||
AND c.status = 1
|
||||
AND pcr.status = 1
|
||||
""",
|
||||
{"child_id": child_id, "user_id": user_id},
|
||||
).scalar_one_or_none()
|
||||
is not None
|
||||
result = await self.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM children AS c
|
||||
JOIN parent_child_relations AS pcr
|
||||
ON pcr.child_id = c.child_id
|
||||
WHERE c.child_id = :child_id
|
||||
AND pcr.user_id = :user_id
|
||||
AND c.status = 1
|
||||
AND pcr.status = 1
|
||||
""",
|
||||
{"child_id": child_id, "user_id": user_id},
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
@@ -18,6 +18,21 @@ class ParentDeviceAccess:
|
||||
|
||||
|
||||
class LocationDAO(BaseDAO):
|
||||
async def get_active_binding_by_device(self, *, device_id: str) -> Mapping[str, Any] | None:
|
||||
result = await self.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT child_id
|
||||
FROM device_bindings
|
||||
WHERE device_id = :device_id
|
||||
AND status = 1
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"device_id": device_id},
|
||||
)
|
||||
return result.mappings().first()
|
||||
|
||||
async def assert_parent_device_access(self, *, device_id: str, user_id: int) -> ParentDeviceAccess:
|
||||
from fastapi import HTTPException, status
|
||||
result = await self.execute(
|
||||
@@ -321,8 +336,7 @@ class LocationDAO(BaseDAO):
|
||||
await self.execute(text(update_current_sql), params)
|
||||
await self.commit()
|
||||
|
||||
async def get_device_current_location(self, *, device_id: str) -> Mapping[str, Any]:
|
||||
# sql 查询
|
||||
async def get_current_location_by_device_id(self, *, device_id: str) -> Mapping[str, Any]:
|
||||
result = await self.execute(
|
||||
text(
|
||||
f"""
|
||||
@@ -348,4 +362,4 @@ class LocationDAO(BaseDAO):
|
||||
),
|
||||
{"device_id": device_id},
|
||||
)
|
||||
return result.mappings().first()
|
||||
return result.mappings().first()
|
||||
|
||||
@@ -32,6 +32,14 @@ class LoginResponse(BaseModel):
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
user_id: int
|
||||
nickname: str | None = None
|
||||
|
||||
|
||||
def _normalize_nickname(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip()
|
||||
return normalized or None
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
@@ -56,20 +64,26 @@ async def login(
|
||||
)
|
||||
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
|
||||
|
||||
normalized_nickname = _normalize_nickname(payload.nickname)
|
||||
parent_service = ParentService()
|
||||
parent = await parent_service.create(
|
||||
openid=wechat_session.openid,
|
||||
unionid=wechat_session.unionid,
|
||||
nickname=payload.nickname,
|
||||
nickname=normalized_nickname,
|
||||
avatar_url=payload.avatar_url,
|
||||
)
|
||||
user_id = int(parent["user_id"])
|
||||
|
||||
access_token, expires_in = create_access_token(user_id=user_id)
|
||||
logger.info("wechat login succeeded", extra={"event": "wechat_login_succeeded", "user_id": user_id})
|
||||
return LoginResponse(access_token=access_token, expires_in=expires_in, user_id=user_id)
|
||||
return LoginResponse(
|
||||
access_token=access_token,
|
||||
expires_in=expires_in,
|
||||
user_id=user_id,
|
||||
nickname=parent.get("nickname"),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request):
|
||||
return {"message": "logged out"}
|
||||
return {"message": "logged out"}
|
||||
|
||||
@@ -24,6 +24,13 @@ class BindingService(DatabaseServiceBase):
|
||||
def __init__(self):
|
||||
super().__init__(service_name="binding_service")
|
||||
|
||||
async def _ensure_device_unbound(self, db_session, device_id: str) -> None:
|
||||
dao = BindingDAO(db_session)
|
||||
active_binding = await dao.get_active_binding_by_device(device_id)
|
||||
if active_binding is None:
|
||||
return
|
||||
raise BindingError("device is already bound, unbind it before binding again", status_code=409)
|
||||
|
||||
async def _ensure_bindable_device(self, db_session, device_id: str, serial_number: str) -> None:
|
||||
dao = BindingDAO(db_session)
|
||||
row = await dao.get_device_auth(device_id)
|
||||
@@ -50,6 +57,7 @@ class BindingService(DatabaseServiceBase):
|
||||
db_session = await self.get_session()
|
||||
try:
|
||||
await self._ensure_bindable_device(db_session, device_id, serial_number)
|
||||
await self._ensure_device_unbound(db_session, device_id)
|
||||
dao = BindingDAO(db_session)
|
||||
bind_token, expires_at = await dao.start_bind(user_id, device_id, child_id)
|
||||
await db_session.commit()
|
||||
@@ -188,6 +196,7 @@ class BindingService(DatabaseServiceBase):
|
||||
db_session = await self.get_session()
|
||||
try:
|
||||
await self._ensure_bindable_device(db_session, device_id, serial_number)
|
||||
await self._ensure_device_unbound(db_session, device_id)
|
||||
dao = BindingDAO(db_session)
|
||||
await dao.direct_bind(device_id, child_id, user_id)
|
||||
await db_session.commit()
|
||||
@@ -201,6 +210,9 @@ class BindingService(DatabaseServiceBase):
|
||||
dao = BindingDAO(db_session)
|
||||
ok = await dao.set_binding_child(device_id=device_id, child_id=child_id, user_id=user_id)
|
||||
if not ok:
|
||||
active_binding = await dao.get_active_binding_by_device(device_id)
|
||||
if active_binding and active_binding["child_id"] is not None:
|
||||
raise BindingError("device is already bound, unbind it before binding again", status_code=409)
|
||||
raise ValueError("binding not found")
|
||||
await db_session.commit()
|
||||
return {"device_id": device_id, "child_id": child_id}
|
||||
|
||||
@@ -89,7 +89,7 @@ class LocationService(DatabaseServiceBase):
|
||||
db_session = await self.get_session()
|
||||
try:
|
||||
dao = LocationDAO(db_session)
|
||||
current_row = await dao.get_device_current_location(device_id=device_id)
|
||||
current_row = await dao.get_current_location_by_device_id(device_id=device_id)
|
||||
if current_row:
|
||||
await dao.update(device_id=device_id, latitude=location.lat, longitude=location.lng)
|
||||
# 更新成功加到历史记录表
|
||||
@@ -98,5 +98,63 @@ class LocationService(DatabaseServiceBase):
|
||||
finally:
|
||||
await db_session.close()
|
||||
|
||||
async def report_mqtt_device_location(
|
||||
self,
|
||||
*,
|
||||
device_id: str,
|
||||
latitude: float | None,
|
||||
longitude: float | None,
|
||||
coord_type: str | None = None,
|
||||
accuracy_m: int | None = None,
|
||||
altitude_m: float | None = None,
|
||||
speed_mps: float | None = None,
|
||||
heading_deg: int | None = None,
|
||||
source: int | None = None,
|
||||
battery_pct: int | None = None,
|
||||
device_time: datetime | None = None,
|
||||
) -> Mapping[str, Any] | None:
|
||||
if latitude is None or longitude is None:
|
||||
return None
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _MQTTLocationPayload:
|
||||
coord_type: str
|
||||
lat: float
|
||||
lng: float
|
||||
accuracy_m: int | None
|
||||
altitude_m: float | None
|
||||
speed_mps: float | None
|
||||
heading_deg: int | None
|
||||
source: int
|
||||
battery_pct: int | None
|
||||
device_time: datetime
|
||||
|
||||
db_session = await self.get_session()
|
||||
try:
|
||||
dao = LocationDAO(db_session)
|
||||
binding_row = await dao.get_active_binding_by_device(device_id=device_id)
|
||||
if not binding_row or binding_row["child_id"] is None:
|
||||
return None
|
||||
|
||||
payload = _MQTTLocationPayload(
|
||||
coord_type=(coord_type or "gcj02").strip() or "gcj02",
|
||||
lat=float(latitude),
|
||||
lng=float(longitude),
|
||||
accuracy_m=accuracy_m,
|
||||
altitude_m=altitude_m,
|
||||
speed_mps=speed_mps,
|
||||
heading_deg=heading_deg,
|
||||
source=source if source is not None else 0,
|
||||
battery_pct=battery_pct,
|
||||
device_time=device_time or datetime.now(),
|
||||
)
|
||||
return await dao.report_device_location(
|
||||
device_id=device_id,
|
||||
child_id=int(binding_row["child_id"]),
|
||||
payload=payload,
|
||||
)
|
||||
finally:
|
||||
await db_session.close()
|
||||
|
||||
# 创建全局 LocationService 实例
|
||||
location_service = LocationService()
|
||||
|
||||
Reference in New Issue
Block a user