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

910 lines
35 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 { 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>
)
}