feat(小程序): 支持多设备绑定与管理
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user