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