feat(binding): support qr scan and nfc card binding flow
This commit is contained in:
@@ -1,11 +1,31 @@
|
||||
import { View, Text, Image, Input, Button } from '@tarojs/components'
|
||||
import { useState } from 'react'
|
||||
import Taro, { useDidShow } from '@tarojs/taro'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Taro, { useDidShow, useDidHide } from '@tarojs/taro'
|
||||
|
||||
import { getToken } from '@/services/auth'
|
||||
import { directBind, resolveActiveBinding, setBindingChild, setSelectedBindingDeviceId } from '@/services/binding'
|
||||
import { Child, createChild, getChildren } from '@/services/child'
|
||||
import {
|
||||
getNFCBindSession,
|
||||
resolveActiveBinding,
|
||||
setBindingChild,
|
||||
setSelectedBindingDeviceId,
|
||||
startNFCBind,
|
||||
} from '@/services/binding'
|
||||
import {
|
||||
Child,
|
||||
createChild,
|
||||
getChildren,
|
||||
resolveChildSelection,
|
||||
setSelectedChildId as setStoredSelectedChildId,
|
||||
} from '@/services/child'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
const SESSION_STATUS_PENDING = 1
|
||||
const SESSION_STATUS_COMPLETED = 2
|
||||
const SESSION_STATUS_EXPIRED = 3
|
||||
const SESSION_STATUS_FAILED = 4
|
||||
const SESSION_STATUS_CANCELLED = 5
|
||||
|
||||
function parseBindingPayload(rawValue: string): { deviceId: string; serialNumber: string } {
|
||||
const raw = String(rawValue || '').trim()
|
||||
if (!raw) return { deviceId: '', serialNumber: '' }
|
||||
@@ -71,15 +91,99 @@ export default function Bind() {
|
||||
const [selectedChildId, setSelectedChildId] = useState<number | null>(null)
|
||||
const [newChildName, setNewChildName] = useState('')
|
||||
const [pendingDeviceId, setPendingDeviceId] = useState<string | null>(null)
|
||||
const [bindToken, setBindToken] = useState('')
|
||||
const [bindStatus, setBindStatus] = useState<number | null>(null)
|
||||
const [cardUUID, setCardUUID] = useState('')
|
||||
const [bindHint, setBindHint] = useState('')
|
||||
const pollingRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useDidShow(() => {
|
||||
void loadPageData()
|
||||
})
|
||||
|
||||
useDidHide(() => {
|
||||
stopPolling()
|
||||
})
|
||||
|
||||
useEffect(() => () => stopPolling(), [])
|
||||
|
||||
const hasSelectedOrNewChild = selectedChildId !== null || Boolean(newChildName.trim())
|
||||
const isPendingBinding = Boolean(pendingDeviceId)
|
||||
const isPollingBind = bindStatus === SESSION_STATUS_PENDING && Boolean(bindToken)
|
||||
const canEditDeviceFields = hasSelectedOrNewChild && !isPollingBind
|
||||
|
||||
const goDevicePage = () => {
|
||||
Taro.reLaunch({ url: '/pages/device/index' })
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollingRef.current) {
|
||||
clearTimeout(pollingRef.current)
|
||||
pollingRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const schedulePoll = () => {
|
||||
stopPolling()
|
||||
pollingRef.current = setTimeout(() => {
|
||||
void pollBindSession()
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
const resetBindSessionState = () => {
|
||||
stopPolling()
|
||||
setBindToken('')
|
||||
setBindStatus(null)
|
||||
setCardUUID('')
|
||||
setBindHint('')
|
||||
}
|
||||
|
||||
const pollBindSession = async () => {
|
||||
if (!bindToken) return
|
||||
|
||||
try {
|
||||
const session = await getNFCBindSession(bindToken)
|
||||
setBindStatus(session.status)
|
||||
setCardUUID(session.card_uuid || '')
|
||||
|
||||
if (session.status === SESSION_STATUS_PENDING) {
|
||||
setBindHint('已发送绑卡指令,请拿自己的卡去设备上贴一下')
|
||||
schedulePoll()
|
||||
return
|
||||
}
|
||||
|
||||
stopPolling()
|
||||
|
||||
if (session.status === SESSION_STATUS_COMPLETED) {
|
||||
setSelectedBindingDeviceId(session.device_id)
|
||||
setBindHint(session.card_uuid ? `绑定完成,卡号 ${session.card_uuid}` : '绑定完成')
|
||||
Taro.showToast({ title: '设备绑定成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
goDevicePage()
|
||||
}, 500)
|
||||
return
|
||||
}
|
||||
|
||||
if (session.status === SESSION_STATUS_EXPIRED) {
|
||||
setBindHint('绑定会话已过期,请重新扫码并贴卡')
|
||||
return
|
||||
}
|
||||
|
||||
if (session.status === SESSION_STATUS_FAILED) {
|
||||
setBindHint('贴卡绑定失败,请重试')
|
||||
return
|
||||
}
|
||||
|
||||
if (session.status === SESSION_STATUS_CANCELLED) {
|
||||
setBindHint('当前绑定已被新的绑定流程替代,请重新扫码')
|
||||
return
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('[bind] poll bind session failed:', error)
|
||||
setBindHint(error?.message || '查询绑定状态失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
const loadPageData = async () => {
|
||||
if (!getToken()) {
|
||||
Taro.reLaunch({ url: '/pages/login/index' })
|
||||
@@ -90,17 +194,15 @@ export default function Bind() {
|
||||
try {
|
||||
const [childResponse, activeBinding] = await Promise.all([getChildren(), resolveActiveBinding()])
|
||||
const currentChildren = childResponse.items || []
|
||||
|
||||
const currentChild = resolveChildSelection(currentChildren)
|
||||
setChildren(currentChildren)
|
||||
setSelectedChildId((currentSelectedChildId) => currentSelectedChildId || currentChildren[0]?.child_id || null)
|
||||
setSelectedChildId(currentChild?.child_id || null)
|
||||
|
||||
if (activeBinding?.device_id && !activeBinding.child_id) {
|
||||
setPendingDeviceId(activeBinding.device_id)
|
||||
setDeviceId(activeBinding.device_id)
|
||||
} else {
|
||||
setPendingDeviceId(null)
|
||||
setDeviceId('')
|
||||
setSerialNumber('')
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('[bind] load failed:', error)
|
||||
@@ -122,13 +224,21 @@ export default function Bind() {
|
||||
const child = await createChild({ child_name: childName })
|
||||
setChildren((currentChildren) => [child, ...currentChildren])
|
||||
setSelectedChildId(child.child_id)
|
||||
setStoredSelectedChildId(child.child_id)
|
||||
setNewChildName('')
|
||||
return child.child_id
|
||||
}
|
||||
|
||||
const handleScanCode = () => {
|
||||
setIsScanning(true)
|
||||
if (!canEditDeviceFields) {
|
||||
Taro.showToast({
|
||||
title: '请先选择或新建儿童资料',
|
||||
icon: 'none',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsScanning(true)
|
||||
Taro.scanCode({
|
||||
onlyFromCamera: true,
|
||||
scanType: ['qrCode', 'barCode'],
|
||||
@@ -145,11 +255,7 @@ export default function Bind() {
|
||||
},
|
||||
fail: (err) => {
|
||||
setIsScanning(false)
|
||||
|
||||
if (err.errMsg && err.errMsg.includes('cancel')) {
|
||||
return
|
||||
}
|
||||
|
||||
if (err.errMsg && err.errMsg.includes('cancel')) return
|
||||
Taro.showToast({
|
||||
title: '扫码失败,请重试',
|
||||
icon: 'none',
|
||||
@@ -159,22 +265,21 @@ export default function Bind() {
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (submitting) return
|
||||
if (submitting || isPollingBind) return
|
||||
|
||||
setSubmitting(true)
|
||||
Taro.showLoading({ title: pendingDeviceId ? '关联中...' : '绑定中...' })
|
||||
Taro.showLoading({ title: isPendingBinding ? '关联中...' : '发送绑卡指令...' })
|
||||
|
||||
try {
|
||||
const childId = await ensureChildId()
|
||||
if (!childId) {
|
||||
throw new Error('请先选择儿童或填写新的儿童昵称')
|
||||
}
|
||||
|
||||
if (pendingDeviceId) {
|
||||
if (!childId) {
|
||||
throw new Error('请选择儿童或填写新儿童昵称')
|
||||
}
|
||||
|
||||
await setBindingChild(pendingDeviceId, { child_id: childId })
|
||||
if (isPendingBinding) {
|
||||
await setBindingChild(pendingDeviceId!, { child_id: childId })
|
||||
setSelectedBindingDeviceId(pendingDeviceId)
|
||||
Taro.showToast({ title: '绑定完成', icon: 'success' })
|
||||
Taro.showToast({ title: '关联完成', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
goDevicePage()
|
||||
}, 300)
|
||||
@@ -188,30 +293,21 @@ export default function Bind() {
|
||||
throw new Error('请输入设备序列号')
|
||||
}
|
||||
|
||||
const result = await directBind({
|
||||
resetBindSessionState()
|
||||
const session = await startNFCBind({
|
||||
device_id: deviceId.trim(),
|
||||
serial_number: serialNumber.trim(),
|
||||
...(childId ? { child_id: 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',
|
||||
})
|
||||
setBindToken(session.bind_token)
|
||||
setBindStatus(session.status)
|
||||
setBindHint('已发送绑卡指令,请去设备上贴自己的卡')
|
||||
schedulePoll()
|
||||
Taro.showToast({ title: '请去设备上贴卡', icon: 'none' })
|
||||
} catch (error: any) {
|
||||
console.error('[bind] submit failed:', error)
|
||||
setBindHint(error?.message || '绑定失败,请重试')
|
||||
Taro.showToast({
|
||||
title: error?.message || '绑定失败,请重试',
|
||||
icon: 'none',
|
||||
@@ -235,23 +331,25 @@ export default function Bind() {
|
||||
return (
|
||||
<View className='bind-page'>
|
||||
<View className='bind-header'>
|
||||
<Text className='title'>{pendingDeviceId ? '补全绑定' : '绑定设备'}</Text>
|
||||
<Text className='title'>{isPendingBinding ? '补全绑定' : '扫码贴卡绑定设备'}</Text>
|
||||
<Text className='subtitle'>
|
||||
{pendingDeviceId ? '设备已绑到当前账号,请补充儿童资料完成配置' : '扫描二维码或手动填写设备号和序列号'}
|
||||
{isPendingBinding
|
||||
? '当前设备已存在待补全关系,请先补充儿童资料'
|
||||
: '先选择儿童,再扫码设备,系统会下发绑卡指令,随后去设备上贴卡确认'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className='scan-area'>
|
||||
<View className='scan-frame' onClick={handleScanCode}>
|
||||
<View className='icon-bg orange'>
|
||||
<Image
|
||||
className='control-icon-img'
|
||||
src={require('../../assets/tab-icons/rings.png')}
|
||||
mode='aspectFit'
|
||||
/>
|
||||
<Image className='control-icon-img' src={require('../../assets/tab-icons/rings.png')} mode='aspectFit' />
|
||||
</View>
|
||||
<Text className='scan-text'>{isScanning ? '正在扫码...' : '点击扫码'}</Text>
|
||||
<Text className='scan-hint'>请将二维码放入框内</Text>
|
||||
<Text className='scan-text'>
|
||||
{!canEditDeviceFields ? '请先完成儿童资料' : isScanning ? '正在扫码...' : '点击扫码'}
|
||||
</Text>
|
||||
<Text className='scan-hint'>
|
||||
{!canEditDeviceFields ? '先选儿童,再扫设备二维码' : '扫描设备二维码或条码'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className='scan-corners'>
|
||||
@@ -264,39 +362,7 @@ export default function Bind() {
|
||||
|
||||
<View className='bind-form'>
|
||||
<View className='form-card'>
|
||||
<Text className='form-title'>设备信息</Text>
|
||||
|
||||
<View className='field-item'>
|
||||
<Text className='field-label'>设备号</Text>
|
||||
<Input
|
||||
className='field-input'
|
||||
value={deviceId}
|
||||
disabled={!!pendingDeviceId}
|
||||
placeholder='请输入设备号'
|
||||
onInput={(event) => setDeviceId(event.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='field-item'>
|
||||
<Text className='field-label'>设备序列号</Text>
|
||||
<Input
|
||||
className='field-input'
|
||||
value={serialNumber}
|
||||
disabled={!!pendingDeviceId}
|
||||
placeholder='请输入设备序列号'
|
||||
onInput={(event) => setSerialNumber(event.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{pendingDeviceId && (
|
||||
<View className='pending-tip'>
|
||||
<Text className='pending-tip-text'>当前待完成设备:{pendingDeviceId}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className='form-card'>
|
||||
<Text className='form-title'>儿童信息</Text>
|
||||
<Text className='form-title'>儿童资料</Text>
|
||||
|
||||
{children.length > 0 && (
|
||||
<View className='child-list'>
|
||||
@@ -305,7 +371,11 @@ export default function Bind() {
|
||||
key={child.child_id}
|
||||
className={`child-item ${selectedChildId === child.child_id ? 'selected' : ''}`}
|
||||
onClick={() => {
|
||||
setSelectedChildId((currentChildId) => (currentChildId === child.child_id ? null : child.child_id))
|
||||
setSelectedChildId((currentChildId) => {
|
||||
const nextChildId = currentChildId === child.child_id ? null : child.child_id
|
||||
setStoredSelectedChildId(nextChildId)
|
||||
return nextChildId
|
||||
})
|
||||
setNewChildName('')
|
||||
}}
|
||||
>
|
||||
@@ -325,16 +395,65 @@ export default function Bind() {
|
||||
setNewChildName(event.detail.value)
|
||||
if (event.detail.value) {
|
||||
setSelectedChildId(null)
|
||||
setStoredSelectedChildId(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text className='field-hint'>优先选择已有儿童,也可以直接输入一个新昵称。</Text>
|
||||
<Text className='field-hint'>
|
||||
{children.length > 0
|
||||
? '可以选已有儿童,也可以在这里新建一个儿童后再绑定设备'
|
||||
: '当前还没有儿童资料,请先创建一个儿童资料'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Button className='submit-btn' loading={submitting} disabled={submitting} onClick={handleSubmit}>
|
||||
{pendingDeviceId ? '完成儿童关联' : '绑定设备'}
|
||||
<View className='form-card'>
|
||||
<Text className='form-title'>设备信息</Text>
|
||||
|
||||
<View className='field-item'>
|
||||
<Text className='field-label'>设备号</Text>
|
||||
<Input
|
||||
className='field-input'
|
||||
value={deviceId}
|
||||
disabled={!canEditDeviceFields || isPendingBinding}
|
||||
placeholder={canEditDeviceFields ? '请输入设备号' : '请先选择儿童资料'}
|
||||
onInput={(event) => setDeviceId(event.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className='field-item'>
|
||||
<Text className='field-label'>设备序列号</Text>
|
||||
<Input
|
||||
className='field-input'
|
||||
value={serialNumber}
|
||||
disabled={!canEditDeviceFields || isPendingBinding}
|
||||
placeholder={canEditDeviceFields ? '请输入设备序列号' : '请先选择儿童资料'}
|
||||
onInput={(event) => setSerialNumber(event.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{bindHint ? (
|
||||
<View className='pending-tip'>
|
||||
<Text className='pending-tip-text'>{bindHint}</Text>
|
||||
{cardUUID ? <Text className='pending-tip-subtext'>卡片 UUID:{cardUUID}</Text> : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{isPendingBinding && (
|
||||
<View className='pending-tip'>
|
||||
<Text className='pending-tip-text'>当前待补全设备:{pendingDeviceId}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Button
|
||||
className='submit-btn'
|
||||
loading={submitting}
|
||||
disabled={submitting || !hasSelectedOrNewChild || isPollingBind}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isPendingBinding ? '完成儿童关联' : isPollingBind ? '等待贴卡确认' : '发送绑卡指令'}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
@@ -342,15 +461,15 @@ export default function Bind() {
|
||||
<Text className='tips-title'>绑定帮助</Text>
|
||||
<View className='tip-item'>
|
||||
<Text className='tip-number'>1</Text>
|
||||
<Text className='tip-text'>先扫描二维码,自动填入设备号和序列号。</Text>
|
||||
<Text className='tip-text'>先选择已有儿童,或者在本页新建一个儿童</Text>
|
||||
</View>
|
||||
<View className='tip-item'>
|
||||
<Text className='tip-number'>2</Text>
|
||||
<Text className='tip-text'>如果二维码里只有设备号,请手动补充序列号。</Text>
|
||||
<Text className='tip-text'>扫描设备二维码,确认设备号和序列号正确</Text>
|
||||
</View>
|
||||
<View className='tip-item'>
|
||||
<Text className='tip-number'>3</Text>
|
||||
<Text className='tip-text'>没有儿童资料时,可以直接在本页新建并完成绑定。</Text>
|
||||
<Text className='tip-text'>点击发送绑卡指令,然后去设备上贴自己的卡完成确认</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
Reference in New Issue
Block a user