478 lines
15 KiB
TypeScript
478 lines
15 KiB
TypeScript
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>
|
||
)
|
||
}
|