910 lines
35 KiB
TypeScript
910 lines
35 KiB
TypeScript
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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<Record<string, string>>((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<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 [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<DeviceStatus | null>(null)
|
||
const [boundDeviceOnline, setBoundDeviceOnline] = useState<DeviceOnlineStatus | null>(null)
|
||
const [loadingBoundDevice, setLoadingBoundDevice] = useState(false)
|
||
const pollingRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||
const pollGenerationRef = useRef(0)
|
||
const pageVisibleRef = useRef(true)
|
||
const countdownRef = useRef<ReturnType<typeof setInterval> | 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<number | null> => {
|
||
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 (
|
||
<View className='bind-page' style={getPageStyle(true)}>
|
||
<View className='loading'>
|
||
<Text>加载中...</Text>
|
||
</View>
|
||
{systemBanner}
|
||
</View>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<View className='bind-page bind-state-page' style={getPageStyle(true)}>
|
||
<View className='bind-header'>
|
||
<View className='back-btn' onClick={handleBack}>
|
||
<Image className='back-icon' src={require('../../assets/tab-icons/arrow-left.png')} mode='aspectFit' />
|
||
<Text className='back-text'>返回</Text>
|
||
</View>
|
||
</View>
|
||
<View className='bind-result'>
|
||
{pending ? (
|
||
<>
|
||
<Text className='bind-result-title'>{title}</Text>
|
||
<View className='bind-countdown-ring'>
|
||
<View className='bind-countdown-half right'>
|
||
<View className='bind-countdown-arc' style={{ transform: `rotate(${Math.min(180, countdownAngle)}deg)` }} />
|
||
</View>
|
||
<View className='bind-countdown-half left'>
|
||
<View className='bind-countdown-arc' style={{ transform: `rotate(${Math.max(0, countdownAngle - 180)}deg)` }} />
|
||
</View>
|
||
<Text className='bind-countdown-value'>{formatRemainingTime(remainingBindSeconds)}</Text>
|
||
<Text className='bind-countdown-label'>剩余贴卡时间</Text>
|
||
</View>
|
||
<View className='bind-instruction'>
|
||
<Text className='bind-instruction-title'>{isAddingCard ? '加卡指令已发送' : '绑卡指令已发送'}</Text>
|
||
<Text className='bind-result-description'>{bindHint}</Text>
|
||
</View>
|
||
<Button className='bind-result-button waiting' disabled>等待设备确认</Button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Image className='bind-result-art' mode='aspectFit'
|
||
src={completed ? require('../../assets/ui/dog-success.png') : require('../../assets/ui/dog-failure.png')} />
|
||
<Text className='bind-result-title'>{title}</Text>
|
||
<Text className='bind-result-description'>{bindHint}</Text>
|
||
{completed ? (
|
||
<>
|
||
<View className='bind-device-summary'>
|
||
<View className='bind-device-row'>
|
||
<Text>设备状态</Text>
|
||
<Text className={`bind-device-value ${boundDeviceOnline?.online === true ? 'online' : ''}`}>
|
||
{loadingBoundDevice ? '查询中' : boundDeviceOnline ? boundDeviceOnline.online ? '在线' : '离线' : '--'}
|
||
</Text>
|
||
</View>
|
||
<View className='bind-device-row'>
|
||
<Text>当前电量</Text>
|
||
<Text className='bind-device-value'>{loadingBoundDevice ? '查询中' : `${resultBattery.text}${resultBattery.showPercent ? '%' : ''}`}</Text>
|
||
</View>
|
||
</View>
|
||
<Button className='bind-result-button primary' onClick={() => Taro.reLaunch({ url: bindResultDestination })}>
|
||
{bindResultDestination.includes('/sleep/') ? '去关联宝贝' : '返回首页'}
|
||
</Button>
|
||
{!isAddingCard && <Button className='bind-result-button primary' onClick={() => Taro.navigateTo({ url: trafficPageUrl(boundDeviceId) })}>
|
||
关联流量服务
|
||
</Button>}
|
||
{!isAddingCard && <Text className='bind-result-description'>设备已绑定。流量查询和充值需另外关联,可稍后在管理页完成。</Text>}
|
||
</>
|
||
) : (
|
||
<>
|
||
<Button className='bind-result-button primary' onClick={() => {
|
||
resetBindSessionState()
|
||
if (isExpiredBind && !isAddingCard) handleScanCode()
|
||
}}>重新开始</Button>
|
||
<Button className='bind-result-button secondary' onClick={goDevicePage}>返回首页</Button>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</View>
|
||
{systemBanner}
|
||
</View>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<View className='bind-page' style={getPageStyle(true)}>
|
||
<View className='bind-header'>
|
||
<View className='back-btn' onClick={handleBack}>
|
||
<Image className='back-icon' src={require('../../assets/tab-icons/arrow-left.png')} mode='aspectFit' />
|
||
<Text className='back-text'>返回</Text>
|
||
</View>
|
||
<Text className='title'>{pageTitle}</Text>
|
||
</View>
|
||
|
||
{isAddingCard ? (
|
||
<View className='pending-device-card'>
|
||
<View className='icon-bg orange'>
|
||
<Image className='control-icon-img' src={require('../../assets/tab-icons/rings.png')} mode='aspectFit' />
|
||
</View>
|
||
<Text className='pending-device-title'>给当前设备添加卡片</Text>
|
||
<Text className='pending-device-id'>{deviceId}</Text>
|
||
<Text className='pending-device-desc'>新卡添加后会和已有卡具备相同功能</Text>
|
||
</View>
|
||
) : isPendingBinding ? (
|
||
<View className='pending-device-card'>
|
||
<View className='icon-bg orange'>
|
||
<Image className='control-icon-img' src={require('../../assets/tab-icons/rings.png')} mode='aspectFit' />
|
||
</View>
|
||
<Text className='pending-device-title'>关联现有设备</Text>
|
||
<Text className='pending-device-id'>{pendingDeviceId}</Text>
|
||
<Text className='pending-device-desc'>选择已有宝贝,或新建宝贝后完成关联</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'>
|
||
{isScanning ? '正在扫码...' : '点击扫码'}
|
||
</Text>
|
||
<Text className='scan-hint'>扫描设备二维码或条码</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'>
|
||
{!isAddingCard && (
|
||
<View className='form-card'>
|
||
<Text className='form-title'>宝贝资料</Text>
|
||
|
||
{children.length > 0 && (
|
||
<View className='child-list'>
|
||
{!isPendingBinding && (
|
||
<View
|
||
className={`child-item ${!hasSelectedOrNewChild ? 'selected' : ''}`}
|
||
onClick={() => {
|
||
setSelectedChildId(null)
|
||
setStoredSelectedChildId(null)
|
||
setNewChildName('')
|
||
}}
|
||
>
|
||
<Text className='child-item-text'>暂不关联宝贝</Text>
|
||
</View>
|
||
)}
|
||
{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'>{isPendingBinding ? '新建宝贝姓名' : '新建宝贝姓名(可选)'}</Text>
|
||
<Input
|
||
className='field-input'
|
||
value={newChildName}
|
||
placeholder={
|
||
isPendingBinding
|
||
? children.length > 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)
|
||
}
|
||
}}
|
||
/>
|
||
</View>
|
||
|
||
<Text className='field-hint'>
|
||
{isPendingBinding
|
||
? children.length > 0
|
||
? '可以选已有宝贝,也可以新建一个宝贝后关联当前设备'
|
||
: '当前还没有宝贝资料,请先创建一个宝贝资料后关联设备'
|
||
: children.length > 0
|
||
? '宝贝资料可选;不选择时,设备会先绑定到账号,稍后可关联宝贝'
|
||
: '也可以先不创建宝贝,直接绑定设备,稍后再关联宝贝'}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
|
||
{!isPendingBinding && (
|
||
<View className='form-card'>
|
||
<Text className='form-title'>{isAddingCard ? '加卡状态' : '设备信息'}</Text>
|
||
|
||
<View className='field-item'>
|
||
<Text className='field-label'>设备号</Text>
|
||
<Input
|
||
className='field-input'
|
||
value={deviceId}
|
||
disabled={isAddingCard || !canEditDeviceFields || isPendingBinding}
|
||
placeholder={isAddingCard ? '当前设备' : canEditDeviceFields ? '请输入设备号' : '绑定确认中'}
|
||
onInput={(event) => setDeviceId(event.detail.value)}
|
||
/>
|
||
</View>
|
||
|
||
{!isAddingCard && (
|
||
<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>
|
||
{isPollingBind ? (
|
||
<Text className='pending-tip-countdown'>剩余贴卡时间:{formatRemainingTime(remainingBindSeconds)}</Text>
|
||
) : null}
|
||
{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 || !canSubmit}
|
||
onClick={isExpiredBind && !isPendingBinding && !isAddingCard ? handleScanCode : handleSubmit}
|
||
>
|
||
{isPendingBinding
|
||
? '完成宝贝关联'
|
||
: isPollingBind
|
||
? '等待贴卡确认'
|
||
: isExpiredBind
|
||
? isAddingCard ? '重新发送加卡指令' : '重新扫码绑定'
|
||
: isAddingCard ? '发送加卡指令' : '发送绑卡指令'}
|
||
</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'>{isAddingCard ? '确认当前设备号正确' : isPendingBinding ? '先选择已有宝贝,或者在本页新建一个宝贝' : '宝贝资料可选,不选择时设备会先进入待关联状态'}</Text>
|
||
</View>
|
||
<View className='tip-item'>
|
||
<Text className='tip-number'>2</Text>
|
||
<Text className='tip-text'>{isAddingCard ? '点击发送加卡指令后,在设备上贴新卡' : isPendingBinding ? '确认当前待关联设备,再选择要关联的宝贝' : '扫描设备二维码,确认设备号和序列号正确'}</Text>
|
||
</View>
|
||
<View className='tip-item'>
|
||
<Text className='tip-number'>3</Text>
|
||
<Text className='tip-text'>{isAddingCard ? '加卡成功后,新卡和原来的卡可以执行相同功能' : isPendingBinding ? '点击完成宝贝关联,设备会出现在该宝贝下面' : '点击发送绑卡指令,然后在 10 分钟内去设备上贴自己的卡完成确认'}</Text>
|
||
</View>
|
||
</View>
|
||
{systemBanner}
|
||
</View>
|
||
)
|
||
}
|