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

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