From 0cbfb4f4eee79b4368234164dcafc602c8e01f1d Mon Sep 17 00:00:00 2001 From: stu2not Date: Tue, 5 May 2026 16:33:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=99=BB=E5=BD=95=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=E5=AE=9A=E4=BD=8D=E4=B8=8E=E7=AE=A1=E7=90=86=E9=A1=B5?= =?UTF-8?q?=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- banban-mini/src/pages/bind/index.scss | 32 +- banban-mini/src/pages/bind/index.tsx | 32 +- banban-mini/src/pages/location/index.scss | 27 ++ banban-mini/src/pages/location/index.tsx | 331 ++++++++++++++------- banban-mini/src/pages/login/index.scss | 27 ++ banban-mini/src/pages/login/index.tsx | 64 ++-- banban-mini/src/pages/sleep/index.scss | 22 +- banban-mini/src/pages/sleep/index.tsx | 29 +- banban-mini/src/services/api.ts | 10 +- banban-mini/src/services/auth.ts | 1 + talkingq-url/banban/dao/binding.py | 16 + talkingq-url/banban/dao/child.py | 28 +- talkingq-url/banban/dao/location.py | 20 +- talkingq-url/banban/routers/wechat_auth.py | 20 +- talkingq-url/banban/service/binding.py | 12 + talkingq-url/banban/service/location.py | 60 +++- 16 files changed, 541 insertions(+), 190 deletions(-) diff --git a/banban-mini/src/pages/bind/index.scss b/banban-mini/src/pages/bind/index.scss index d9cb7c9..b1937ed 100644 --- a/banban-mini/src/pages/bind/index.scss +++ b/banban-mini/src/pages/bind/index.scss @@ -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; } } diff --git a/banban-mini/src/pages/bind/index.tsx b/banban-mini/src/pages/bind/index.tsx index 8abeb44..003246a 100644 --- a/banban-mini/src/pages/bind/index.tsx +++ b/banban-mini/src/pages/bind/index.tsx @@ -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 ( + + + 返回 + {isPendingBinding ? '补全绑定' : '扫码贴卡绑定设备'} - - {isPendingBinding - ? '当前设备已存在待补全关系,请先补充儿童资料' - : '先选择儿童,再扫码设备,系统会下发绑卡指令,随后去设备上贴卡确认'} - diff --git a/banban-mini/src/pages/location/index.scss b/banban-mini/src/pages/location/index.scss index 5edceae..74d393f 100644 --- a/banban-mini/src/pages/location/index.scss +++ b/banban-mini/src/pages/location/index.scss @@ -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; +} diff --git a/banban-mini/src/pages/location/index.tsx b/banban-mini/src/pages/location/index.tsx index 16526b1..ab83d11 100644 --- a/banban-mini/src/pages/location/index.tsx +++ b/banban-mini/src/pages/location/index.tsx @@ -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(null) const [trajectoryMode, setTrajectoryMode] = useState('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() { 设备定位 - - {MODE_OPTIONS.map((item) => ( - { - if (trajectoryMode === item.key) return - void loadLocation(false, item.key) - }} - > - {item.label} - - ))} - - - - { - console.error('[location] map error:', event.detail) - }} - /> - - - - - - {summaryTitle} - - {loading - ? '正在获取设备位置...' - : trajectoryMode === 'current' - ? `更新于 ${formatTime(deviceLocation?.updated_at)}` - : `轨迹点 ${trajectory.length} 个 · 最近更新 ${formatTime(trajectory[trajectory.length - 1]?.device_time)}`} - - - loadLocation(true)}> - + {emptyState ? ( + + + {emptyState.title} + {emptyState.desc} + { + if (emptyState.actionUrl === '/pages/sleep/index') { + Taro.switchTab({ url: emptyState.actionUrl }) + return + } + Taro.navigateTo({ url: emptyState.actionUrl }) + }} + > + {emptyState.actionText} + + ) : ( + <> + + {MODE_OPTIONS.map((item) => ( + { + if (trajectoryMode === item.key) return + void loadLocation(false, item.key) + }} + > + {item.label} + + ))} + - {trajectoryMode === 'current' ? ( - deviceLocation ? ( - - - 📍 + + { + console.error('[location] map error:', event.detail) + }} + /> + + + + + + {summaryTitle} + + {loading + ? '正在获取设备位置...' + : trajectoryMode === 'current' + ? `更新于 ${formatTime(deviceLocation?.updated_at)}` + : `轨迹点 ${trajectory.length} 个 · 最近更新 ${formatTime(trajectory[trajectory.length - 1]?.device_time)}`} + - - - {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}% + loadLocation(true)}> + + + + + {trajectoryMode === 'current' ? ( + deviceLocation ? ( + + + 📍 + + + + {deviceLocation.lat.toFixed(6)}, {deviceLocation.lng.toFixed(6)} + + {buildLocationDetailLines(deviceLocation, { includeIdentity: true }).map((line, index) => ( + + {line} + + ))} + + + ) : ( + + 当前孩子暂无设备位置 + + ) + ) : trajectory.length > 0 ? ( + + {selectedPoint && ( + + + + + + + {selectedPoint.lat.toFixed(6)}, {selectedPoint.lng.toFixed(6)} + + {buildLocationDetailLines(selectedPoint).map((line, index) => ( + + {line} + + ))} + + )} + + + + 🛣️ + + + 共 {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)} + + + - - ) : ( - - 当前孩子暂无设备位置 - - ) - ) : 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}% - )} - + ) : ( + + 暂无轨迹 + + {trajectoryMode === 'today' ? '当前孩子今天还没有新的历史点位。' : '当前孩子最近没有新的历史点位。'} + )} - - - - 🛣️ - - - 共 {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)} - - - - ) : ( - - 暂无轨迹 - - {trajectoryMode === 'today' ? '当前孩子今天还没有新的历史点位。' : '当前孩子最近没有新的历史点位。'} - - - )} - + + )} ) } diff --git a/banban-mini/src/pages/login/index.scss b/banban-mini/src/pages/login/index.scss index 26c0287..e0b1763 100644 --- a/banban-mini/src/pages/login/index.scss +++ b/banban-mini/src/pages/login/index.scss @@ -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; diff --git a/banban-mini/src/pages/login/index.tsx b/banban-mini/src/pages/login/index.tsx index 1bc9834..810a2b3 100644 --- a/banban-mini/src/pages/login/index.tsx +++ b/banban-mini/src/pages/login/index.tsx @@ -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() { + + 家长昵称 + setNickname(event.detail.value)} + /> + +