import { View, Text, Image, Input, Button } from '@tarojs/components' import { useEffect, useRef, useState } from 'react' import Taro, { useDidShow, useDidHide } from '@tarojs/taro' import { getToken } from '@/services/auth' 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: '' } 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>((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 [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([]) const [selectedChildId, setSelectedChildId] = useState(null) const [newChildName, setNewChildName] = useState('') const [pendingDeviceId, setPendingDeviceId] = useState(null) const [bindToken, setBindToken] = useState('') const [bindStatus, setBindStatus] = useState(null) const [cardUUID, setCardUUID] = useState('') const [bindHint, setBindHint] = useState('') const pollingRef = useRef | 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' }) return } setLoading(true) try { const [childResponse, activeBinding] = await Promise.all([getChildren(), resolveActiveBinding()]) const currentChildren = childResponse.items || [] const currentChild = resolveChildSelection(currentChildren) setChildren(currentChildren) setSelectedChildId(currentChild?.child_id || null) if (activeBinding?.device_id && !activeBinding.child_id) { setPendingDeviceId(activeBinding.device_id) setDeviceId(activeBinding.device_id) } else { setPendingDeviceId(null) } } catch (error: any) { console.error('[bind] load failed:', error) Taro.showToast({ title: error?.message || '加载失败,请稍后重试', icon: 'none', }) } finally { setLoading(false) } } const ensureChildId = async (): Promise => { if (selectedChildId) return selectedChildId const childName = newChildName.trim() if (!childName) return null 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 = () => { if (!canEditDeviceFields) { Taro.showToast({ title: '请先选择或新建儿童资料', icon: 'none', }) return } setIsScanning(true) Taro.scanCode({ onlyFromCamera: true, scanType: ['qrCode', 'barCode'], success: (res) => { setIsScanning(false) const parsed = parseBindingPayload(res.result || '') if (parsed.deviceId) setDeviceId(parsed.deviceId) if (parsed.serialNumber) setSerialNumber(parsed.serialNumber) Taro.showToast({ title: parsed.serialNumber ? '已填入设备信息' : '已识别设备号,请补充序列号', icon: 'none', }) }, fail: (err) => { setIsScanning(false) if (err.errMsg && err.errMsg.includes('cancel')) return Taro.showToast({ title: '扫码失败,请重试', icon: 'none', }) }, }) } const handleSubmit = async () => { if (submitting || isPollingBind) return setSubmitting(true) Taro.showLoading({ title: isPendingBinding ? '关联中...' : '发送绑卡指令...' }) try { const childId = await ensureChildId() if (!childId) { throw new Error('请先选择儿童或填写新的儿童昵称') } if (isPendingBinding) { await setBindingChild(pendingDeviceId!, { child_id: childId }) setSelectedBindingDeviceId(pendingDeviceId) Taro.showToast({ title: '关联完成', icon: 'success' }) setTimeout(() => { goDevicePage() }, 300) return } if (!deviceId.trim()) { throw new Error('请输入设备号') } if (!serialNumber.trim()) { throw new Error('请输入设备序列号') } resetBindSessionState() const session = await startNFCBind({ device_id: deviceId.trim(), serial_number: serialNumber.trim(), child_id: childId, }) 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', }) } finally { Taro.hideLoading() setSubmitting(false) } } if (loading) { return ( 加载中... ) } return ( {isPendingBinding ? '补全绑定' : '扫码贴卡绑定设备'} {isPendingBinding ? '当前设备已存在待补全关系,请先补充儿童资料' : '先选择儿童,再扫码设备,系统会下发绑卡指令,随后去设备上贴卡确认'} {!canEditDeviceFields ? '请先完成儿童资料' : isScanning ? '正在扫码...' : '点击扫码'} {!canEditDeviceFields ? '先选儿童,再扫设备二维码' : '扫描设备二维码或条码'} 儿童资料 {children.length > 0 && ( {children.map((child) => ( { setSelectedChildId((currentChildId) => { const nextChildId = currentChildId === child.child_id ? null : child.child_id setStoredSelectedChildId(nextChildId) return nextChildId }) setNewChildName('') }} > {child.child_name} ))} )} 新建儿童昵称 0 ? '不选已有儿童时,可填写新昵称' : '请输入儿童昵称'} onInput={(event) => { setNewChildName(event.detail.value) if (event.detail.value) { setSelectedChildId(null) setStoredSelectedChildId(null) } }} /> {children.length > 0 ? '可以选已有儿童,也可以在这里新建一个儿童后再绑定设备' : '当前还没有儿童资料,请先创建一个儿童资料'} 设备信息 设备号 setDeviceId(event.detail.value)} /> 设备序列号 setSerialNumber(event.detail.value)} /> {bindHint ? ( {bindHint} {cardUUID ? 卡片 UUID:{cardUUID} : null} ) : null} {isPendingBinding && ( 当前待补全设备:{pendingDeviceId} )} 绑定帮助 1 先选择已有儿童,或者在本页新建一个儿童 2 扫描设备二维码,确认设备号和序列号正确 3 点击发送绑卡指令,然后去设备上贴自己的卡完成确认 ) }