完善登录绑定定位与管理页交互

This commit is contained in:
stu2not
2026-05-05 16:33:21 +08:00
parent a454cae088
commit 0cbfb4f4ee
16 changed files with 541 additions and 190 deletions

View File

@@ -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;
}
}

View File

@@ -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'>

View File

@@ -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;
}

View File

@@ -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>
)
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;

View File

@@ -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)}>

View File

@@ -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>(

View File

@@ -17,6 +17,7 @@ export interface LoginSession {
token_type: string
expires_in: number
user_id: number
nickname?: string
}
export interface Parent {