import { getPageStyle } from '@/utils/page-style' import { formatBatteryDisplay } from '@/utils/battery' import { View, Text, Image, Input, Button } from '@tarojs/components' import { useEffect, useRef, useState } from 'react' import Taro, { useDidShow, useDidHide, useRouter } from '@tarojs/taro' import { getToken } from '@/services/auth' import { DeviceOnlineStatus, DeviceStatus, getDeviceOnlineStatus, getDeviceStatus } from '@/services/device' import { getNFCBindSession, reportBindInitialLocation, resolveActiveBinding, setBindingChild, setSelectedBindingDeviceId, startNFCCardBind, startNFCBind, } from '@/services/binding' import { Child, CHILD_NAME_RULE_MESSAGE, createChild, getChildren, sanitizeChildNameInput, resolveChildSelection, setSelectedChildId as setStoredSelectedChildId, validateChildNameInput, } from '@/services/child' import { useSystemBanner } from '@/components/system-banner/use-system-banner' import { trafficPageUrl } from '@/services/traffic-binding' 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 const BIND_PAGE_URL = '/pages/bind/index' function safeDecodeURIComponent(value: string): string { try { return decodeURIComponent(value) } catch (error) { console.log('[bind] decode uri component failed:', error) return value } } function decodeQueryComponent(value: string): string { return safeDecodeURIComponent(value.replace(/\+/g, ' ')) } function decodeLaunchQrValue(value: string): string { let decoded = String(value || '').trim() for (let index = 0; index < 2; index += 1) { const nextDecoded = safeDecodeURIComponent(decoded).trim() if (nextDecoded === decoded) break decoded = nextDecoded } return decoded } function getRouteParam(params: Record | undefined, key: string): string { const value = params?.[key] if (Array.isArray(value)) return String(value[0] || '').trim() return String(value || '').trim() } function buildPageUrl(pagePath: string, params: Record | undefined): string { const query = Object.entries(params || {}) .filter(([key, value]) => key && value !== undefined && value !== null && value !== '') .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`) .join('&') return query ? `${pagePath}?${query}` : pagePath } function getLaunchQrPayload(params: Record | undefined): string { const qParam = getRouteParam(params, 'q') return qParam ? decodeLaunchQrValue(qParam) : '' } function normalizeExpiresAt(expiresAt: string): string { let normalized = String(expiresAt || '').trim() if (!normalized) return '' const hasTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized) if (!hasTimezone) normalized = `${normalized}Z` return normalized.replace(/\.(\d{3})\d+(Z|[+-]\d{2}:?\d{2})$/i, '.$1$2') } function getRemainingBindSeconds(expiresAt: string): number { const timestamp = Date.parse(normalizeExpiresAt(expiresAt)) if (!Number.isFinite(timestamp)) return 0 return Math.max(0, Math.ceil((timestamp - Date.now()) / 1000)) } function formatRemainingTime(totalSeconds: number): string { const safeSeconds = Math.max(0, totalSeconds) const minutes = Math.floor(safeSeconds / 60) const seconds = safeSeconds % 60 const minuteText = minutes < 10 ? `0${minutes}` : String(minutes) const secondText = seconds < 10 ? `0${seconds}` : String(seconds) return `${minuteText}:${secondText}` } 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[decodeQueryComponent(key)] = decodeQueryComponent(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 router = useRouter() const isAssignMode = String(router.params?.mode || '').trim() === 'assign' const isCardMode = String(router.params?.mode || '').trim() === 'card' const cardModeDeviceId = getRouteParam(router.params, 'device_id') const systemBanner = useSystemBanner() 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 [bindResultDestination, setBindResultDestination] = useState('/pages/device/index') const [bindExpiresAt, setBindExpiresAt] = useState('') const [remainingBindSeconds, setRemainingBindSeconds] = useState(0) const [totalBindSeconds, setTotalBindSeconds] = useState(0) const [boundDeviceId, setBoundDeviceId] = useState('') const [boundDeviceStatus, setBoundDeviceStatus] = useState(null) const [boundDeviceOnline, setBoundDeviceOnline] = useState(null) const [loadingBoundDevice, setLoadingBoundDevice] = useState(false) const pollingRef = useRef | null>(null) const pollGenerationRef = useRef(0) const pageVisibleRef = useRef(true) const countdownRef = useRef | null>(null) const appliedLaunchQrRef = useRef('') useDidShow(() => { pageVisibleRef.current = true void loadPageData() if (bindStatus === SESSION_STATUS_PENDING && bindToken) { schedulePoll(bindToken) } }) useDidHide(() => { pageVisibleRef.current = false stopPolling() }) useEffect(() => () => { pageVisibleRef.current = false stopPolling() stopCountdown() }, []) useEffect(() => { if (bindStatus !== SESSION_STATUS_COMPLETED || !boundDeviceId) return let cancelled = false setLoadingBoundDevice(true) void Promise.all([ getDeviceStatus(boundDeviceId).catch(() => null), getDeviceOnlineStatus(boundDeviceId).catch(() => null), ]).then(([status, online]) => { if (cancelled) return setBoundDeviceStatus(status) setBoundDeviceOnline(online) setLoadingBoundDevice(false) }) return () => { cancelled = true } }, [bindStatus, boundDeviceId]) const hasSelectedOrNewChild = selectedChildId !== null || Boolean(newChildName.trim()) const isPendingBinding = Boolean(pendingDeviceId) const isAddingCard = isCardMode && Boolean(deviceId.trim()) const isPollingBind = bindStatus === SESSION_STATUS_PENDING && Boolean(bindToken) const isExpiredBind = bindStatus === SESSION_STATUS_EXPIRED const canEditDeviceFields = !isPendingBinding && !isPollingBind const canSubmit = isPendingBinding ? hasSelectedOrNewChild : isExpiredBind ? true : !isPollingBind const pageTitle = isPendingBinding ? '补全绑定' : isAddingCard ? '添加设备卡片' : '扫码贴卡绑定设备' const goDevicePage = () => { Taro.reLaunch({ url: '/pages/device/index' }) } const goManagePage = () => { Taro.switchTab({ url: '/pages/sleep/index' }) } const uploadBindInitialLocation = async (targetDeviceId?: string | null) => { const normalizedDeviceId = String(targetDeviceId || '').trim() if (!normalizedDeviceId) return try { const location = await Taro.getLocation({ type: 'gcj02', isHighAccuracy: true, highAccuracyExpireTime: 3000, }) await reportBindInitialLocation(normalizedDeviceId, { latitude: location.latitude, longitude: location.longitude, coord_type: 'gcj02', }) } catch (error) { console.log('[bind] initial location skipped:', error) } } const handleBack = () => { if (getCurrentPages().length > 1) { Taro.navigateBack() return } Taro.switchTab({ url: '/pages/device/index' }) } const stopPolling = () => { pollGenerationRef.current += 1 if (pollingRef.current) { clearTimeout(pollingRef.current) pollingRef.current = null } } const stopCountdown = () => { if (countdownRef.current) { clearInterval(countdownRef.current) countdownRef.current = null } } const schedulePoll = (nextBindToken?: string) => { stopPolling() if (!pageVisibleRef.current) return const generation = pollGenerationRef.current pollingRef.current = setTimeout(() => { void pollBindSession(nextBindToken, generation) }, 1500) } const resetBindSessionState = () => { stopPolling() stopCountdown() setBindToken('') setBindStatus(null) setCardUUID('') setBindHint('') setBindExpiresAt('') setRemainingBindSeconds(0) setTotalBindSeconds(0) setBoundDeviceId('') setBoundDeviceStatus(null) setBoundDeviceOnline(null) } const applyLaunchQrPayload = () => { if (isAssignMode || isCardMode) return false const launchPayload = getLaunchQrPayload(router.params) if (!launchPayload || appliedLaunchQrRef.current === launchPayload) return false appliedLaunchQrRef.current = launchPayload const parsed = parseBindingPayload(launchPayload) if (!parsed.deviceId && !parsed.serialNumber) return false resetBindSessionState() if (parsed.deviceId) setDeviceId(parsed.deviceId) if (parsed.serialNumber) setSerialNumber(parsed.serialNumber) setBindHint( parsed.serialNumber ? '已读取设备二维码信息,请选择宝贝后发送绑卡指令' : '已读取设备号,请补充设备序列号' ) return true } const resetExpiredBindSession = (showToast = true) => { stopPolling() stopCountdown() setBindToken('') setBindStatus(SESSION_STATUS_EXPIRED) setCardUUID('') setBindExpiresAt('') setRemainingBindSeconds(0) if (!isCardMode) { setDeviceId('') setSerialNumber('') } setBindHint(isCardMode ? '贴卡时间已过,请重新发送加卡指令' : '贴卡时间已过,请重新扫码并贴卡') if (showToast) { Taro.showToast({ title: isCardMode ? '贴卡已超时,请重新发送' : '贴卡已超时,请重新扫码', icon: 'none' }) } } useEffect(() => { if (bindStatus !== SESSION_STATUS_PENDING || !bindExpiresAt) { stopCountdown() return } const updateRemainingTime = () => { const nextRemainingSeconds = getRemainingBindSeconds(bindExpiresAt) setRemainingBindSeconds(nextRemainingSeconds) if (nextRemainingSeconds <= 0) { resetExpiredBindSession() } } updateRemainingTime() stopCountdown() countdownRef.current = setInterval(updateRemainingTime, 1000) return () => stopCountdown() }, [bindStatus, bindExpiresAt]) const pollBindSession = async (nextBindToken?: string, generation = pollGenerationRef.current) => { const activeBindToken = (nextBindToken || bindToken).trim() if (!activeBindToken || !pageVisibleRef.current || generation !== pollGenerationRef.current) return try { const session = await getNFCBindSession(activeBindToken) if (!pageVisibleRef.current || generation !== pollGenerationRef.current) return setBindStatus(session.status) setCardUUID(session.card_uuid || '') setBindExpiresAt(session.expires_at || '') if (session.status === SESSION_STATUS_PENDING) { if (getRemainingBindSeconds(session.expires_at) <= 0) { resetExpiredBindSession() return } setBindHint(isAddingCard ? '已发送加卡指令,请在倒计时结束前去设备上贴新卡' : '已发送绑卡指令,请在倒计时结束前去设备上贴自己的卡') schedulePoll(activeBindToken) return } stopPolling() stopCountdown() if (session.status === SESSION_STATUS_COMPLETED) { setBoundDeviceId(session.device_id) setSelectedBindingDeviceId(session.device_id) if (!isAddingCard && session.child_id) { void uploadBindInitialLocation(session.device_id) } setBindHint( isAddingCard ? session.card_uuid ? `新卡添加完成,卡号 ${session.card_uuid}` : '新卡添加完成' : session.child_id ? session.card_uuid ? `绑定完成,卡号 ${session.card_uuid}` : '绑定完成' : session.card_uuid ? `绑定完成,卡号 ${session.card_uuid},稍后可关联宝贝` : '绑定完成,稍后可关联宝贝' ) Taro.showToast({ title: isAddingCard ? '新卡添加成功' : '设备绑定成功', icon: 'success' }) setBindResultDestination(isAddingCard || session.child_id ? '/pages/device/index' : '/pages/sleep/index') return } if (session.status === SESSION_STATUS_EXPIRED) { resetExpiredBindSession(false) return } if (session.status === SESSION_STATUS_FAILED) { setBindHint(isAddingCard ? '添加新卡失败,请确认卡未绑定到其他设备后重试' : '贴卡绑定失败,请重试') return } if (session.status === SESSION_STATUS_CANCELLED) { setBindHint('当前绑定已被新的绑定流程替代,请重新扫码') return } } catch (error: any) { if (!pageVisibleRef.current || generation !== pollGenerationRef.current) return console.error('[bind] poll bind session failed:', error) setBindHint('暂时无法查询绑定结果,正在重试,请稍候') schedulePoll(activeBindToken) } } const loadPageData = async () => { if (!getToken()) { Taro.setStorageSync('postLoginRedirect', buildPageUrl(BIND_PAGE_URL, router.params)) 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 (isCardMode) { const targetDeviceId = cardModeDeviceId || activeBinding?.device_id || '' if (!targetDeviceId) { throw new Error('当前没有可加卡的设备') } setPendingDeviceId(null) setDeviceId(targetDeviceId) setSerialNumber('') setBindHint('确认当前设备后发送加卡指令') } else if (isAssignMode && activeBinding?.device_id && !activeBinding.child_id) { setPendingDeviceId(activeBinding.device_id) setDeviceId(activeBinding.device_id) } else { setPendingDeviceId(null) applyLaunchQrPayload() } } 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 childNameError = validateChildNameInput(childName) if (childNameError) { throw new Error(childNameError) } 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: isPollingBind ? '绑定确认中,请稍候' : '当前不能扫码', icon: 'none', }) return } setIsScanning(true) Taro.scanCode({ onlyFromCamera: true, scanType: ['qrCode', 'barCode'], success: (res) => { setIsScanning(false) console.info('[bind] scanCode success response:', res) console.info('[bind] scanCode raw result:', res.result || '') const parsed = parseBindingPayload(res.result || '') console.info('[bind] scanCode parsed payload:', parsed) resetBindSessionState() if (parsed.deviceId) setDeviceId(parsed.deviceId) if (parsed.serialNumber) setSerialNumber(parsed.serialNumber) Taro.showToast({ title: parsed.serialNumber ? '已填入设备信息' : '已识别设备号,请补充序列号', icon: 'none', }) }, fail: (err) => { setIsScanning(false) console.warn('[bind] scanCode failed response:', err) 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 ? '关联中...' : isAddingCard ? '发送加卡指令...' : '发送绑卡指令...' }) try { const childId = isAddingCard ? null : await ensureChildId() if (isPendingBinding) { if (!childId) { throw new Error('请先选择宝贝或填写新的宝贝姓名') } await setBindingChild(pendingDeviceId!, { child_id: childId }) setSelectedBindingDeviceId(pendingDeviceId) void uploadBindInitialLocation(pendingDeviceId) Taro.showToast({ title: '关联完成', icon: 'success' }) setTimeout(() => { goDevicePage() }, 300) return } if (!deviceId.trim()) { throw new Error('请输入设备号') } if (isAddingCard) { resetBindSessionState() const session = await startNFCCardBind({ device_id: deviceId.trim() }) setBindToken(session.bind_token) setBindStatus(session.status) setBindExpiresAt(session.expires_at) setRemainingBindSeconds(getRemainingBindSeconds(session.expires_at)) setTotalBindSeconds(getRemainingBindSeconds(session.expires_at)) setBindHint('已发送加卡指令,请在倒计时结束前去设备上贴新卡') schedulePoll(session.bind_token) Taro.showToast({ title: '请去设备上贴新卡', icon: 'none' }) return } if (!serialNumber.trim()) { throw new Error('请输入设备序列号') } resetBindSessionState() const bindPayload = { device_id: deviceId.trim(), serial_number: serialNumber.trim(), } const session = await startNFCBind(childId ? { ...bindPayload, child_id: childId } : bindPayload) setBindToken(session.bind_token) setBindStatus(session.status) setBindExpiresAt(session.expires_at) setRemainingBindSeconds(getRemainingBindSeconds(session.expires_at)) setTotalBindSeconds(getRemainingBindSeconds(session.expires_at)) setBindHint(childId ? '已发送绑卡指令,请在倒计时结束前去设备上贴自己的卡' : '已发送绑卡指令,贴卡后设备会先进入待关联状态') schedulePoll(session.bind_token) 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 ( 加载中... {systemBanner} ) } const sessionVisible = bindStatus !== null && [ SESSION_STATUS_PENDING, SESSION_STATUS_COMPLETED, SESSION_STATUS_EXPIRED, SESSION_STATUS_FAILED, SESSION_STATUS_CANCELLED, ].includes(bindStatus) if (sessionVisible) { const completed = bindStatus === SESSION_STATUS_COMPLETED const pending = bindStatus === SESSION_STATUS_PENDING const countdownAngle = totalBindSeconds > 0 ? Math.max(0, Math.min(360, remainingBindSeconds / totalBindSeconds * 360)) : 0 const resultBattery = formatBatteryDisplay(boundDeviceStatus?.power ?? boundDeviceStatus?.battery_pct) const title = pending ? '请在设备上贴卡' : completed ? isAddingCard ? '新卡添加完成' : '设备绑定成功' : isExpiredBind ? '贴卡时间已过' : '贴卡未完成' return ( 返回 {pending ? ( <> {title} {formatRemainingTime(remainingBindSeconds)} 剩余贴卡时间 {isAddingCard ? '加卡指令已发送' : '绑卡指令已发送'} {bindHint} ) : ( <> {title} {bindHint} {completed ? ( <> 设备状态 {loadingBoundDevice ? '查询中' : boundDeviceOnline ? boundDeviceOnline.online ? '在线' : '离线' : '--'} 当前电量 {loadingBoundDevice ? '查询中' : `${resultBattery.text}${resultBattery.showPercent ? '%' : ''}`} {!isAddingCard && } {!isAddingCard && 设备已绑定。流量查询和充值需另外关联,可稍后在管理页完成。} ) : ( <> )} )} {systemBanner} ) } return ( 返回 {pageTitle} {isAddingCard ? ( 给当前设备添加卡片 {deviceId} 新卡添加后会和已有卡具备相同功能 ) : isPendingBinding ? ( 关联现有设备 {pendingDeviceId} 选择已有宝贝,或新建宝贝后完成关联 ) : ( {isScanning ? '正在扫码...' : '点击扫码'} 扫描设备二维码或条码 )} {!isAddingCard && ( 宝贝资料 {children.length > 0 && ( {!isPendingBinding && ( { setSelectedChildId(null) setStoredSelectedChildId(null) setNewChildName('') }} > 暂不关联宝贝 )} {children.map((child) => ( { setSelectedChildId((currentChildId) => { const nextChildId = currentChildId === child.child_id ? null : child.child_id setStoredSelectedChildId(nextChildId) return nextChildId }) setNewChildName('') }} > {child.child_name} ))} )} {isPendingBinding ? '新建宝贝姓名' : '新建宝贝姓名(可选)'} 0 ? '不选已有宝贝时,可填写2-5位简体中文姓名(暂不支持数字和英文)' : CHILD_NAME_RULE_MESSAGE : children.length > 0 ? '不选已有宝贝时,可填写2-5位简体中文姓名(暂不支持数字和英文)' : '可不填,稍后再关联宝贝' } maxlength={5} onInput={(event) => { const nextChildName = sanitizeChildNameInput(event.detail.value) setNewChildName(nextChildName) if (nextChildName) { setSelectedChildId(null) setStoredSelectedChildId(null) } }} /> {isPendingBinding ? children.length > 0 ? '可以选已有宝贝,也可以新建一个宝贝后关联当前设备' : '当前还没有宝贝资料,请先创建一个宝贝资料后关联设备' : children.length > 0 ? '宝贝资料可选;不选择时,设备会先绑定到账号,稍后可关联宝贝' : '也可以先不创建宝贝,直接绑定设备,稍后再关联宝贝'} )} {!isPendingBinding && ( {isAddingCard ? '加卡状态' : '设备信息'} 设备号 setDeviceId(event.detail.value)} /> {!isAddingCard && ( 设备序列号 setSerialNumber(event.detail.value)} /> )} {bindHint ? ( {bindHint} {isPollingBind ? ( 剩余贴卡时间:{formatRemainingTime(remainingBindSeconds)} ) : null} {cardUUID ? 卡片 UUID:{cardUUID} : null} ) : null} {isPendingBinding && ( 当前待补全设备:{pendingDeviceId} )} )} 绑定帮助 1 {isAddingCard ? '确认当前设备号正确' : isPendingBinding ? '先选择已有宝贝,或者在本页新建一个宝贝' : '宝贝资料可选,不选择时设备会先进入待关联状态'} 2 {isAddingCard ? '点击发送加卡指令后,在设备上贴新卡' : isPendingBinding ? '确认当前待关联设备,再选择要关联的宝贝' : '扫描设备二维码,确认设备号和序列号正确'} 3 {isAddingCard ? '加卡成功后,新卡和原来的卡可以执行相同功能' : isPendingBinding ? '点击完成宝贝关联,设备会出现在该宝贝下面' : '点击发送绑卡指令,然后在 10 分钟内去设备上贴自己的卡完成确认'} {systemBanner} ) }