feat(小程序): 支持多设备绑定与管理
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
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<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' })
|
||||
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 (
|
||||
<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>
|
||||
</View>
|
||||
<View className='bind-form'>
|
||||
<View className='form-card'>
|
||||
<Text className='form-title'>设备信息</Text>
|
||||
|
||||
{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'>
|
||||
<View className='field-item'>
|
||||
<Text className='field-label'>设备号</Text>
|
||||
<Input
|
||||
className='empty-input'
|
||||
placeholder='Enter child name'
|
||||
value={newChildName}
|
||||
onInput={(e) => setNewChildName(e.detail.value)}
|
||||
className='field-input'
|
||||
value={deviceId}
|
||||
disabled={!!pendingDeviceId}
|
||||
placeholder='请输入设备号'
|
||||
onInput={(event) => setDeviceId(event.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
<View className='empty-btn' onClick={handleAddChild}>
|
||||
<Text className='empty-btn-text'>{saving ? 'Saving...' : 'Create and link child'}</Text>
|
||||
|
||||
<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='pending-tip'>
|
||||
<Text className='pending-tip-text'>当前待完成设备:{pendingDeviceId}</Text>
|
||||
</View>
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
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 (
|
||||
<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'>
|
||||
<Input
|
||||
<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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
try {
|
||||
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
|
||||
}
|
||||
|
||||
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<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',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -52,4 +52,4 @@ export async function updateChild(
|
||||
method: 'PATCH',
|
||||
data,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user