上传小程序修改合入

This commit is contained in:
HycJack
2026-05-05 12:24:48 +08:00
parent 7e29958ffa
commit bb87bff0d3
19 changed files with 684 additions and 283 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

@@ -174,6 +174,33 @@
}
}
.audio-bubble {
min-width: 220px;
display: flex;
align-items: center;
gap: 14px;
&.playing {
opacity: 0.82;
}
}
.audio-bubble-icon {
font-size: 24px;
font-weight: 700;
line-height: 1;
}
.audio-bubble-text {
flex: 1;
font-size: 28px;
}
.audio-bubble-duration {
font-size: 24px;
opacity: 0.8;
}
.empty-chat {
min-height: 320px;
display: flex;

View File

@@ -1,5 +1,5 @@
import { View, Text, ScrollView, Image, Input } from '@tarojs/components'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import Taro, { useRouter } from '@tarojs/taro'
import {
getMessages,
@@ -47,6 +47,11 @@ function formatParticipantLabel(message: ChatMessage): string {
return `${senderType} ${senderId}`
}
function formatAudioDuration(durationMs?: number | null): string {
const totalSeconds = Math.max(1, Math.round((durationMs || 0) / 1000))
return `${totalSeconds}s`
}
export default function ChatDetail() {
const router = useRouter()
const params = router.params
@@ -85,6 +90,29 @@ export default function ChatDetail() {
const [loading, setLoading] = useState(true)
const [inputText, setInputText] = useState('')
const [sending, setSending] = useState(false)
const [playingMessageId, setPlayingMessageId] = useState<number | null>(null)
const audioContextRef = useRef<Taro.InnerAudioContext | null>(null)
useEffect(() => {
const audioContext = Taro.createInnerAudioContext()
audioContextRef.current = audioContext
audioContext.onEnded(() => {
setPlayingMessageId(null)
})
audioContext.onStop(() => {
setPlayingMessageId(null)
})
audioContext.onError(() => {
setPlayingMessageId(null)
Taro.showToast({ title: '语音播放失败', icon: 'none' })
})
return () => {
audioContext.destroy()
audioContextRef.current = null
}
}, [])
useEffect(() => {
void loadChatData(activeConversationId)
@@ -157,6 +185,46 @@ export default function ChatDetail() {
}
}
const handlePlayAudio = (message: ChatMessage) => {
if (message.contentType !== 2 || !message.mediaUrl) {
Taro.showToast({ title: '语音地址不存在', icon: 'none' })
return
}
const audioContext = audioContextRef.current
if (!audioContext) {
Taro.showToast({ title: '播放器未就绪', icon: 'none' })
return
}
if (playingMessageId === message.id) {
audioContext.stop()
setPlayingMessageId(null)
return
}
audioContext.stop()
audioContext.src = message.mediaUrl
audioContext.autoplay = true
audioContext.play()
setPlayingMessageId(message.id)
}
const renderMessageBody = (msg: ChatMessage) => {
if (msg.contentType === 2 && msg.mediaUrl) {
const isPlaying = playingMessageId === msg.id
return (
<View className={`audio-bubble ${isPlaying ? 'playing' : ''}`} onClick={() => handlePlayAudio(msg)}>
<Text className='audio-bubble-icon'>{isPlaying ? '[]' : '>'}</Text>
<Text className='audio-bubble-text'>{msg.mediaTranscriptText || '点击播放语音'}</Text>
<Text className='audio-bubble-duration'>{formatAudioDuration(msg.mediaDurationMs)}</Text>
</View>
)
}
return <Text>{msg.content}</Text>
}
return (
<View className='chat-detail-page'>
<View className='chat-header'>
@@ -199,7 +267,7 @@ export default function ChatDetail() {
<View className='message-main'>
<Text className='message-meta'>{formatParticipantLabel(msg)}</Text>
<View className='message-bubble'>
<Text>{msg.content}</Text>
{renderMessageBody(msg)}
</View>
</View>
<View className='user-avatar'>
@@ -214,7 +282,7 @@ export default function ChatDetail() {
<View className='message-main'>
<Text className='message-meta'>{formatParticipantLabel(msg)}</Text>
<View className='message-bubble'>
<Text>{msg.content}</Text>
{renderMessageBody(msg)}
</View>
</View>
</View>

View File

@@ -65,7 +65,7 @@ export default function Chat() {
</View>
) : conversations.length === 0 ? (
<View className='empty-card'>
<Text className='empty-title'></Text>
<Text className='empty-title'></Text>
</View>
) : (
<ScrollView className='conversation-list' scrollY>

View File

@@ -348,6 +348,14 @@
color: #1A1A1A;
}
.empty-subtitle {
display: block;
margin-top: 16px;
font-size: 26px;
line-height: 1.6;
color: #666666;
}
.empty-btn {
margin-top: 28px;
height: 92px;
@@ -378,3 +386,46 @@
font-weight: 600;
color: #1A1A1A;
}
.child-focus-card {
display: flex;
align-items: center;
background: #FFFFFF;
border-radius: 24px;
margin: 0 24px 24px;
padding: 28px 24px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
}
.child-focus-avatar {
width: 96px;
height: 96px;
border-radius: 50%;
background: #FEF3E8;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20px;
}
.child-focus-icon {
font-size: 40px;
}
.child-focus-info {
flex: 1;
}
.child-focus-name {
display: block;
font-size: 32px;
font-weight: 700;
color: #1A1A1A;
}
.child-focus-desc {
display: block;
margin-top: 8px;
font-size: 26px;
color: #666666;
}

View File

@@ -2,8 +2,8 @@ import { View, Text, Switch, Slider, Image } from '@tarojs/components'
import { useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import { getToken } from '@/services/auth'
import { Binding, resolveActiveBinding } from '@/services/binding'
import { Child, getChild } from '@/services/child'
import { Binding, loadCurrentChildBindingContext } from '@/services/binding'
import { Child } from '@/services/child'
import './index.scss'
export default function Device() {
@@ -25,20 +25,9 @@ export default function Device() {
setIsLoading(true)
try {
const activeBinding = await resolveActiveBinding()
setBinding(activeBinding)
if (activeBinding?.child_id) {
try {
const currentChild = await getChild(activeBinding.child_id)
setChild(currentChild)
} catch (error) {
console.error('[device] load child failed:', error)
setChild(null)
}
} else {
setChild(null)
}
const context = await loadCurrentChildBindingContext()
setChild(context.currentChild)
setBinding(context.currentBinding)
} catch (error: any) {
console.error('[device] load failed:', error)
Taro.showToast({
@@ -72,7 +61,7 @@ export default function Device() {
)
}
if (!binding) {
if (!child) {
return (
<View className='device-page'>
<View className='page-header'>
@@ -83,7 +72,39 @@ export default function Device() {
<View className='empty-robot'>
<Image className='robot-img' src={require('../../assets/tab-icons/robot.png')} mode='aspectFit' />
</View>
<Text className='empty-title'></Text>
<Text className='empty-title'></Text>
<Text className='empty-subtitle'></Text>
<View className='empty-btn' onClick={() => Taro.switchTab({ url: '/pages/sleep/index' })}>
<Text className='empty-btn-text'></Text>
</View>
</View>
</View>
)
}
if (!binding) {
return (
<View className='device-page'>
<View className='page-header'>
<Text className='page-title'>{child.child_name} </Text>
</View>
<View className='child-focus-card'>
<View className='child-focus-avatar'>
<Text className='child-focus-icon'>🧒</Text>
</View>
<View className='child-focus-info'>
<Text className='child-focus-name'>{child.child_name}</Text>
<Text className='child-focus-desc'></Text>
</View>
</View>
<View className='empty-card'>
<View className='empty-robot'>
<Image className='robot-img' src={require('../../assets/tab-icons/robot.png')} mode='aspectFit' />
</View>
<Text className='empty-title'></Text>
<Text className='empty-subtitle'></Text>
<View className='empty-btn' onClick={() => Taro.navigateTo({ url: '/pages/bind/index' })}>
<Text className='empty-btn-text'></Text>
</View>
@@ -92,8 +113,8 @@ export default function Device() {
)
}
const childName = child?.child_name || '未设置'
const pageTitle = child?.child_name ? `${child.child_name} 的伴伴` : '伴伴设备'
const childName = child.child_name || '未设置'
const pageTitle = `${childName} 的伴伴`
const battery = 82
const daysLeft = 4
@@ -106,7 +127,7 @@ export default function Device() {
<View className='status-card'>
<View className='status-badge'>
<View className='status-dot'></View>
<Text className='status-text'>{binding.child_id ? '已完成绑定' : '待关联儿童'}</Text>
<Text className='status-text'></Text>
</View>
<View className='battery-section'>
<Text className='battery-percent'>
@@ -124,12 +145,12 @@ export default function Device() {
<View className='detail-card'>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className='detail-value'>{binding.device_id}</Text>
<Text className='detail-label'></Text>
<Text className='detail-value'>{childName}</Text>
</View>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className='detail-value'>{childName}</Text>
<Text className='detail-label'></Text>
<Text className='detail-value'>{binding.device_id}</Text>
</View>
<View className='detail-row'>
<Text className='detail-label'></Text>
@@ -195,12 +216,6 @@ export default function Device() {
</View>
</View>
</View>
{!binding.child_id && (
<View className='helper-card' onClick={() => Taro.navigateTo({ url: '/pages/bind/index' })}>
<Text className='helper-title'></Text>
</View>
)}
</View>
)
}

View File

@@ -26,8 +26,8 @@ const DEFAULT_COORDINATES = {
const MODE_OPTIONS: Array<{ key: TrajectoryMode; label: string }> = [
{ key: 'current', label: '当前位置' },
{ key: 'today', label: '今日轨迹' },
{ key: 'recent', label: '最近点位' },
// { key: 'today', label: '今日轨迹' },
// { key: 'recent', label: '最近点位' },
]
function formatTime(value?: string | null): string {
@@ -339,7 +339,7 @@ export default function Location() {
</View>
) : (
<View className='location-empty'>
<Text className='location-empty-title'></Text>
<Text className='location-empty-title'></Text>
</View>
)
) : trajectory.length > 0 ? (
@@ -384,7 +384,7 @@ export default function Location() {
<View className='location-empty'>
<Text className='location-empty-title'></Text>
<Text className='location-empty-desc'>
{trajectoryMode === 'today' ? '今天还没有新的历史点位。' : '最近时间范围内没有新的历史点位。'}
{trajectoryMode === 'today' ? '当前孩子今天还没有新的历史点位。' : '当前孩子最近没有新的历史点位。'}
</Text>
</View>
)}

View File

@@ -62,9 +62,11 @@ export default function Login() {
}, 300)
} catch (error: any) {
console.error('[login] failed:', error)
Taro.showToast({
title: error?.message || '登录失败,请重试',
icon: 'none',
const message = String(error?.message || '登录失败,请重试')
Taro.showModal({
title: '登录失败',
content: message,
showCancel: false,
})
} finally {
Taro.hideLoading()

View File

@@ -5,13 +5,11 @@ import { clearToken, getCurrentUserId, getToken } from '@/services/auth'
import {
BindingListItem,
clearSelectedBindingDeviceId,
getBindings,
resolveBindingSelection,
setBindingChild,
loadCurrentChildBindingContext,
setSelectedBindingDeviceId,
unbindDevice,
} from '@/services/binding'
import { Child, createChild, getChildren, updateChild } from '@/services/child'
import { Child, clearSelectedChildId, createChild, setSelectedChildId, updateChild } from '@/services/child'
import './index.scss'
interface MenuItem {
@@ -28,8 +26,9 @@ export default function Sleep() {
const [children, setChildren] = useState<Child[]>([])
const [bindings, setBindings] = useState<BindingListItem[]>([])
const [binding, setBinding] = useState<BindingListItem | null>(null)
const [currentChild, setCurrentChild] = useState<Child | null>(null)
const [showModal, setShowModal] = useState(false)
const [showDeviceModal, setShowDeviceModal] = useState(false)
const [showChildModal, setShowChildModal] = useState(false)
const [modalType, setModalType] = useState<'add' | 'edit'>('add')
const [childName, setChildName] = useState('')
const [editingChildId, setEditingChildId] = useState<number | null>(null)
@@ -47,13 +46,11 @@ export default function Sleep() {
setLoading(true)
try {
const [childResponse, bindingResponse] = await Promise.all([getChildren(), getBindings(undefined, 100)])
const bindingItems = bindingResponse.items || []
const activeBinding = resolveBindingSelection(bindingItems)
setChildren(childResponse.items || [])
setBindings(bindingItems)
setBinding(activeBinding)
const context = await loadCurrentChildBindingContext()
setChildren(context.children)
setBindings(context.bindings as BindingListItem[])
setCurrentChild(context.currentChild)
setBinding((context.currentBinding as BindingListItem | null) || null)
setParentInfo(Taro.getStorageSync('userInfo') || {})
} catch (error: any) {
console.error('[manage] load failed:', error)
@@ -66,8 +63,7 @@ export default function Sleep() {
}
}
const currentChild = binding?.child_id ? children.find((item) => item.child_id === binding.child_id) || null : null
const currentChildName = currentChild?.child_name || binding?.child_name || (binding && !binding.child_id ? '待关联' : '未设置')
const currentChildName = currentChild?.child_name || '未设置'
const handleOpenModal = (type: 'add' | 'edit', child?: Child) => {
setModalType(type)
@@ -82,12 +78,19 @@ export default function Sleep() {
setEditingChildId(null)
}
const handleSelectBinding = (targetBinding: BindingListItem) => {
setSelectedBindingDeviceId(targetBinding.device_id)
setBinding(targetBinding)
setShowDeviceModal(false)
const handleSelectChild = (child: Child) => {
setSelectedChildId(child.child_id)
const nextBinding = bindings.find((item) => item.child_id === child.child_id) || null
if (nextBinding?.device_id) {
setSelectedBindingDeviceId(nextBinding.device_id)
} else {
clearSelectedBindingDeviceId()
}
setCurrentChild(child)
setBinding(nextBinding)
setShowChildModal(false)
Taro.showToast({
title: '已切换设备',
title: '已切换当前孩子',
icon: 'success',
})
}
@@ -102,12 +105,8 @@ export default function Sleep() {
try {
if (modalType === 'add') {
const createdChild = await createChild({ child_name: normalizedName })
if (binding && !binding.child_id) {
await setBindingChild(binding.device_id, { child_id: createdChild.child_id })
Taro.showToast({ title: '已创建并关联', icon: 'success' })
} else {
Taro.showToast({ title: '创建成功', icon: 'success' })
}
setSelectedChildId(createdChild.child_id)
Taro.showToast({ title: '创建成功', icon: 'success' })
} else if (editingChildId) {
await updateChild(editingChildId, { child_name: normalizedName })
Taro.showToast({ title: '修改成功', icon: 'success' })
@@ -124,54 +123,19 @@ export default function Sleep() {
}
}
const handleChildMenu = () => {
if (binding && !binding.child_id && children.length > 0) {
Taro.showActionSheet({
itemList: [...children.map((item) => item.child_name), '新建儿童资料'],
success: async (result) => {
if (result.tapIndex === children.length) {
handleOpenModal('add')
return
}
const targetChild = children[result.tapIndex]
if (!targetChild) return
try {
await setBindingChild(binding.device_id, { child_id: targetChild.child_id })
Taro.showToast({ title: '关联成功', icon: 'success' })
await loadData()
} catch (error: any) {
Taro.showToast({
title: error?.message || '关联失败,请重试',
icon: 'none',
})
}
},
})
return
}
if (currentChild) {
handleOpenModal('edit', currentChild)
return
}
handleOpenModal('add')
}
const handleUnbind = () => {
if (!binding) return
Taro.showModal({
title: '解除设备绑定',
content: '确定要解除当前设备绑定吗?',
content: `确定要解除 ${currentChildName} 当前绑定的设备吗?`,
confirmColor: '#FF8C42',
success: async (result) => {
if (!result.confirm) return
try {
await unbindDevice(binding.device_id)
clearSelectedBindingDeviceId()
Taro.showToast({ title: '已解绑', icon: 'success' })
await loadData()
} catch (error: any) {
@@ -193,6 +157,7 @@ export default function Sleep() {
if (!result.confirm) return
clearToken()
clearSelectedBindingDeviceId()
clearSelectedChildId()
Taro.removeStorageSync('userInfo')
Taro.reLaunch({ url: '/pages/login/index' })
},
@@ -202,21 +167,33 @@ export default function Sleep() {
const handleMenuClick = (item: MenuItem) => {
if (item.disabled) return
if (item.name === '儿童资料 (用于称呼)') {
handleChildMenu()
if (item.name === '当前孩子') {
setShowChildModal(true)
return
}
if (item.name === '绑定新设备') {
if (item.name === '编辑当前孩子') {
if (currentChild) {
handleOpenModal('edit', currentChild)
} else {
handleOpenModal('add')
}
return
}
if (item.name === '绑定设备') {
if (!currentChild) {
handleOpenModal('add')
Taro.showToast({
title: '请先添加儿童资料',
icon: 'none',
})
return
}
Taro.navigateTo({ url: '/pages/bind/index' })
return
}
if (item.name === '切换设备') {
setShowDeviceModal(true)
return
}
if (item.name === '解除设备绑定') {
handleUnbind()
}
@@ -237,24 +214,24 @@ export default function Sleep() {
{
icon: require('../../assets/tab-icons/orange-robot.png'),
iconBgClass: 'orange',
name: '儿童资料 (用于称呼)',
name: '当前孩子',
value: currentChildName,
arrow: true,
},
{
icon: require('../../assets/tab-icons/rings.png'),
iconBgClass: 'green',
name: '切换设备',
value: bindings.length > 0 ? `${bindings.length}` : '暂无设备',
arrow: true,
},
{
icon: require('../../assets/tab-icons/orange-robot.png'),
iconBgClass: 'orange',
name: '绑定新设备',
name: '编辑当前孩子',
value: '',
arrow: true,
},
{
icon: require('../../assets/tab-icons/rings.png'),
iconBgClass: 'green',
name: '绑定设备',
value: binding?.device_id ? `当前: ${binding.device_id}` : '未绑定',
arrow: true,
},
{
icon: require('../../assets/tab-icons/broken-rings.png'),
iconBgClass: 'red',
@@ -284,23 +261,23 @@ export default function Sleep() {
<Text className='user-role'> ID: {userId || '--'}</Text>
</View>
<View className='verified-badge'>
<Text>{bindings.length > 0 ? `已绑定 ${bindings.length}` : '未绑定设备'}</Text>
<Text>{currentChild ? '已选择当前孩子' : '未选择孩子'}</Text>
</View>
</View>
<View className='summary-card'>
<View className='summary-row'>
<Text className='summary-label'></Text>
<Text className='summary-value'>{bindings.length} </Text>
</View>
<View className='summary-row'>
<Text className='summary-label'></Text>
<Text className='summary-value'>{binding?.device_id || '未绑定'}</Text>
<Text className='summary-label'></Text>
<Text className='summary-value'>{children.length} </Text>
</View>
<View className='summary-row'>
<Text className='summary-label'></Text>
<Text className='summary-value'>{currentChildName}</Text>
</View>
<View className='summary-row'>
<Text className='summary-label'></Text>
<Text className='summary-value'>{binding?.device_id || '未绑定'}</Text>
</View>
</View>
<View className='menu-card'>
@@ -333,39 +310,40 @@ export default function Sleep() {
<Text className='version-text'> Companion V1.1.0</Text>
</View>
{showDeviceModal && (
<View className='modal-mask' onClick={() => setShowDeviceModal(false)}>
{showChildModal && (
<View className='modal-mask' onClick={() => setShowChildModal(false)}>
<View
className='modal-card device-switch-card'
onClick={(event) => {
event.stopPropagation()
}}
>
<Text className='modal-title'></Text>
{bindings.length === 0 ? (
<Text className='device-switch-empty'></Text>
<Text className='modal-title'></Text>
{children.length === 0 ? (
<Text className='device-switch-empty'></Text>
) : (
<View className='device-switch-list'>
{bindings.map((item) => {
const isActive = binding?.device_id === item.device_id
{children.map((item) => {
const isActive = currentChild?.child_id === item.child_id
const itemBinding = bindings.find((bindingItem) => bindingItem.child_id === item.child_id) || null
return (
<View
key={item.device_id}
key={item.child_id}
className={`device-switch-item ${isActive ? 'active' : ''}`}
onClick={() => handleSelectBinding(item)}
onClick={() => handleSelectChild(item)}
>
<View className='device-switch-head'>
<Text className='device-switch-id'>{item.device_id}</Text>
<Text className='device-switch-id'>{item.child_name}</Text>
{isActive && <Text className='device-switch-tag'></Text>}
</View>
<Text className='device-switch-name'>{item.child_name || '待关联儿童'}</Text>
<Text className='device-switch-name'>{itemBinding?.device_id || '未绑定设备'}</Text>
</View>
)
})}
</View>
)}
<View className='modal-actions'>
<Text className='modal-action cancel' onClick={() => setShowDeviceModal(false)}>
<Text className='modal-action cancel' onClick={() => setShowChildModal(false)}>
</Text>
</View>