diff --git a/banban-mini/src/pages/bind/index.scss b/banban-mini/src/pages/bind/index.scss index 50b3718..007b012 100644 --- a/banban-mini/src/pages/bind/index.scss +++ b/banban-mini/src/pages/bind/index.scss @@ -6,67 +6,6 @@ padding-bottom: calc(120px + env(safe-area-inset-bottom)); } -.loading { - display: flex; - align-items: center; - justify-content: center; - height: 400px; - font-size: 30px; - color: #999999; -} - -.empty-tip { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 70%; - font-size: 30px; - color: #999999; - padding: 0 48px; - - .empty-title { - font-size: 36px; - color: #1A1A1A; - font-weight: 600; - margin-bottom: 12px; - } - - .empty-desc { - font-size: 26px; - color: #999999; - margin-bottom: 48px; - } - - .empty-input-wrap { - width: 100%; - margin-bottom: 24px; - - .empty-input { - background: #F5F7FA; - border-radius: 12px; - padding: 24px; - font-size: 30px; - width: 100%; - height: 96px; - box-sizing: border-box; - text-align: center; - } - } - - .empty-btn { - background: #FF8C42; - border-radius: 12px; - padding: 24px 64px; - - .empty-btn-text { - color: #FFFFFF; - font-size: 32px; - font-weight: 500; - } - } -} - .icon-bg { width: 72px; height: 72px; @@ -214,6 +153,125 @@ } } +.bind-form { + margin: 0 48px 48px; +} + +.form-card { + background: #FFFFFF; + border-radius: 20px; + padding: 28px 32px; + margin-bottom: 24px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); +} + +.form-title { + font-size: 30px; + font-weight: 700; + color: #1A1A1A; + display: block; + margin-bottom: 20px; +} + +.field-item { + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } +} + +.field-label { + display: block; + font-size: 26px; + color: #666666; + margin-bottom: 12px; +} + +.field-input { + width: 100%; + height: 88px; + background: #F7F8FA; + border-radius: 16px; + padding: 0 24px; + font-size: 28px; + color: #1A1A1A; + box-sizing: border-box; +} + +.field-hint { + display: block; + font-size: 24px; + color: #999999; + line-height: 1.5; +} + +.pending-tip { + margin-top: 20px; + padding: 20px 24px; + background: #FFF7E8; + border-radius: 16px; +} + +.pending-tip-text { + font-size: 26px; + color: #D46B08; +} + +.child-list { + display: flex; + flex-wrap: wrap; + margin-bottom: 20px; +} + +.child-item { + padding: 14px 24px; + border-radius: 999px; + background: #F5F7FA; + margin-right: 16px; + margin-bottom: 16px; + + &.selected { + background: #FF8C42; + + .child-item-text { + color: #FFFFFF; + } + } +} + +.child-item-text { + font-size: 26px; + color: #666666; +} + +.submit-btn { + width: 100%; + height: 96px; + background: #FF8C42; + border-radius: 16px; + color: #FFFFFF; + font-size: 32px; + font-weight: 600; + border: none; + + &::after { + display: none; + } +} + +.loading { + min-height: 100%; + display: flex; + align-items: center; + justify-content: center; + + text { + font-size: 32px; + color: #999999; + } +} + .bind-tips { margin: 0 48px; diff --git a/banban-mini/src/pages/bind/index.tsx b/banban-mini/src/pages/bind/index.tsx index 3720e28..aaf8d6f 100644 --- a/banban-mini/src/pages/bind/index.tsx +++ b/banban-mini/src/pages/bind/index.tsx @@ -1,201 +1,233 @@ -import { View, Text, Image, Input } from '@tarojs/components' -import { useState, useEffect } from 'react' -import Taro from '@tarojs/taro' -import { getToken } from '@/services/api' -import { getCurrentBinding, directBind, setBindingChild } from '@/services/binding' -import { getChildren, createChild } from '@/services/child' +import { View, Text, Image, Input, Button } from '@tarojs/components' +import { useState } from 'react' +import Taro, { useDidShow } from '@tarojs/taro' +import { getToken } from '@/services/auth' +import { directBind, resolveActiveBinding, setBindingChild, setSelectedBindingDeviceId } from '@/services/binding' +import { Child, createChild, getChildren } from '@/services/child' import './index.scss' -interface Child { - child_id: number - child_name: string +function parseBindingPayload(rawValue: string): { deviceId: string; serialNumber: string } { + const raw = String(rawValue || '').trim() + if (!raw) return { deviceId: '', serialNumber: '' } + + try { + const parsed = JSON.parse(raw) + return { + deviceId: String(parsed?.device_id || parsed?.deviceId || '').trim(), + serialNumber: String(parsed?.serial_number || parsed?.serialNumber || '').trim(), + } + } catch (error) { + console.log('[bind] scan payload is not json:', error) + } + + const queryPart = raw.includes('?') ? raw.slice(raw.indexOf('?') + 1) : raw + if (queryPart.includes('=')) { + const pairMap = queryPart.split('&').reduce>((result, item) => { + const [key, ...restValue] = item.split('=') + if (!key) return result + result[decodeURIComponent(key)] = decodeURIComponent(restValue.join('=') || '') + return result + }, {}) + const deviceId = pairMap.device_id || pairMap.deviceId || '' + const serialNumber = pairMap.serial_number || pairMap.serialNumber || pairMap.sn || '' + if (deviceId || serialNumber) { + return { + deviceId: deviceId.trim(), + serialNumber: serialNumber.trim(), + } + } + } + + const deviceIdMatch = raw.match(/device(?:_id|Id)?[:=]([A-Za-z0-9_-]+)/i) + const serialNumberMatch = raw.match(/serial(?:_number|Number)?[:=]([A-Za-z0-9_-]+)/i) + if (deviceIdMatch || serialNumberMatch) { + return { + deviceId: deviceIdMatch?.[1] || '', + serialNumber: serialNumberMatch?.[1] || '', + } + } + + const parts = raw.split(/[\s,;|]+/).filter(Boolean) + if (parts.length >= 2) { + return { + deviceId: parts[0], + serialNumber: parts[1], + } + } + + return { + deviceId: raw, + serialNumber: '', + } } export default function Bind() { - const [isScanning, setIsScanning] = useState(false) const [loading, setLoading] = useState(true) + const [submitting, setSubmitting] = useState(false) + const [isScanning, setIsScanning] = useState(false) + const [deviceId, setDeviceId] = useState('') + const [serialNumber, setSerialNumber] = useState('') const [children, setChildren] = useState([]) + const [selectedChildId, setSelectedChildId] = useState(null) const [newChildName, setNewChildName] = useState('') - const [saving, setSaving] = useState(false) const [pendingDeviceId, setPendingDeviceId] = useState(null) - useEffect(() => { - checkAuth() - }, []) + useDidShow(() => { + void loadPageData() + }) const goDevicePage = () => { - setTimeout(() => { - Taro.reLaunch({ url: '/pages/device/index' }) - }, 500) + Taro.reLaunch({ url: '/pages/device/index' }) } - const completeChildLink = async (deviceId: string, childId: number) => { - await setBindingChild(deviceId, { child_id: childId }) - setPendingDeviceId(null) - } - - const checkAuth = async () => { - const token = getToken() - if (!token) { - Taro.redirectTo({ url: '/pages/login/index' }) + const loadPageData = async () => { + if (!getToken()) { + Taro.reLaunch({ url: '/pages/login/index' }) return } + setLoading(true) try { - const childRes = await getChildren() - const currentChildren = childRes.items || [] + const [childResponse, activeBinding] = await Promise.all([getChildren(), resolveActiveBinding()]) + const currentChildren = childResponse.items || [] + setChildren(currentChildren) + setSelectedChildId((currentSelectedChildId) => currentSelectedChildId || currentChildren[0]?.child_id || null) - const binding = await getCurrentBinding() - if (binding) { - if (binding.child_id) { - Taro.showToast({ title: 'Already bound', icon: 'none' }) - goDevicePage() - return - } - - setPendingDeviceId(binding.device_id) - if (currentChildren.length > 0) { - await completeChildLink(binding.device_id, currentChildren[0].child_id) - Taro.showToast({ title: 'Binding completed', icon: 'success' }) - goDevicePage() - return - } + if (activeBinding?.device_id && !activeBinding.child_id) { + setPendingDeviceId(activeBinding.device_id) + setDeviceId(activeBinding.device_id) + } else { + setPendingDeviceId(null) + setDeviceId('') + setSerialNumber('') } - } catch (err) { - console.error('Load bind page data failed:', err) + } catch (error: any) { + console.error('[bind] load failed:', error) + Taro.showToast({ + title: error?.message || '加载失败,请稍后重试', + icon: 'none', + }) } finally { setLoading(false) } } - const doBind = async (deviceId: string) => { - Taro.showLoading({ title: 'Binding...' }) + const ensureChildId = async (): Promise => { + if (selectedChildId) return selectedChildId - try { - await directBind({ device_id: deviceId }) + const childName = newChildName.trim() + if (!childName) return null - if (children.length > 0) { - await completeChildLink(deviceId, children[0].child_id) - Taro.hideLoading() - Taro.showToast({ - title: 'Bind success', - icon: 'success', - duration: 1200, - }) - goDevicePage() - return - } - - setPendingDeviceId(deviceId) - Taro.hideLoading() - Taro.showToast({ title: 'Device bound, add child next', icon: 'none' }) - } catch (err: any) { - Taro.hideLoading() - Taro.showToast({ title: err.message || 'Bind failed', icon: 'none' }) - } + const child = await createChild({ child_name: childName }) + setChildren((currentChildren) => [child, ...currentChildren]) + setSelectedChildId(child.child_id) + setNewChildName('') + return child.child_id } const handleScanCode = () => { - if (pendingDeviceId) { - Taro.showToast({ title: 'Complete child setup first', icon: 'none' }) - return - } - setIsScanning(true) Taro.scanCode({ onlyFromCamera: true, scanType: ['qrCode', 'barCode'], - success: async (res) => { + success: (res) => { setIsScanning(false) - const deviceId = res.result + const parsed = parseBindingPayload(res.result || '') + if (parsed.deviceId) setDeviceId(parsed.deviceId) + if (parsed.serialNumber) setSerialNumber(parsed.serialNumber) - if (!deviceId) { - Taro.showToast({ title: 'Invalid device code', icon: 'none' }) - return - } - - await doBind(deviceId) + Taro.showToast({ + title: parsed.serialNumber ? '已填入设备信息' : '已识别设备号,请补充序列号', + icon: 'none', + }) }, fail: (err) => { setIsScanning(false) + if (err.errMsg && err.errMsg.includes('cancel')) { return } - Taro.showToast({ title: 'Scan failed', icon: 'none' }) + + Taro.showToast({ + title: '扫码失败,请重试', + icon: 'none', + }) }, }) } - const handleManualInput = () => { - if (pendingDeviceId) { - Taro.showToast({ title: 'Complete child setup first', icon: 'none' }) - return - } + const handleSubmit = async () => { + if (submitting) return - Taro.showModal({ - title: 'Manual input', - editable: true, - placeholderText: 'Enter device code', - success: (res) => { - if (res.confirm && res.content) { - doBind(res.content) - } - }, - }) - } + setSubmitting(true) + Taro.showLoading({ title: pendingDeviceId ? '关联中...' : '绑定中...' }) - const handleAddChild = async () => { - if (!newChildName.trim()) { - Taro.showToast({ title: 'Please enter child name', icon: 'none' }) - return - } - - setSaving(true) try { - const child = await createChild({ child_name: newChildName.trim() }) - setNewChildName('') + const childId = await ensureChildId() if (pendingDeviceId) { - await completeChildLink(pendingDeviceId, child.child_id) - Taro.showToast({ title: 'Child linked', icon: 'success' }) - goDevicePage() + if (!childId) { + throw new Error('请选择儿童或填写新儿童昵称') + } + + await setBindingChild(pendingDeviceId, { child_id: childId }) + setSelectedBindingDeviceId(pendingDeviceId) + Taro.showToast({ title: '绑定完成', icon: 'success' }) + setTimeout(() => { + goDevicePage() + }, 300) return } - const childRes = await getChildren() - setChildren(childRes.items || []) - Taro.showToast({ title: 'Child created', icon: 'success' }) - } catch (err) { - Taro.showToast({ title: 'Create child failed', icon: 'none' }) - } finally { - setSaving(false) - } - } + if (!deviceId.trim()) { + throw new Error('请输入设备号') + } + if (!serialNumber.trim()) { + throw new Error('请输入设备序列号') + } - const handleLogout = () => { - Taro.showModal({ - title: 'Logout', - content: 'Clear login info and go to login page?', - confirmColor: '#FF8C42', - success: (res) => { - if (!res.confirm) return - Taro.removeStorageSync('token') - Taro.removeStorageSync('user_id') - Taro.removeStorageSync('expires_in') - Taro.removeStorageSync('userInfo') - Taro.removeStorageSync('hasDevice') - Taro.removeStorageSync('deviceInfo') - Taro.reLaunch({ url: '/pages/login/index' }) - }, - }) + const result = await directBind({ + device_id: deviceId.trim(), + serial_number: serialNumber.trim(), + ...(childId ? { child_id: childId } : {}), + }) + + setSelectedBindingDeviceId(result.device_id) + + if (childId || result.child_id) { + Taro.showToast({ title: '设备绑定成功', icon: 'success' }) + setTimeout(() => { + goDevicePage() + }, 300) + return + } + + setPendingDeviceId(result.device_id) + setDeviceId(result.device_id) + Taro.showToast({ + title: '设备已绑定,请继续关联儿童', + icon: 'none', + }) + } catch (error: any) { + console.error('[bind] submit failed:', error) + Taro.showToast({ + title: error?.message || '绑定失败,请重试', + icon: 'none', + }) + } finally { + Taro.hideLoading() + setSubmitting(false) + } } if (loading) { return ( - Loading... + + 加载中... + ) } @@ -203,8 +235,10 @@ export default function Bind() { return ( - Bind Device - Scan QR/device code to bind first, then set child profile. + {pendingDeviceId ? '补全绑定' : '绑定设备'} + + {pendingDeviceId ? '设备已绑到当前账号,请补充儿童资料完成配置' : '扫描二维码或手动填写设备号和序列号'} + @@ -216,8 +250,8 @@ export default function Bind() { mode='aspectFit' /> - {isScanning ? 'Scanning...' : 'Tap to scan'} - Place QR/barcode in frame + {isScanning ? '正在扫码...' : '点击扫码'} + 请将二维码放入框内 @@ -228,51 +262,95 @@ export default function Bind() { - - - # - Manual device code input - {'>'} - - + + + 设备信息 - {pendingDeviceId && ( - - Device bound: {pendingDeviceId} - Add child profile to complete setup - + + 设备号 setNewChildName(e.detail.value)} + className='field-input' + value={deviceId} + disabled={!!pendingDeviceId} + placeholder='请输入设备号' + onInput={(event) => setDeviceId(event.detail.value)} /> - - {saving ? 'Saving...' : 'Create and link child'} + + + 设备序列号 + setSerialNumber(event.detail.value)} + /> + + {pendingDeviceId && ( + + 当前待完成设备:{pendingDeviceId} + + )} - )} + + + 儿童信息 + + {children.length > 0 && ( + + {children.map((child) => ( + { + setSelectedChildId((currentChildId) => (currentChildId === child.child_id ? null : child.child_id)) + setNewChildName('') + }} + > + {child.child_name} + + ))} + + )} + + + 新建儿童昵称 + 0 ? '不选已有儿童时,可填写新昵称' : '请输入儿童昵称'} + onInput={(event) => { + setNewChildName(event.detail.value) + if (event.detail.value) { + setSelectedChildId(null) + } + }} + /> + + + 优先选择已有儿童,也可以直接输入一个新昵称。 + + + + - Binding Tips + 绑定帮助 1 - Find QR code or device code on device. + 先扫描二维码,自动填入设备号和序列号。 2 - Bind device first by scan/manual input. + 如果二维码里只有设备号,请手动补充序列号。 3 - Create or select child profile to finish setup. - - - - - - Logout + 没有儿童资料时,可以直接在本页新建并完成绑定。 diff --git a/banban-mini/src/pages/device/index.scss b/banban-mini/src/pages/device/index.scss index 89dfc31..4cbf338 100644 --- a/banban-mini/src/pages/device/index.scss +++ b/banban-mini/src/pages/device/index.scss @@ -14,15 +14,8 @@ 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; - } } .status-card { @@ -289,3 +282,99 @@ color: #999999; } } + +.detail-card { + background: #FFFFFF; + border-radius: 20px; + margin: 0 24px 24px; + padding: 10px 24px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); +} + +.detail-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 0; + border-bottom: 1px solid #F3F3F3; + + &:last-child { + border-bottom: none; + } +} + +.detail-label { + font-size: 28px; + color: #666666; +} + +.detail-value { + max-width: 60%; + font-size: 28px; + color: #1A1A1A; + text-align: right; + word-break: break-all; +} + +.empty-card { + background: #FFFFFF; + border-radius: 24px; + margin: 0 24px; + padding: 48px 32px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); + text-align: center; +} + +.empty-robot { + width: 132px; + height: 132px; + background: #FEF3E8; + border-radius: 50%; + margin: 0 auto 24px; + display: flex; + align-items: center; + justify-content: center; + + .robot-img { + width: 68px; + height: 68px; + } +} + +.empty-title { + display: block; + font-size: 34px; + font-weight: 700; + color: #1A1A1A; +} + +.empty-btn { + margin-top: 28px; + height: 92px; + border-radius: 16px; + background: #FF8C42; + display: flex; + align-items: center; + justify-content: center; +} + +.empty-btn-text { + font-size: 30px; + color: #FFFFFF; + font-weight: 600; +} + +.helper-card { + background: #FFFFFF; + border-radius: 20px; + margin: 0 24px 24px; + padding: 28px 24px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); +} + +.helper-title { + display: block; + font-size: 30px; + font-weight: 600; + color: #1A1A1A; +} diff --git a/banban-mini/src/pages/device/index.tsx b/banban-mini/src/pages/device/index.tsx index e4bff30..492c70c 100644 --- a/banban-mini/src/pages/device/index.tsx +++ b/banban-mini/src/pages/device/index.tsx @@ -1,104 +1,65 @@ import { View, Text, Switch, Slider, Image } from '@tarojs/components' -import { useState, useEffect } from 'react' -import Taro from '@tarojs/taro' -import { getCurrentBinding } from '@/services/binding' +import { useState } from 'react' +import Taro, { useDidShow } from '@tarojs/taro' +import { getToken } from '@/services/auth' +import { Binding, resolveActiveBinding } from '@/services/binding' +import { Child, getChild } from '@/services/child' import './index.scss' -interface DeviceData { - id: string - name: string - status: 'online' | 'offline' - battery: number - daysLeft: number - sleepEnabled: boolean - sleepTime: string - volume: number -} - export default function Device() { - const [device, setDevice] = useState(null) + const [binding, setBinding] = useState(null) + const [child, setChild] = useState(null) const [isLoading, setIsLoading] = useState(true) - const [hasDevice, setHasDevice] = useState(false) + const [sleepEnabled, setSleepEnabled] = useState(true) + const [volume, setVolume] = useState(50) - useEffect(() => { - console.log('[Device] Checking login status...') - const token = Taro.getStorageSync('token') - console.log('[Device] Token exists:', !!token) - - if (!token) { - console.log('[Device] No token, redirecting to login') - Taro.redirectTo({ url: '/pages/login/index' }) + useDidShow(() => { + void loadDeviceInfo() + }) + + const loadDeviceInfo = async () => { + if (!getToken()) { + Taro.reLaunch({ url: '/pages/login/index' }) return } - loadDeviceInfo() - }, []) - - const loadDeviceInfo = async () => { + setIsLoading(true) try { - const binding = await getCurrentBinding() - console.log('[Device] Got binding:', binding) - - if (binding) { - if (!binding.child_id) { - setHasDevice(false) - Taro.showToast({ title: 'Please link child profile first', icon: 'none' }) - setTimeout(() => { - Taro.redirectTo({ url: '/pages/bind/index' }) - }, 600) - return - } + const activeBinding = await resolveActiveBinding() + setBinding(activeBinding) - setHasDevice(true) - Taro.setStorageSync('hasDevice', '1') - Taro.setStorageSync('deviceInfo', { - id: binding.device_id, - name: '伴伴设备', - status: 'online', - battery: 82 - }) - setDevice({ - id: binding.device_id, - name: '伴伴设备', - status: 'online', - battery: 82, - daysLeft: 4, - sleepEnabled: true, - sleepTime: '每天 22:00 - 07:00', - volume: 50 - }) + if (activeBinding?.child_id) { + try { + const currentChild = await getChild(activeBinding.child_id) + setChild(currentChild) + } catch (error) { + console.error('[device] load child failed:', error) + setChild(null) + } } else { - setHasDevice(false) - Taro.removeStorageSync('hasDevice') - Taro.removeStorageSync('deviceInfo') - } - } catch (err: any) { - console.log('[Device] No binding or error:', err?.message || err) - if (err?.status === 404) { - setHasDevice(false) - Taro.removeStorageSync('hasDevice') - Taro.removeStorageSync('deviceInfo') + setChild(null) } + } catch (error: any) { + console.error('[device] load failed:', error) + Taro.showToast({ + title: error?.message || '加载失败,请稍后重试', + icon: 'none', + }) + } finally { + setIsLoading(false) } - setIsLoading(false) } const handleSleepToggle = (e: any) => { - if (!device) return - setDevice({ ...device, sleepEnabled: e.detail.value }) + setSleepEnabled(e.detail.value) Taro.showToast({ - title: e.detail.value ? '已开启定时休眠' : '已关闭定时休眠', - icon: 'success' + title: e.detail.value ? '休眠展示已开启' : '休眠展示已关闭', + icon: 'none', }) } const handleVolumeChange = (e: any) => { - if (!device) return - setDevice({ ...device, volume: e.detail.value }) - } - - const switchTab = (url: string) => { - Taro.switchTab({ url }) + setVolume(e.detail.value) } if (isLoading) { @@ -111,27 +72,48 @@ export default function Device() { ) } - // 未绑定设备,跳转到绑定页面 - if (!hasDevice || !device) { - Taro.redirectTo({ url: '/pages/bind/index' }) - return null + if (!binding) { + return ( + + + 设备首页 + + + + + + + 先绑定一台伴伴设备 + Taro.navigateTo({ url: '/pages/bind/index' })}> + 去绑定设备 + + + + ) } + const childName = child?.child_name || '未设置' + const pageTitle = child?.child_name ? `${child.child_name} 的伴伴` : '伴伴设备' + const battery = 82 + const daysLeft = 4 + return ( - {device.name} - 设备运行状态良好 + {pageTitle} - 4G 在线 + {binding.child_id ? '已完成绑定' : '待关联儿童'} - {device.battery}% - 剩余电量 (预计续航 {device.daysLeft} 天) + + {battery} + % + + 模拟电量展示 (预计续航 {daysLeft} 天) @@ -140,6 +122,21 @@ export default function Device() { + + + 设备号 + {binding.device_id} + + + 儿童昵称 + {childName} + + + 绑定时间 + {binding.bound_at || '--'} + + + 基础控制 @@ -153,14 +150,10 @@ export default function Device() { 定时休眠 - {device.sleepTime} + 开启后进入休眠展示 - + @@ -188,7 +181,7 @@ export default function Device() { className='slider' min={0} max={100} - value={device.volume} + value={volume} onChange={handleVolumeChange} activeColor='#FF8C42' backgroundColor='#E5E5E5' @@ -203,6 +196,11 @@ export default function Device() { + {!binding.child_id && ( + Taro.navigateTo({ url: '/pages/bind/index' })}> + 继续完成儿童关联 + + )} ) } diff --git a/banban-mini/src/pages/sleep/index.scss b/banban-mini/src/pages/sleep/index.scss index 9bba1ba..b9d7829 100644 --- a/banban-mini/src/pages/sleep/index.scss +++ b/banban-mini/src/pages/sleep/index.scss @@ -161,149 +161,198 @@ } } -.add-input-box { +.image-avatar { + width: 100%; + height: 100%; + border-radius: 24px; +} + +.summary-card { background: #FFFFFF; border-radius: 20px; margin: 0 24px 24px; - padding: 24px; + padding: 10px 24px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); +} - .input-row { - display: flex; - flex-direction: column; - gap: 16px; +.summary-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 0; + border-bottom: 1px solid #F2F2F2; + + &:last-child { + border-bottom: none; } +} - .input-wrapper { - .name-input { - background: #F5F7FA; - border-radius: 12px; - padding: 20px 24px; - font-size: 30px; - width: 100%; - box-sizing: border-box; - } +.summary-label { + font-size: 28px; + color: #666666; +} - .input-hint { - font-size: 24px; - color: #999999; - margin-top: 12px; - display: block; - } - } - - .input-btns { - display: flex; - justify-content: flex-end; - gap: 24px; - - .confirm-btn { - font-size: 30px; - color: #FF8C42; - font-weight: 500; - } - - .cancel-btn { - font-size: 30px; - color: #999999; - } - } +.summary-value { + max-width: 60%; + font-size: 28px; + color: #1A1A1A; + text-align: right; + word-break: break-all; } .menu-item.disabled { - opacity: 0.5; - - &:active { - background: transparent; - } + opacity: 0.45; } .logout-section { - margin: 24px; + margin: 0 24px; } .logout-btn { + height: 92px; + border-radius: 18px; background: #FFFFFF; - border-radius: 20px; - padding: 26px 24px; - text-align: center; + display: flex; + align-items: center; + justify-content: center; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); +} - .logout-text { - font-size: 30px; - color: #FF3B30; - font-weight: 500; - } +.logout-text { + font-size: 30px; + color: #FF6B35; + font-weight: 600; } .modal-mask { position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.5); + inset: 0; + background: rgba(0, 0, 0, 0.42); display: flex; align-items: center; justify-content: center; - z-index: 1000; + padding: 0 48px; + z-index: 20; } -.modal-content { +.modal-card { + width: 100%; background: #FFFFFF; - border-radius: 20px; - width: 600px; - margin: 0 48px; - overflow: hidden; + border-radius: 24px; + padding: 32px; + box-sizing: border-box; } -.modal-header { - padding: 32px 32px 16px; - text-align: center; +.modal-title { + display: block; + font-size: 32px; + font-weight: 700; + color: #1A1A1A; + margin-bottom: 24px; +} - .modal-title { - font-size: 34px; +.modal-input-wrap { + background: #F7F8FA; + border-radius: 16px; + padding: 0 24px; +} + +.modal-input { + height: 88px; + width: 100%; + font-size: 28px; + color: #1A1A1A; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + margin-top: 28px; +} + +.modal-action { + font-size: 30px; + margin-left: 32px; + + &.cancel { + color: #999999; + } + + &.confirm { + color: #FF8C42; font-weight: 600; - color: #1A1A1A; } } -.modal-body { - padding: 16px 32px 32px; +.device-switch-card { + max-height: 70vh; +} + +.device-switch-empty { + display: block; + font-size: 28px; + color: #999999; + line-height: 1.6; +} + +.device-switch-list { + max-height: 52vh; + overflow-y: auto; +} + +.device-switch-item { + padding: 24px; + border-radius: 18px; + background: #F7F8FA; + margin-bottom: 16px; + + &:last-child { + margin-bottom: 0; + } + + &.active { + background: #FFF2E7; + border: 2px solid #FFBE94; + } +} + +.device-switch-head { display: flex; align-items: center; - - .modal-input { - background: #F5F7FA; - border-radius: 12px; - padding: 0 24px; - font-size: 34px; - width: 100%; - height: 96px; - box-sizing: border-box; - display: flex; - align-items: center; - } + justify-content: space-between; + margin-bottom: 10px; } -.modal-footer { +.device-switch-id { + font-size: 28px; + color: #1A1A1A; + font-weight: 600; + word-break: break-all; +} + +.device-switch-tag { + margin-left: 16px; + padding: 6px 14px; + border-radius: 999px; + background: #FF8C42; + font-size: 22px; + color: #FFFFFF; + flex-shrink: 0; +} + +.device-switch-name { + font-size: 26px; + color: #666666; +} + +.loading { + min-height: 100%; display: flex; - border-top: 1px solid #F0F0F0; + align-items: center; + justify-content: center; - .modal-btn { - flex: 1; - text-align: center; - padding: 24px 0; + text { font-size: 32px; - - &.cancel { - color: #666666; - border-right: 1px solid #F0F0F0; - } - - &.confirm { - color: #FF8C42; - font-weight: 500; - } + color: #999999; } } diff --git a/banban-mini/src/pages/sleep/index.tsx b/banban-mini/src/pages/sleep/index.tsx index e93c8f3..a788c25 100644 --- a/banban-mini/src/pages/sleep/index.tsx +++ b/banban-mini/src/pages/sleep/index.tsx @@ -1,210 +1,270 @@ import { View, Text, Image, Input } from '@tarojs/components' -import { useState, useEffect } from 'react' -import Taro from '@tarojs/taro' -import { getToken, getCurrentUserId } from '@/services/api' -import { getChildren, createChild, updateChild } from '@/services/child' -import { getCurrentBinding, unbindDevice } from '@/services/binding' +import { useState } from 'react' +import Taro, { useDidShow } from '@tarojs/taro' +import { clearToken, getCurrentUserId, getToken } from '@/services/auth' +import { + BindingListItem, + clearSelectedBindingDeviceId, + getBindings, + resolveBindingSelection, + setBindingChild, + setSelectedBindingDeviceId, + unbindDevice, +} from '@/services/binding' +import { Child, createChild, getChildren, updateChild } from '@/services/child' import './index.scss' -interface Child { - child_id: number - child_name: string - child_gender: number - status: number -} - -interface Binding { - device_id: string - child_id: number | null - status: number -} - interface MenuItem { icon: string iconBgClass: string name: string - value?: string + value: string arrow?: boolean disabled?: boolean } export default function Sleep() { - const [children, setChildren] = useState([]) - const [binding, setBinding] = useState(null) const [loading, setLoading] = useState(true) + const [children, setChildren] = useState([]) + const [bindings, setBindings] = useState([]) + const [binding, setBinding] = useState(null) const [showModal, setShowModal] = useState(false) + const [showDeviceModal, setShowDeviceModal] = useState(false) const [modalType, setModalType] = useState<'add' | 'edit'>('add') const [childName, setChildName] = useState('') const [editingChildId, setEditingChildId] = useState(null) const [parentInfo, setParentInfo] = useState<{ nickname?: string; avatar_url?: string }>({}) - const token = getToken() - const userId = getCurrentUserId() + useDidShow(() => { + void loadData() + }) - useEffect(() => { - if (!token) { + const loadData = async () => { + if (!getToken()) { Taro.reLaunch({ url: '/pages/login/index' }) return } - loadData() - }, []) - const loadData = async () => { + setLoading(true) try { - setLoading(true) - - // Get parent info - const userInfo = Taro.getStorageSync('userInfo') || {} - setParentInfo(userInfo) - - // Get children - const childRes = await getChildren() - setChildren(childRes.items || []) + const [childResponse, bindingResponse] = await Promise.all([getChildren(), getBindings(undefined, 100)]) + const bindingItems = bindingResponse.items || [] + const activeBinding = resolveBindingSelection(bindingItems) - // Get binding - try { - const bindRes = await getCurrentBinding() - setBinding(bindRes) - } catch (err: any) { - if (err.status === 404) { - setBinding(null) - } - } - } catch (err) { - console.error('Load failed:', err) + setChildren(childResponse.items || []) + setBindings(bindingItems) + setBinding(activeBinding) + setParentInfo(Taro.getStorageSync('userInfo') || {}) + } catch (error: any) { + console.error('[manage] load failed:', error) + Taro.showToast({ + title: error?.message || '加载失败,请稍后重试', + icon: 'none', + }) } finally { setLoading(false) } } + const currentChild = binding?.child_id ? children.find((item) => item.child_id === binding.child_id) || null : null + const currentChildName = currentChild?.child_name || binding?.child_name || (binding && !binding.child_id ? '待关联' : '未设置') + const handleOpenModal = (type: 'add' | 'edit', child?: Child) => { setModalType(type) - if (type === 'edit' && child) { - setChildName(child.child_name) - setEditingChildId(child.child_id) - } else { - setChildName('') - setEditingChildId(null) - } + setChildName(child?.child_name || '') + setEditingChildId(child?.child_id || null) setShowModal(true) } - const handleModalConfirm = async () => { - if (!childName.trim()) { - Taro.showToast({ title: '请输入昵称', icon: 'none' }) - return - } - - try { - if (modalType === 'add') { - await createChild({ child_name: childName.trim() }) - Taro.showToast({ title: '添加成功', icon: 'success' }) - } else if (editingChildId) { - await updateChild(editingChildId, { child_name: childName.trim() }) - Taro.showToast({ title: '修改成功', icon: 'success' }) - } - setShowModal(false) - setChildName('') - setEditingChildId(null) - loadData() - } catch (err) { - Taro.showToast({ title: '操作失败', icon: 'none' }) - } - } - - const handleModalCancel = () => { + const handleCloseModal = () => { setShowModal(false) setChildName('') setEditingChildId(null) } - const handleUnbind = () => { - if (!binding) return - Taro.showModal({ - title: '解除设备绑定', - content: '确定要解除当前设备的绑定吗?', - confirmColor: '#FF8C42', - success: async (res) => { - if (res.confirm) { - try { - await unbindDevice(binding.device_id) - Taro.showToast({ title: '已解绑', icon: 'success' }) - setBinding(null) - loadData() - } catch (err) { - Taro.showToast({ title: '解绑失败', icon: 'none' }) - } - } - } + const handleSelectBinding = (targetBinding: BindingListItem) => { + setSelectedBindingDeviceId(targetBinding.device_id) + setBinding(targetBinding) + setShowDeviceModal(false) + Taro.showToast({ + title: '已切换设备', + icon: 'success', }) } - const handleLogout = async () => { + const handleSubmitModal = async () => { + const normalizedName = childName.trim() + if (!normalizedName) { + Taro.showToast({ title: '请输入儿童昵称', icon: 'none' }) + return + } + + try { + if (modalType === 'add') { + const createdChild = await createChild({ child_name: normalizedName }) + if (binding && !binding.child_id) { + await setBindingChild(binding.device_id, { child_id: createdChild.child_id }) + Taro.showToast({ title: '已创建并关联', icon: 'success' }) + } else { + Taro.showToast({ title: '创建成功', icon: 'success' }) + } + } else if (editingChildId) { + await updateChild(editingChildId, { child_name: normalizedName }) + Taro.showToast({ title: '修改成功', icon: 'success' }) + } + + handleCloseModal() + await loadData() + } catch (error: any) { + console.error('[manage] save child failed:', error) + Taro.showToast({ + title: error?.message || '保存失败,请重试', + icon: 'none', + }) + } + } + + const handleChildMenu = () => { + if (binding && !binding.child_id && children.length > 0) { + Taro.showActionSheet({ + itemList: [...children.map((item) => item.child_name), '新建儿童资料'], + success: async (result) => { + if (result.tapIndex === children.length) { + handleOpenModal('add') + return + } + + const targetChild = children[result.tapIndex] + if (!targetChild) return + + try { + await setBindingChild(binding.device_id, { child_id: targetChild.child_id }) + Taro.showToast({ title: '关联成功', icon: 'success' }) + await loadData() + } catch (error: any) { + Taro.showToast({ + title: error?.message || '关联失败,请重试', + icon: 'none', + }) + } + }, + }) + return + } + + if (currentChild) { + handleOpenModal('edit', currentChild) + return + } + + handleOpenModal('add') + } + + const handleUnbind = () => { + if (!binding) return + + Taro.showModal({ + title: '解除设备绑定', + content: '确定要解除当前设备绑定吗?', + confirmColor: '#FF8C42', + success: async (result) => { + if (!result.confirm) return + + try { + await unbindDevice(binding.device_id) + Taro.showToast({ title: '已解绑', icon: 'success' }) + await loadData() + } catch (error: any) { + Taro.showToast({ + title: error?.message || '解绑失败,请重试', + icon: 'none', + }) + } + }, + }) + } + + const handleLogout = () => { Taro.showModal({ title: '退出登录', - content: '确定要退出登录吗?', + content: '确定要退出当前账号吗?', confirmColor: '#FF8C42', - success: (res) => { - if (res.confirm) { - Taro.removeStorageSync('token') - Taro.removeStorageSync('user_id') - Taro.removeStorageSync('userInfo') - Taro.reLaunch({ url: '/pages/login/index' }) - } - } + success: (result) => { + if (!result.confirm) return + clearToken() + clearSelectedBindingDeviceId() + Taro.removeStorageSync('userInfo') + Taro.reLaunch({ url: '/pages/login/index' }) + }, }) } const handleMenuClick = (item: MenuItem) => { if (item.disabled) return - + + if (item.name === '儿童资料 (用于称呼)') { + handleChildMenu() + return + } + if (item.name === '绑定新设备') { - if (binding) { - Taro.showToast({ title: '已有设备绑定', icon: 'none' }) - } else { - Taro.navigateTo({ url: '/pages/bind/index' }) - } - } else if (item.name === '解除设备绑定') { + Taro.navigateTo({ url: '/pages/bind/index' }) + return + } + + if (item.name === '切换设备') { + setShowDeviceModal(true) + return + } + + if (item.name === '解除设备绑定') { handleUnbind() - } else if (item.name.includes('儿童资料')) { - if (children.length > 0) { - handleOpenModal('edit', children[0]) - } else { - handleOpenModal('add') - } } } - if (!token) return null + if (loading) { + return ( + + + 加载中... + + + ) + } + const userId = getCurrentUserId() const menuItems: MenuItem[] = [ { icon: require('../../assets/tab-icons/orange-robot.png'), iconBgClass: 'orange', name: '儿童资料 (用于称呼)', - value: children.length > 0 ? children[0].child_name : '未设置', - arrow: true + value: currentChildName, + arrow: true, }, { icon: require('../../assets/tab-icons/rings.png'), iconBgClass: 'green', + name: '切换设备', + value: bindings.length > 0 ? `共 ${bindings.length} 台` : '暂无设备', + arrow: true, + }, + { + icon: require('../../assets/tab-icons/orange-robot.png'), + iconBgClass: 'orange', name: '绑定新设备', value: '', - arrow: true + arrow: true, }, { icon: require('../../assets/tab-icons/broken-rings.png'), iconBgClass: 'red', name: '解除设备绑定', - value: '', + value: binding?.device_id ? `当前: ${binding.device_id}` : '', arrow: true, - disabled: !binding - } + disabled: !binding, + }, ] - const firstChild = children[0] - const childDisplayValue = firstChild ? firstChild.child_name : '' - return ( @@ -214,24 +274,39 @@ export default function Sleep() { {parentInfo.avatar_url ? ( - + ) : ( 👤 )} {parentInfo.nickname || '家长用户'} - ID: {userId} + 用户 ID: {userId || '--'} + + + {bindings.length > 0 ? `已绑定 ${bindings.length} 台` : '未绑定设备'} + + + + + + 已绑定设备数 + {bindings.length} 台 + + + 当前设备 + {binding?.device_id || '未绑定'} + + + 当前儿童 + {currentChildName} {menuItems.map((item, index) => ( - handleMenuClick(item)} - > + handleMenuClick(item)}> @@ -239,9 +314,7 @@ export default function Sleep() { {item.name} - {item.name.includes('儿童资料') && ( - {childDisplayValue} - )} + {item.value && {item.value}} {item.arrow && } @@ -260,25 +333,66 @@ export default function Sleep() { 伴伴 Companion V1.1.0 - {/* Modal */} + {showDeviceModal && ( + setShowDeviceModal(false)}> + { + event.stopPropagation() + }} + > + 切换设备 + {bindings.length === 0 ? ( + 当前还没有已绑定设备 + ) : ( + + {bindings.map((item) => { + const isActive = binding?.device_id === item.device_id + return ( + handleSelectBinding(item)} + > + + {item.device_id} + {isActive && 当前} + + {item.child_name || '待关联儿童'} + + ) + })} + + )} + + setShowDeviceModal(false)}> + 关闭 + + + + + )} + {showModal && ( - - - {modalType === 'add' ? '添加儿童' : '修改昵称'} - - - + {modalType === 'add' ? '新建儿童资料' : '修改儿童昵称'} + + setChildName(e.detail.value)} - focus={true} + placeholder='请输入儿童昵称' + focus + onInput={(event) => setChildName(event.detail.value)} /> - - 取消 - 确认 + + + 取消 + + + 确认 + diff --git a/banban-mini/src/services/binding.ts b/banban-mini/src/services/binding.ts index 79270dc..e0501a5 100644 --- a/banban-mini/src/services/binding.ts +++ b/banban-mini/src/services/binding.ts @@ -1,97 +1,126 @@ +import Taro from '@tarojs/taro' import { request } from './api' +const SELECTED_BINDING_DEVICE_ID_KEY = 'selectedBindingDeviceId' + +function buildQuery(params: Record): string { + return Object.entries(params) + .filter(([, value]) => value !== undefined && value !== null && value !== '') + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`) + .join('&') +} + export interface Binding { device_id: string child_id: number | null + child_name?: string | null status: number bound_at: string } -export interface BindStartResponse { - bind_token: string - expires_at: string -} +export interface BindingListItem extends Binding {} -export interface BindHistoryItem { - device_id: string - child_id: number | null - bound_at: string - unbound_at?: string -} - -export interface BindHistoryResponse { - items: BindHistoryItem[] +export interface BindingListResponse { + items: BindingListItem[] total: number - next_cursor?: string + next_cursor?: number | null +} + +export interface DirectBindPayload { + device_id: string + serial_number: string + child_id?: number +} + +export function getSelectedBindingDeviceId(): string | null { + const value = Taro.getStorageSync(SELECTED_BINDING_DEVICE_ID_KEY) + const deviceId = String(value || '').trim() + return deviceId || null +} + +export function setSelectedBindingDeviceId(deviceId?: string | null) { + const normalizedDeviceId = String(deviceId || '').trim() + if (!normalizedDeviceId) { + Taro.removeStorageSync(SELECTED_BINDING_DEVICE_ID_KEY) + return + } + Taro.setStorageSync(SELECTED_BINDING_DEVICE_ID_KEY, normalizedDeviceId) +} + +export function clearSelectedBindingDeviceId() { + Taro.removeStorageSync(SELECTED_BINDING_DEVICE_ID_KEY) +} + +export function resolveBindingSelection(items: T[]): T | null { + const selectedDeviceId = getSelectedBindingDeviceId() + const selectedBinding = selectedDeviceId ? items.find((item) => item.device_id === selectedDeviceId) || null : null + const activeBinding = selectedBinding || items[0] || null + + if (activeBinding) { + setSelectedBindingDeviceId(activeBinding.device_id) + } else { + clearSelectedBindingDeviceId() + } + + return activeBinding } -// 获取当前绑定信息 export async function getCurrentBinding(): Promise { try { return await request('/bindings/current') - } catch (err: any) { - if (err.status === 404) { - return null - } - throw err + } catch (error: any) { + if (error?.status === 404) return null + throw error } } -// 开始绑定设备 -export async function startBind(data: { - device_id: string - child_id?: number -}): Promise { - return request('/bindings/start', { - method: 'POST', - data, +export async function getBindings(cursor?: number, limit: number = 20): Promise { + const query = buildQuery({ + cursor, + limit, }) + return request(`/bindings?${query}`) } -// 确认绑定设备 -export async function confirmBind(data: { - bind_token: string - challenge_code: string -}): Promise<{ device_id: string; child_id: number | null }> { - return request('/bindings/confirm', { - method: 'POST', - data, - }) -} - -// 获取设备绑定信息 export async function getBinding(deviceId: string): Promise { - // If no deviceId, get the first binding for this user - if (!deviceId) { - try { - // Try to get binding list - for now return null if no specific device - return null - } catch { - return null - } + try { + return await request(`/bindings/${deviceId}`) + } catch (error: any) { + if (error?.status === 404) return null + throw error } - return request(`/bindings/${deviceId}`) } -// 解绑设备 -export async function unbindDevice(deviceId: string): Promise { - return request(`/bindings/${deviceId}`, { - method: 'DELETE', - }) +export async function resolveActiveBinding(): Promise { + const selectedDeviceId = getSelectedBindingDeviceId() + + if (selectedDeviceId) { + const selectedBinding = await getBinding(selectedDeviceId) + if (selectedBinding) { + setSelectedBindingDeviceId(selectedBinding.device_id) + return selectedBinding + } + + clearSelectedBindingDeviceId() + } + + const currentBinding = await getCurrentBinding() + if (currentBinding?.device_id) { + setSelectedBindingDeviceId(currentBinding.device_id) + } else { + clearSelectedBindingDeviceId() + } + + return currentBinding } -// 直接绑定设备(手动输入设备码) -export async function directBind(data: { - device_id: string - child_id?: number -}): Promise<{ device_id: string; child_id: number | null }> { +export async function directBind(data: DirectBindPayload): Promise<{ device_id: string; child_id: number | null }> { return request('/bindings/direct', { method: 'POST', data, }) } -// Bind child profile after device has been bound to parent account. export async function setBindingChild( deviceId: string, data: { child_id: number } @@ -102,15 +131,8 @@ export async function setBindingChild( }) } -// 获取绑定历史 -export async function getBindHistory( - deviceId: string, - cursor?: string, - limit: number = 20 -): Promise { - const params = new URLSearchParams() - if (cursor) params.append('cursor', cursor) - params.append('limit', String(limit)) - - return request(`/bindings/history/${deviceId}?${params.toString()}`) +export async function unbindDevice(deviceId: string): Promise { + return request(`/bindings/${deviceId}`, { + method: 'DELETE', + }) } diff --git a/banban-mini/src/services/child.ts b/banban-mini/src/services/child.ts index 034ef59..67b8f29 100644 --- a/banban-mini/src/services/child.ts +++ b/banban-mini/src/services/child.ts @@ -1,5 +1,12 @@ import { request } from './api' +function buildQuery(params: Record): string { + return Object.entries(params) + .filter(([, value]) => value !== undefined && value !== null && value !== '') + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`) + .join('&') +} + export interface Child { child_id: number child_name: string @@ -11,10 +18,9 @@ export interface Child { export interface ChildListResponse { items: Child[] total: number - next_cursor?: number + next_cursor?: number | null } -// 创建小孩档案 export async function createChild(data: { child_name: string child_gender?: number @@ -26,24 +32,18 @@ export async function createChild(data: { }) } -// 获取小孩列表 -export async function getChildren( - cursor?: number, - limit: number = 20 -): Promise { - const params = new URLSearchParams() - if (cursor) params.append('cursor', String(cursor)) - params.append('limit', String(limit)) - - return request(`/children?${params.toString()}`) +export async function getChildren(cursor?: number, limit: number = 20): Promise { + const query = buildQuery({ + cursor, + limit, + }) + return request(`/children?${query}`) } -// 获取单个小孩信息 export async function getChild(childId: number): Promise { return request(`/children/${childId}`) } -// 更新小孩信息 export async function updateChild( childId: number, data: { child_name?: string; child_gender?: number; child_birthday?: string } @@ -52,4 +52,4 @@ export async function updateChild( method: 'PATCH', data, }) -} \ No newline at end of file +}