绑定页支持贴卡倒计时过期重扫

This commit is contained in:
stu2not
2026-06-03 09:13:36 +08:00
parent e039ac63be
commit 649a344f55
3 changed files with 266 additions and 8 deletions

View File

@@ -269,10 +269,19 @@
}
.pending-tip-text {
display: block;
font-size: 26px;
color: #D46B08;
}
.pending-tip-countdown {
display: block;
margin-top: 8px;
font-size: 28px;
color: #B45309;
font-weight: 700;
}
.pending-tip-subtext {
display: block;
margin-top: 8px;

View File

@@ -27,6 +27,31 @@ const SESSION_STATUS_EXPIRED = 3
const SESSION_STATUS_FAILED = 4
const SESSION_STATUS_CANCELLED = 5
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: '' }
@@ -99,23 +124,33 @@ export default function Bind() {
const [bindStatus, setBindStatus] = useState<number | null>(null)
const [cardUUID, setCardUUID] = useState('')
const [bindHint, setBindHint] = useState('')
const [bindExpiresAt, setBindExpiresAt] = useState('')
const [remainingBindSeconds, setRemainingBindSeconds] = useState(0)
const pollingRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null)
useDidShow(() => {
void loadPageData()
if (bindStatus === SESSION_STATUS_PENDING && bindToken) {
schedulePoll(bindToken)
}
})
useDidHide(() => {
stopPolling()
})
useEffect(() => () => stopPolling(), [])
useEffect(() => () => {
stopPolling()
stopCountdown()
}, [])
const hasSelectedOrNewChild = selectedChildId !== null || Boolean(newChildName.trim())
const isPendingBinding = Boolean(pendingDeviceId)
const isPollingBind = bindStatus === SESSION_STATUS_PENDING && Boolean(bindToken)
const isExpiredBind = bindStatus === SESSION_STATUS_EXPIRED
const canEditDeviceFields = !isPendingBinding && !isPollingBind
const canSubmit = isPendingBinding ? hasSelectedOrNewChild : !isPollingBind
const canSubmit = isPendingBinding ? hasSelectedOrNewChild : isExpiredBind ? true : !isPollingBind
const goDevicePage = () => {
Taro.reLaunch({ url: '/pages/device/index' })
@@ -140,6 +175,13 @@ export default function Bind() {
}
}
const stopCountdown = () => {
if (countdownRef.current) {
clearInterval(countdownRef.current)
countdownRef.current = null
}
}
const schedulePoll = (nextBindToken?: string) => {
stopPolling()
pollingRef.current = setTimeout(() => {
@@ -149,12 +191,52 @@ export default function Bind() {
const resetBindSessionState = () => {
stopPolling()
stopCountdown()
setBindToken('')
setBindStatus(null)
setCardUUID('')
setBindHint('')
setBindExpiresAt('')
setRemainingBindSeconds(0)
}
const resetExpiredBindSession = (showToast = true) => {
stopPolling()
stopCountdown()
setBindToken('')
setBindStatus(SESSION_STATUS_EXPIRED)
setCardUUID('')
setBindExpiresAt('')
setRemainingBindSeconds(0)
setDeviceId('')
setSerialNumber('')
setBindHint('贴卡时间已过,请重新扫码并贴卡')
if (showToast) {
Taro.showToast({ title: '贴卡已超时,请重新扫码', 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) => {
const activeBindToken = (nextBindToken || bindToken).trim()
if (!activeBindToken) return
@@ -163,14 +245,20 @@ export default function Bind() {
const session = await getNFCBindSession(activeBindToken)
setBindStatus(session.status)
setCardUUID(session.card_uuid || '')
setBindExpiresAt(session.expires_at || '')
if (session.status === SESSION_STATUS_PENDING) {
setBindHint('已发送绑卡指令,请拿自己的卡去设备上贴一下')
if (getRemainingBindSeconds(session.expires_at) <= 0) {
resetExpiredBindSession()
return
}
setBindHint('已发送绑卡指令,请在倒计时结束前去设备上贴自己的卡')
schedulePoll(activeBindToken)
return
}
stopPolling()
stopCountdown()
if (session.status === SESSION_STATUS_COMPLETED) {
setSelectedBindingDeviceId(session.device_id)
@@ -191,7 +279,7 @@ export default function Bind() {
}
if (session.status === SESSION_STATUS_EXPIRED) {
setBindHint('绑定会话已过期,请重新扫码并贴卡')
resetExpiredBindSession(false)
return
}
@@ -271,6 +359,7 @@ export default function Bind() {
success: (res) => {
setIsScanning(false)
const parsed = parseBindingPayload(res.result || '')
resetBindSessionState()
if (parsed.deviceId) setDeviceId(parsed.deviceId)
if (parsed.serialNumber) setSerialNumber(parsed.serialNumber)
@@ -328,7 +417,9 @@ export default function Bind() {
setBindToken(session.bind_token)
setBindStatus(session.status)
setBindHint(childId ? '已发送绑卡指令,请去设备上贴自己的卡' : '已发送绑卡指令,贴卡后设备会先进入待关联状态')
setBindExpiresAt(session.expires_at)
setRemainingBindSeconds(getRemainingBindSeconds(session.expires_at))
setBindHint(childId ? '已发送绑卡指令,请在倒计时结束前去设备上贴自己的卡' : '已发送绑卡指令,贴卡后设备会先进入待关联状态')
schedulePoll(session.bind_token)
Taro.showToast({ title: '请去设备上贴卡', icon: 'none' })
} catch (error: any) {
@@ -492,6 +583,9 @@ export default function Bind() {
{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}
@@ -508,9 +602,9 @@ export default function Bind() {
className='submit-btn'
loading={submitting}
disabled={submitting || !canSubmit}
onClick={handleSubmit}
onClick={isExpiredBind && !isPendingBinding ? handleScanCode : handleSubmit}
>
{isPendingBinding ? '完成儿童关联' : isPollingBind ? '等待贴卡确认' : '发送绑卡指令'}
{isPendingBinding ? '完成儿童关联' : isPollingBind ? '等待贴卡确认' : isExpiredBind ? '重新扫码绑定' : '发送绑卡指令'}
</Button>
</View>
@@ -526,7 +620,7 @@ export default function Bind() {
</View>
<View className='tip-item'>
<Text className='tip-number'>3</Text>
<Text className='tip-text'>{isPendingBinding ? '点击完成儿童关联,设备会出现在该孩子下面' : '点击发送绑卡指令,然后去设备上贴自己的卡完成确认'}</Text>
<Text className='tip-text'>{isPendingBinding ? '点击完成儿童关联,设备会出现在该孩子下面' : '点击发送绑卡指令,然后在 10 分钟内去设备上贴自己的卡完成确认'}</Text>
</View>
</View>
{systemBanner}