Files
banban/banban-mini/src/pages/bind/index.tsx

478 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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 [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 [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' })
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<number | null> => {
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 (
<View className='bind-page'>
<View className='loading'>
<Text>...</Text>
</View>
</View>
)
}
return (
<View className='bind-page'>
<View className='bind-header'>
<Text className='title'>{isPendingBinding ? '补全绑定' : '扫码贴卡绑定设备'}</Text>
<Text className='subtitle'>
{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' />
</View>
<Text className='scan-text'>
{!canEditDeviceFields ? '请先完成儿童资料' : isScanning ? '正在扫码...' : '点击扫码'}
</Text>
<Text className='scan-hint'>
{!canEditDeviceFields ? '先选儿童,再扫设备二维码' : '扫描设备二维码或条码'}
</Text>
</View>
<View className='scan-corners'>
<View className='corner top-left'></View>
<View className='corner top-right'></View>
<View className='corner bottom-left'></View>
<View className='corner bottom-right'></View>
</View>
</View>
<View className='bind-form'>
<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) => {
const nextChildId = currentChildId === child.child_id ? null : child.child_id
setStoredSelectedChildId(nextChildId)
return nextChildId
})
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)
setStoredSelectedChildId(null)
}
}}
/>
</View>
<Text className='field-hint'>
{children.length > 0
? '可以选已有儿童,也可以在这里新建一个儿童后再绑定设备'
: '当前还没有儿童资料,请先创建一个儿童资料'}
</Text>
</View>
<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>
<View className='bind-tips'>
<Text className='tips-title'></Text>
<View className='tip-item'>
<Text className='tip-number'>1</Text>
<Text className='tip-text'></Text>
</View>
<View className='tip-item'>
<Text className='tip-number'>2</Text>
<Text className='tip-text'></Text>
</View>
<View className='tip-item'>
<Text className='tip-number'>3</Text>
<Text className='tip-text'></Text>
</View>
</View>
</View>
)
}