feat(binding): support qr scan and nfc card binding flow

This commit is contained in:
ChengCan
2026-04-30 00:58:56 +08:00
parent 25131adfa2
commit dfb532d883
9 changed files with 984 additions and 489 deletions

View File

@@ -218,6 +218,13 @@
color: #D46B08;
}
.pending-tip-subtext {
display: block;
margin-top: 8px;
font-size: 24px;
color: #8C5400;
}
.child-list {
display: flex;
flex-wrap: wrap;

View File

@@ -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>

View File

@@ -1,5 +1,7 @@
import Taro from '@tarojs/taro'
import { request } from './api'
import { Child, clearSelectedChildId, getChildren, resolveChildSelection, setSelectedChildId } from './child'
const SELECTED_BINDING_DEVICE_ID_KEY = 'selectedBindingDeviceId'
@@ -26,12 +28,38 @@ export interface BindingListResponse {
next_cursor?: number | null
}
export interface BindingChildContext {
currentChild: Child | null
currentBinding: Binding | null
}
export interface DirectBindPayload {
device_id: string
serial_number: string
child_id?: number
}
export interface NFCSessionStartPayload {
device_id: string
serial_number: string
child_id?: number
}
export interface NFCSessionStartResponse {
bind_token: string
expires_at: string
status: number
}
export interface NFCSessionStatus {
bind_token: string
device_id: string
child_id: number | null
status: number
expires_at: string
card_uuid?: string | null
}
export function getSelectedBindingDeviceId(): string | null {
const value = Taro.getStorageSync(SELECTED_BINDING_DEVICE_ID_KEY)
const deviceId = String(value || '').trim()
@@ -67,7 +95,7 @@ export function resolveBindingSelection<T extends { device_id: string }>(items:
export async function getCurrentBinding(): Promise<Binding | null> {
try {
return await request<Binding>('/bindings/current')
return await request<Binding>('/banban/bindings/current')
} catch (error: any) {
if (error?.status === 404) return null
throw error
@@ -75,16 +103,13 @@ export async function getCurrentBinding(): Promise<Binding | null> {
}
export async function getBindings(cursor?: number, limit: number = 20): Promise<BindingListResponse> {
const query = buildQuery({
cursor,
limit,
})
return request<BindingListResponse>(`/bindings?${query}`)
const query = buildQuery({ cursor, limit })
return request<BindingListResponse>(`/banban/bindings?${query}`)
}
export async function getBinding(deviceId: string): Promise<Binding | null> {
try {
return await request<Binding>(`/bindings/${deviceId}`)
return await request<Binding>(`/banban/bindings/${deviceId}`)
} catch (error: any) {
if (error?.status === 404) return null
throw error
@@ -100,7 +125,6 @@ export async function resolveActiveBinding(): Promise<Binding | null> {
setSelectedBindingDeviceId(selectedBinding.device_id)
return selectedBinding
}
clearSelectedBindingDeviceId()
}
@@ -115,24 +139,92 @@ export async function resolveActiveBinding(): Promise<Binding | null> {
}
export async function directBind(data: DirectBindPayload): Promise<{ device_id: string; child_id: number | null }> {
return request('/bindings/direct', {
return request('/banban/bindings/direct', { method: 'POST', data })
}
export async function startNFCBind(data: NFCSessionStartPayload): Promise<NFCSessionStartResponse> {
return request<NFCSessionStartResponse>('/banban/bindings/start', {
method: 'POST',
data,
})
}
export async function getNFCBindSession(bindToken: string): Promise<NFCSessionStatus> {
return request<NFCSessionStatus>(`/banban/bindings/sessions/${bindToken}`)
}
export async function setBindingChild(
deviceId: string,
data: { child_id: number }
): Promise<{ device_id: string; child_id: number | null }> {
return request(`/bindings/${deviceId}/child`, {
return request(`/banban/bindings/${deviceId}/child`, {
method: 'PATCH',
data,
})
}
export async function unbindDevice(deviceId: string): Promise<void> {
return request(`/bindings/${deviceId}`, {
return request(`/banban/bindings/${deviceId}`, {
method: 'DELETE',
})
}
export function resolveBindingForChild<T extends { child_id: number | null }>(
items: T[],
childId?: number | null
): T | null {
const normalizedChildId = Number(childId || 0)
if (!Number.isFinite(normalizedChildId) || normalizedChildId <= 0) return null
const childBindings = items.filter((item) => item.child_id === normalizedChildId)
if (childBindings.length === 0) return null
const selectedDeviceId = getSelectedBindingDeviceId()
if (selectedDeviceId) {
const selectedBinding = childBindings.find((item: any) => item.device_id === selectedDeviceId) || null
if (selectedBinding) return selectedBinding
}
return childBindings[0] || null
}
export function syncChildAndBindingSelection(children: Child[], bindings: Binding[]): BindingChildContext {
const currentChild = resolveChildSelection(children)
const currentBinding = currentChild ? resolveBindingForChild(bindings, currentChild.child_id) : null
if (currentChild) {
setSelectedChildId(currentChild.child_id)
} else {
clearSelectedChildId()
}
if (currentBinding?.device_id) {
setSelectedBindingDeviceId(currentBinding.device_id)
} else {
clearSelectedBindingDeviceId()
}
return {
currentChild,
currentBinding,
}
}
export async function loadCurrentChildBindingContext(): Promise<{
children: Child[]
bindings: Binding[]
currentChild: Child | null
currentBinding: Binding | null
}> {
const [childResponse, bindingResponse] = await Promise.all([getChildren(undefined, 100), getBindings(undefined, 100)])
const children = childResponse.items || []
const bindings = bindingResponse.items || []
const { currentChild, currentBinding } = syncChildAndBindingSelection(children, bindings)
return {
children,
bindings,
currentChild,
currentBinding,
}
}

View File

@@ -1,5 +1,8 @@
import Taro from '@tarojs/taro'
import { request } from './api'
const SELECTED_CHILD_ID_KEY = 'selectedChildId'
function buildQuery(params: Record<string, string | number | undefined | null>): string {
return Object.entries(params)
.filter(([, value]) => value !== undefined && value !== null && value !== '')
@@ -21,12 +24,44 @@ export interface ChildListResponse {
next_cursor?: number | null
}
export function getSelectedChildId(): number | null {
const value = Number(Taro.getStorageSync(SELECTED_CHILD_ID_KEY) || 0)
return Number.isFinite(value) && value > 0 ? value : null
}
export function setSelectedChildId(childId?: number | null) {
const normalizedChildId = Number(childId || 0)
if (!Number.isFinite(normalizedChildId) || normalizedChildId <= 0) {
Taro.removeStorageSync(SELECTED_CHILD_ID_KEY)
return
}
Taro.setStorageSync(SELECTED_CHILD_ID_KEY, normalizedChildId)
}
export function clearSelectedChildId() {
Taro.removeStorageSync(SELECTED_CHILD_ID_KEY)
}
export function resolveChildSelection<T extends { child_id: number }>(items: T[]): T | null {
const selectedChildId = getSelectedChildId()
const selectedChild = selectedChildId ? items.find((item) => item.child_id === selectedChildId) || null : null
const currentChild = selectedChild || items[0] || null
if (currentChild) {
setSelectedChildId(currentChild.child_id)
} else {
clearSelectedChildId()
}
return currentChild
}
export async function createChild(data: {
child_name: string
child_gender?: number
child_birthday?: string
}): Promise<Child> {
return request<Child>('/children', {
return request<Child>('/banban/children', {
method: 'POST',
data,
})
@@ -37,18 +72,18 @@ export async function getChildren(cursor?: number, limit: number = 20): Promise<
cursor,
limit,
})
return request<ChildListResponse>(`/children?${query}`)
return request<ChildListResponse>(`/banban/children?${query}`)
}
export async function getChild(childId: number): Promise<Child> {
return request<Child>(`/children/${childId}`)
return request<Child>(`/banban/children/${childId}`)
}
export async function updateChild(
childId: number,
data: { child_name?: string; child_gender?: number; child_birthday?: string }
): Promise<Child> {
return request<Child>(`/children/${childId}`, {
return request<Child>(`/banban/children/${childId}`, {
method: 'PATCH',
data,
})