feat(小程序): 支持多设备绑定与管理

This commit is contained in:
stu2not
2026-04-22 17:25:31 +08:00
parent d7df6f565c
commit f21e0afcd0
8 changed files with 1078 additions and 670 deletions

View File

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

View File

@@ -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<Record<string, string>>((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<Child[]>([])
const [selectedChildId, setSelectedChildId] = useState<number | null>(null)
const [newChildName, setNewChildName] = useState('')
const [saving, setSaving] = useState(false)
const [pendingDeviceId, setPendingDeviceId] = useState<string | null>(null)
useEffect(() => {
checkAuth()
}, [])
useDidShow(() => {
void loadPageData()
})
const goDevicePage = () => {
setTimeout(() => {
Taro.reLaunch({ url: '/pages/device/index' })
}, 500)
}
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
if (activeBinding?.device_id && !activeBinding.child_id) {
setPendingDeviceId(activeBinding.device_id)
setDeviceId(activeBinding.device_id)
} else {
setPendingDeviceId(null)
setDeviceId('')
setSerialNumber('')
}
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
}
}
} 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<number | null> => {
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' })
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 (
<View className='bind-page'>
<View className='loading'>Loading...</View>
<View className='loading'>
<Text>...</Text>
</View>
</View>
)
}
@@ -203,8 +235,10 @@ export default function Bind() {
return (
<View className='bind-page'>
<View className='bind-header'>
<Text className='title'>Bind Device</Text>
<Text className='subtitle'>Scan QR/device code to bind first, then set child profile.</Text>
<Text className='title'>{pendingDeviceId ? '补全绑定' : '绑定设备'}</Text>
<Text className='subtitle'>
{pendingDeviceId ? '设备已绑到当前账号,请补充儿童资料完成配置' : '扫描二维码或手动填写设备号和序列号'}
</Text>
</View>
<View className='scan-area'>
@@ -216,8 +250,8 @@ export default function Bind() {
mode='aspectFit'
/>
</View>
<Text className='scan-text'>{isScanning ? 'Scanning...' : 'Tap to scan'}</Text>
<Text className='scan-hint'>Place QR/barcode in frame</Text>
<Text className='scan-text'>{isScanning ? '正在扫码...' : '点击扫码'}</Text>
<Text className='scan-hint'></Text>
</View>
<View className='scan-corners'>
@@ -228,51 +262,95 @@ export default function Bind() {
</View>
</View>
<View className='bind-options'>
<View className='option-item' onClick={handleManualInput}>
<Text className='option-icon'>#</Text>
<Text className='option-text'>Manual device code input</Text>
<Text className='option-arrow'>{'>'}</Text>
<View className='bind-form'>
<View className='form-card'>
<Text className='form-title'></Text>
<View className='field-item'>
<Text className='field-label'></Text>
<Input
className='field-input'
value={deviceId}
disabled={!!pendingDeviceId}
placeholder='请输入设备号'
onInput={(event) => setDeviceId(event.detail.value)}
/>
</View>
<View className='field-item'>
<Text className='field-label'></Text>
<Input
className='field-input'
value={serialNumber}
disabled={!!pendingDeviceId}
placeholder='请输入设备序列号'
onInput={(event) => setSerialNumber(event.detail.value)}
/>
</View>
{pendingDeviceId && (
<View className='empty-tip'>
<Text className='empty-title'>Device bound: {pendingDeviceId}</Text>
<Text className='empty-desc'>Add child profile to complete setup</Text>
<View className='empty-input-wrap'>
<Input
className='empty-input'
placeholder='Enter child name'
value={newChildName}
onInput={(e) => setNewChildName(e.detail.value)}
/>
<View className='pending-tip'>
<Text className='pending-tip-text'>{pendingDeviceId}</Text>
</View>
<View className='empty-btn' onClick={handleAddChild}>
<Text className='empty-btn-text'>{saving ? 'Saving...' : 'Create and link child'}</Text>
)}
</View>
<View className='form-card'>
<Text className='form-title'></Text>
{children.length > 0 && (
<View className='child-list'>
{children.map((child) => (
<View
key={child.child_id}
className={`child-item ${selectedChildId === child.child_id ? 'selected' : ''}`}
onClick={() => {
setSelectedChildId((currentChildId) => (currentChildId === child.child_id ? null : child.child_id))
setNewChildName('')
}}
>
<Text className='child-item-text'>{child.child_name}</Text>
</View>
))}
</View>
)}
<View className='field-item'>
<Text className='field-label'></Text>
<Input
className='field-input'
value={newChildName}
placeholder={children.length > 0 ? '不选已有儿童时,可填写新昵称' : '请输入儿童昵称'}
onInput={(event) => {
setNewChildName(event.detail.value)
if (event.detail.value) {
setSelectedChildId(null)
}
}}
/>
</View>
<Text className='field-hint'></Text>
</View>
<Button className='submit-btn' loading={submitting} disabled={submitting} onClick={handleSubmit}>
{pendingDeviceId ? '完成儿童关联' : '绑定设备'}
</Button>
</View>
<View className='bind-tips'>
<Text className='tips-title'>Binding Tips</Text>
<Text className='tips-title'></Text>
<View className='tip-item'>
<Text className='tip-number'>1</Text>
<Text className='tip-text'>Find QR code or device code on device.</Text>
<Text className='tip-text'></Text>
</View>
<View className='tip-item'>
<Text className='tip-number'>2</Text>
<Text className='tip-text'>Bind device first by scan/manual input.</Text>
<Text className='tip-text'></Text>
</View>
<View className='tip-item'>
<Text className='tip-number'>3</Text>
<Text className='tip-text'>Create or select child profile to finish setup.</Text>
</View>
</View>
<View className='bind-footer'>
<View className='logout-btn' onClick={handleLogout}>
<Text className='logout-btn-text'>Logout</Text>
<Text className='tip-text'></Text>
</View>
</View>
</View>

View File

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

View File

@@ -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<DeviceData | null>(null)
const [binding, setBinding] = useState<Binding | null>(null)
const [child, setChild] = useState<Child | null>(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' })
return
}
loadDeviceInfo()
}, [])
useDidShow(() => {
void loadDeviceInfo()
})
const loadDeviceInfo = async () => {
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)
if (!getToken()) {
Taro.reLaunch({ url: '/pages/login/index' })
return
}
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
})
setIsLoading(true)
try {
const activeBinding = await resolveActiveBinding()
setBinding(activeBinding)
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)
}
}
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 (
<View className='device-page'>
<View className='page-header'>
<Text className='page-title'></Text>
</View>
<View className='empty-card'>
<View className='empty-robot'>
<Image className='robot-img' src={require('../../assets/tab-icons/robot.png')} mode='aspectFit' />
</View>
<Text className='empty-title'></Text>
<View className='empty-btn' onClick={() => Taro.navigateTo({ url: '/pages/bind/index' })}>
<Text className='empty-btn-text'></Text>
</View>
</View>
</View>
)
}
const childName = child?.child_name || '未设置'
const pageTitle = child?.child_name ? `${child.child_name} 的伴伴` : '伴伴设备'
const battery = 82
const daysLeft = 4
return (
<View className='device-page'>
<View className='page-header'>
<Text className='page-title'>{device.name}</Text>
<Text className='page-subtitle'></Text>
<Text className='page-title'>{pageTitle}</Text>
</View>
<View className='status-card'>
<View className='status-badge'>
<View className='status-dot'></View>
<Text className='status-text'>4G 线</Text>
<Text className='status-text'>{binding.child_id ? '已完成绑定' : '待关联儿童'}</Text>
</View>
<View className='battery-section'>
<Text className='battery-percent'>{device.battery}<Text className='percent'>%</Text></Text>
<Text className='battery-label'> ( {device.daysLeft} )</Text>
<Text className='battery-percent'>
{battery}
<Text className='percent'>%</Text>
</Text>
<Text className='battery-label'> ( {daysLeft} )</Text>
</View>
<View className='device-avatar'>
<View className='robot-icon'>
@@ -140,6 +122,21 @@ export default function Device() {
</View>
</View>
<View className='detail-card'>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className='detail-value'>{binding.device_id}</Text>
</View>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className='detail-value'>{childName}</Text>
</View>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className='detail-value'>{binding.bound_at || '--'}</Text>
</View>
</View>
<View className='section-title'></View>
<View className='control-card'>
<View className='control-item'>
@@ -153,14 +150,10 @@ export default function Device() {
</View>
<View className='control-info'>
<Text className='control-name'></Text>
<Text className='control-detail'>{device.sleepTime}</Text>
<Text className='control-detail'></Text>
</View>
</View>
<Switch
checked={device.sleepEnabled}
onChange={handleSleepToggle}
color='#FF8C42'
/>
<Switch checked={sleepEnabled} onChange={handleSleepToggle} color='#FF8C42' />
</View>
<View className='divider'></View>
@@ -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() {
</View>
</View>
{!binding.child_id && (
<View className='helper-card' onClick={() => Taro.navigateTo({ url: '/pages/bind/index' })}>
<Text className='helper-title'></Text>
</View>
)}
</View>
)
}

View File

@@ -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 {
.summary-row {
display: flex;
flex-direction: column;
gap: 16px;
}
justify-content: space-between;
align-items: center;
padding: 20px 0;
border-bottom: 1px solid #F2F2F2;
.input-wrapper {
.name-input {
background: #F5F7FA;
border-radius: 12px;
padding: 20px 24px;
font-size: 30px;
width: 100%;
box-sizing: border-box;
}
.input-hint {
font-size: 24px;
color: #999999;
margin-top: 12px;
display: block;
&:last-child {
border-bottom: none;
}
}
.input-btns {
display: flex;
justify-content: flex-end;
gap: 24px;
.confirm-btn {
font-size: 30px;
color: #FF8C42;
font-weight: 500;
.summary-label {
font-size: 28px;
color: #666666;
}
.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;
}
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 {
font-size: 34px;
font-weight: 600;
display: block;
font-size: 32px;
font-weight: 700;
color: #1A1A1A;
}
margin-bottom: 24px;
}
.modal-body {
padding: 16px 32px 32px;
display: flex;
align-items: center;
.modal-input-wrap {
background: #F7F8FA;
border-radius: 16px;
padding: 0 24px;
}
.modal-input {
background: #F5F7FA;
border-radius: 12px;
padding: 0 24px;
font-size: 34px;
height: 88px;
width: 100%;
height: 96px;
box-sizing: border-box;
display: flex;
align-items: center;
}
font-size: 28px;
color: #1A1A1A;
}
.modal-footer {
.modal-actions {
display: flex;
border-top: 1px solid #F0F0F0;
justify-content: flex-end;
margin-top: 28px;
}
.modal-btn {
flex: 1;
text-align: center;
padding: 24px 0;
font-size: 32px;
.modal-action {
font-size: 30px;
margin-left: 32px;
&.cancel {
color: #666666;
border-right: 1px solid #F0F0F0;
color: #999999;
}
&.confirm {
color: #FF8C42;
font-weight: 500;
font-weight: 600;
}
}
.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;
justify-content: space-between;
margin-bottom: 10px;
}
.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;
align-items: center;
justify-content: center;
text {
font-size: 32px;
color: #999999;
}
}

View File

@@ -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<Child[]>([])
const [binding, setBinding] = useState<Binding | null>(null)
const [loading, setLoading] = useState(true)
const [children, setChildren] = useState<Child[]>([])
const [bindings, setBindings] = useState<BindingListItem[]>([])
const [binding, setBinding] = useState<BindingListItem | null>(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<number | null>(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 () => {
try {
setLoading(true)
// Get parent info
const userInfo = Taro.getStorageSync('userInfo') || {}
setParentInfo(userInfo)
// Get children
const childRes = await getChildren()
setChildren(childRes.items || [])
// 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)
const [childResponse, bindingResponse] = await Promise.all([getChildren(), getBindings(undefined, 100)])
const bindingItems = bindingResponse.items || []
const activeBinding = resolveBindingSelection(bindingItems)
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' })
const handleCloseModal = () => {
setShowModal(false)
setChildName('')
setEditingChildId(null)
}
const handleSelectBinding = (targetBinding: BindingListItem) => {
setSelectedBindingDeviceId(targetBinding.device_id)
setBinding(targetBinding)
setShowDeviceModal(false)
Taro.showToast({
title: '已切换设备',
icon: 'success',
})
}
const handleSubmitModal = async () => {
const normalizedName = childName.trim()
if (!normalizedName) {
Taro.showToast({ title: '请输入儿童昵称', icon: 'none' })
return
}
try {
if (modalType === 'add') {
await createChild({ child_name: childName.trim() })
Taro.showToast({ title: '添加成功', icon: 'success' })
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: childName.trim() })
await updateChild(editingChildId, { child_name: normalizedName })
Taro.showToast({ title: '修改成功', icon: 'success' })
}
setShowModal(false)
setChildName('')
setEditingChildId(null)
loadData()
} catch (err) {
Taro.showToast({ title: '操作失败', icon: 'none' })
handleCloseModal()
await loadData()
} catch (error: any) {
console.error('[manage] save child failed:', error)
Taro.showToast({
title: error?.message || '保存失败,请重试',
icon: 'none',
})
}
}
const handleModalCancel = () => {
setShowModal(false)
setChildName('')
setEditingChildId(null)
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: '确定要解除当前设备绑定吗?',
content: '确定要解除当前设备绑定吗?',
confirmColor: '#FF8C42',
success: async (res) => {
if (res.confirm) {
success: async (result) => {
if (!result.confirm) return
try {
await unbindDevice(binding.device_id)
Taro.showToast({ title: '已解绑', icon: 'success' })
setBinding(null)
loadData()
} catch (err) {
Taro.showToast({ title: '解绑失败', icon: 'none' })
}
}
await loadData()
} catch (error: any) {
Taro.showToast({
title: error?.message || '解绑失败,请重试',
icon: 'none',
})
}
},
})
}
const handleLogout = async () => {
const handleLogout = () => {
Taro.showModal({
title: '退出登录',
content: '确定要退出登录吗?',
content: '确定要退出当前账号吗?',
confirmColor: '#FF8C42',
success: (res) => {
if (res.confirm) {
Taro.removeStorageSync('token')
Taro.removeStorageSync('user_id')
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' })
return
}
} else if (item.name === '解除设备绑定') {
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 (
<View className='manage-page'>
<View className='loading'>
<Text>...</Text>
</View>
</View>
)
}
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 (
<View className='manage-page'>
<View className='page-header'>
@@ -214,24 +274,39 @@ export default function Sleep() {
<View className='user-card'>
<View className='user-avatar'>
{parentInfo.avatar_url ? (
<Image className='avatar-img' src={parentInfo.avatar_url} mode='aspectFill' />
<Image className='avatar-img image-avatar' src={parentInfo.avatar_url} mode='aspectFill' />
) : (
<Text className='avatar-img'>👤</Text>
)}
</View>
<View className='user-info'>
<Text className='user-name'>{parentInfo.nickname || '家长用户'}</Text>
<Text className='user-role'>ID: {userId}</Text>
<Text className='user-role'> ID: {userId || '--'}</Text>
</View>
<View className='verified-badge'>
<Text>{bindings.length > 0 ? `已绑定 ${bindings.length}` : '未绑定设备'}</Text>
</View>
</View>
<View className='summary-card'>
<View className='summary-row'>
<Text className='summary-label'></Text>
<Text className='summary-value'>{bindings.length} </Text>
</View>
<View className='summary-row'>
<Text className='summary-label'></Text>
<Text className='summary-value'>{binding?.device_id || '未绑定'}</Text>
</View>
<View className='summary-row'>
<Text className='summary-label'></Text>
<Text className='summary-value'>{currentChildName}</Text>
</View>
</View>
<View className='menu-card'>
{menuItems.map((item, index) => (
<View key={index}>
<View
className={`menu-item ${item.disabled ? 'disabled' : ''}`}
onClick={() => handleMenuClick(item)}
>
<View className={`menu-item ${item.disabled ? 'disabled' : ''}`} onClick={() => handleMenuClick(item)}>
<View className='menu-left'>
<View className={`icon-bg ${item.iconBgClass}`}>
<Image className='control-icon-img' src={item.icon} mode='aspectFit' />
@@ -239,9 +314,7 @@ export default function Sleep() {
<Text className='menu-name'>{item.name}</Text>
</View>
<View className='menu-right'>
{item.name.includes('儿童资料') && (
<Text className='menu-value'>{childDisplayValue}</Text>
)}
{item.value && <Text className='menu-value'>{item.value}</Text>}
{item.arrow && <Text className='arrow'></Text>}
</View>
</View>
@@ -260,25 +333,66 @@ export default function Sleep() {
<Text className='version-text'> Companion V1.1.0</Text>
</View>
{/* Modal */}
{showDeviceModal && (
<View className='modal-mask' onClick={() => setShowDeviceModal(false)}>
<View
className='modal-card device-switch-card'
onClick={(event) => {
event.stopPropagation()
}}
>
<Text className='modal-title'></Text>
{bindings.length === 0 ? (
<Text className='device-switch-empty'></Text>
) : (
<View className='device-switch-list'>
{bindings.map((item) => {
const isActive = binding?.device_id === item.device_id
return (
<View
key={item.device_id}
className={`device-switch-item ${isActive ? 'active' : ''}`}
onClick={() => handleSelectBinding(item)}
>
<View className='device-switch-head'>
<Text className='device-switch-id'>{item.device_id}</Text>
{isActive && <Text className='device-switch-tag'></Text>}
</View>
<Text className='device-switch-name'>{item.child_name || '待关联儿童'}</Text>
</View>
)
})}
</View>
)}
<View className='modal-actions'>
<Text className='modal-action cancel' onClick={() => setShowDeviceModal(false)}>
</Text>
</View>
</View>
</View>
)}
{showModal && (
<View className='modal-mask'>
<View className='modal-content'>
<View className='modal-header'>
<Text className='modal-title'>{modalType === 'add' ? '添加儿童' : '修改昵称'}</Text>
</View>
<View className='modal-body'>
<View className='modal-card'>
<Text className='modal-title'>{modalType === 'add' ? '新建儿童资料' : '修改儿童昵称'}</Text>
<View className='modal-input-wrap'>
<Input
className='modal-input'
placeholder='请输入儿童昵称'
value={childName}
onInput={(e) => setChildName(e.detail.value)}
focus={true}
placeholder='请输入儿童昵称'
focus
onInput={(event) => setChildName(event.detail.value)}
/>
</View>
<View className='modal-footer'>
<Text className='modal-btn cancel' onClick={handleModalCancel}></Text>
<Text className='modal-btn confirm' onClick={handleModalConfirm}></Text>
<View className='modal-actions'>
<Text className='modal-action cancel' onClick={handleCloseModal}>
</Text>
<Text className='modal-action confirm' onClick={handleSubmitModal}>
</Text>
</View>
</View>
</View>

View File

@@ -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, string | number | undefined | null>): 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<T extends { device_id: string }>(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<Binding | null> {
try {
return await request<Binding>('/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<BindStartResponse> {
return request<BindStartResponse>('/bindings/start', {
method: 'POST',
data,
export async function getBindings(cursor?: number, limit: number = 20): Promise<BindingListResponse> {
const query = buildQuery({
cursor,
limit,
})
return request<BindingListResponse>(`/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<Binding | null> {
// 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
return await request<Binding>(`/bindings/${deviceId}`)
} catch (error: any) {
if (error?.status === 404) return null
throw error
}
}
return request<Binding>(`/bindings/${deviceId}`)
}
// 解绑设备
export async function unbindDevice(deviceId: string): Promise<void> {
return request(`/bindings/${deviceId}`, {
method: 'DELETE',
})
export async function resolveActiveBinding(): Promise<Binding | null> {
const selectedDeviceId = getSelectedBindingDeviceId()
if (selectedDeviceId) {
const selectedBinding = await getBinding(selectedDeviceId)
if (selectedBinding) {
setSelectedBindingDeviceId(selectedBinding.device_id)
return selectedBinding
}
// 直接绑定设备(手动输入设备码)
export async function directBind(data: {
device_id: string
child_id?: number
}): Promise<{ device_id: string; child_id: number | null }> {
clearSelectedBindingDeviceId()
}
const currentBinding = await getCurrentBinding()
if (currentBinding?.device_id) {
setSelectedBindingDeviceId(currentBinding.device_id)
} else {
clearSelectedBindingDeviceId()
}
return currentBinding
}
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<BindHistoryResponse> {
const params = new URLSearchParams()
if (cursor) params.append('cursor', cursor)
params.append('limit', String(limit))
return request<BindHistoryResponse>(`/bindings/history/${deviceId}?${params.toString()}`)
export async function unbindDevice(deviceId: string): Promise<void> {
return request(`/bindings/${deviceId}`, {
method: 'DELETE',
})
}

View File

@@ -1,5 +1,12 @@
import { request } from './api'
function buildQuery(params: Record<string, string | number | undefined | null>): 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<ChildListResponse> {
const params = new URLSearchParams()
if (cursor) params.append('cursor', String(cursor))
params.append('limit', String(limit))
return request<ChildListResponse>(`/children?${params.toString()}`)
export async function getChildren(cursor?: number, limit: number = 20): Promise<ChildListResponse> {
const query = buildQuery({
cursor,
limit,
})
return request<ChildListResponse>(`/children?${query}`)
}
// 获取单个小孩信息
export async function getChild(childId: number): Promise<Child> {
return request<Child>(`/children/${childId}`)
}
// 更新小孩信息
export async function updateChild(
childId: number,
data: { child_name?: string; child_gender?: number; child_birthday?: string }