feat(小程序): 支持多设备绑定与管理
This commit is contained in:
@@ -6,67 +6,6 @@
|
|||||||
padding-bottom: calc(120px + env(safe-area-inset-bottom));
|
padding-bottom: calc(120px + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 400px;
|
|
||||||
font-size: 30px;
|
|
||||||
color: #999999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-tip {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 70%;
|
|
||||||
font-size: 30px;
|
|
||||||
color: #999999;
|
|
||||||
padding: 0 48px;
|
|
||||||
|
|
||||||
.empty-title {
|
|
||||||
font-size: 36px;
|
|
||||||
color: #1A1A1A;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-desc {
|
|
||||||
font-size: 26px;
|
|
||||||
color: #999999;
|
|
||||||
margin-bottom: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-input-wrap {
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
|
|
||||||
.empty-input {
|
|
||||||
background: #F5F7FA;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 24px;
|
|
||||||
font-size: 30px;
|
|
||||||
width: 100%;
|
|
||||||
height: 96px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-btn {
|
|
||||||
background: #FF8C42;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 24px 64px;
|
|
||||||
|
|
||||||
.empty-btn-text {
|
|
||||||
color: #FFFFFF;
|
|
||||||
font-size: 32px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-bg {
|
.icon-bg {
|
||||||
width: 72px;
|
width: 72px;
|
||||||
height: 72px;
|
height: 72px;
|
||||||
@@ -214,6 +153,125 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bind-form {
|
||||||
|
margin: 0 48px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card {
|
||||||
|
background: #FFFFFF;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 28px 32px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-title {
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1A1A1A;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-item {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #666666;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 88px;
|
||||||
|
background: #F7F8FA;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 0 24px;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #1A1A1A;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
display: block;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #999999;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-tip {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
background: #FFF7E8;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-tip-text {
|
||||||
|
font-size: 26px;
|
||||||
|
color: #D46B08;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-item {
|
||||||
|
padding: 14px 24px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #F5F7FA;
|
||||||
|
margin-right: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background: #FF8C42;
|
||||||
|
|
||||||
|
.child-item-text {
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-item-text {
|
||||||
|
font-size: 26px;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 96px;
|
||||||
|
background: #FF8C42;
|
||||||
|
border-radius: 16px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
text {
|
||||||
|
font-size: 32px;
|
||||||
|
color: #999999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.bind-tips {
|
.bind-tips {
|
||||||
margin: 0 48px;
|
margin: 0 48px;
|
||||||
|
|
||||||
|
|||||||
@@ -1,201 +1,233 @@
|
|||||||
import { View, Text, Image, Input } from '@tarojs/components'
|
import { View, Text, Image, Input, Button } from '@tarojs/components'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState } from 'react'
|
||||||
import Taro from '@tarojs/taro'
|
import Taro, { useDidShow } from '@tarojs/taro'
|
||||||
import { getToken } from '@/services/api'
|
import { getToken } from '@/services/auth'
|
||||||
import { getCurrentBinding, directBind, setBindingChild } from '@/services/binding'
|
import { directBind, resolveActiveBinding, setBindingChild, setSelectedBindingDeviceId } from '@/services/binding'
|
||||||
import { getChildren, createChild } from '@/services/child'
|
import { Child, createChild, getChildren } from '@/services/child'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
interface Child {
|
function parseBindingPayload(rawValue: string): { deviceId: string; serialNumber: string } {
|
||||||
child_id: number
|
const raw = String(rawValue || '').trim()
|
||||||
child_name: string
|
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[decodeURIComponent(key)] = decodeURIComponent(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() {
|
export default function Bind() {
|
||||||
const [isScanning, setIsScanning] = useState(false)
|
|
||||||
const [loading, setLoading] = useState(true)
|
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 [children, setChildren] = useState<Child[]>([])
|
||||||
|
const [selectedChildId, setSelectedChildId] = useState<number | null>(null)
|
||||||
const [newChildName, setNewChildName] = useState('')
|
const [newChildName, setNewChildName] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [pendingDeviceId, setPendingDeviceId] = useState<string | null>(null)
|
const [pendingDeviceId, setPendingDeviceId] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useDidShow(() => {
|
||||||
checkAuth()
|
void loadPageData()
|
||||||
}, [])
|
})
|
||||||
|
|
||||||
const goDevicePage = () => {
|
const goDevicePage = () => {
|
||||||
setTimeout(() => {
|
|
||||||
Taro.reLaunch({ url: '/pages/device/index' })
|
Taro.reLaunch({ url: '/pages/device/index' })
|
||||||
}, 500)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const completeChildLink = async (deviceId: string, childId: number) => {
|
const loadPageData = async () => {
|
||||||
await setBindingChild(deviceId, { child_id: childId })
|
if (!getToken()) {
|
||||||
setPendingDeviceId(null)
|
Taro.reLaunch({ url: '/pages/login/index' })
|
||||||
}
|
|
||||||
|
|
||||||
const checkAuth = async () => {
|
|
||||||
const token = getToken()
|
|
||||||
if (!token) {
|
|
||||||
Taro.redirectTo({ url: '/pages/login/index' })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const childRes = await getChildren()
|
const [childResponse, activeBinding] = await Promise.all([getChildren(), resolveActiveBinding()])
|
||||||
const currentChildren = childRes.items || []
|
const currentChildren = childResponse.items || []
|
||||||
|
|
||||||
setChildren(currentChildren)
|
setChildren(currentChildren)
|
||||||
|
setSelectedChildId((currentSelectedChildId) => currentSelectedChildId || currentChildren[0]?.child_id || null)
|
||||||
|
|
||||||
const binding = await getCurrentBinding()
|
if (activeBinding?.device_id && !activeBinding.child_id) {
|
||||||
if (binding) {
|
setPendingDeviceId(activeBinding.device_id)
|
||||||
if (binding.child_id) {
|
setDeviceId(activeBinding.device_id)
|
||||||
Taro.showToast({ title: 'Already bound', icon: 'none' })
|
} else {
|
||||||
goDevicePage()
|
setPendingDeviceId(null)
|
||||||
return
|
setDeviceId('')
|
||||||
|
setSerialNumber('')
|
||||||
}
|
}
|
||||||
|
} catch (error: any) {
|
||||||
setPendingDeviceId(binding.device_id)
|
console.error('[bind] load failed:', error)
|
||||||
if (currentChildren.length > 0) {
|
Taro.showToast({
|
||||||
await completeChildLink(binding.device_id, currentChildren[0].child_id)
|
title: error?.message || '加载失败,请稍后重试',
|
||||||
Taro.showToast({ title: 'Binding completed', icon: 'success' })
|
icon: 'none',
|
||||||
goDevicePage()
|
})
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Load bind page data failed:', err)
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const doBind = async (deviceId: string) => {
|
const ensureChildId = async (): Promise<number | null> => {
|
||||||
Taro.showLoading({ title: 'Binding...' })
|
if (selectedChildId) return selectedChildId
|
||||||
|
|
||||||
try {
|
const childName = newChildName.trim()
|
||||||
await directBind({ device_id: deviceId })
|
if (!childName) return null
|
||||||
|
|
||||||
if (children.length > 0) {
|
const child = await createChild({ child_name: childName })
|
||||||
await completeChildLink(deviceId, children[0].child_id)
|
setChildren((currentChildren) => [child, ...currentChildren])
|
||||||
Taro.hideLoading()
|
setSelectedChildId(child.child_id)
|
||||||
Taro.showToast({
|
setNewChildName('')
|
||||||
title: 'Bind success',
|
return child.child_id
|
||||||
icon: 'success',
|
|
||||||
duration: 1200,
|
|
||||||
})
|
|
||||||
goDevicePage()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setPendingDeviceId(deviceId)
|
|
||||||
Taro.hideLoading()
|
|
||||||
Taro.showToast({ title: 'Device bound, add child next', icon: 'none' })
|
|
||||||
} catch (err: any) {
|
|
||||||
Taro.hideLoading()
|
|
||||||
Taro.showToast({ title: err.message || 'Bind failed', icon: 'none' })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScanCode = () => {
|
const handleScanCode = () => {
|
||||||
if (pendingDeviceId) {
|
|
||||||
Taro.showToast({ title: 'Complete child setup first', icon: 'none' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsScanning(true)
|
setIsScanning(true)
|
||||||
|
|
||||||
Taro.scanCode({
|
Taro.scanCode({
|
||||||
onlyFromCamera: true,
|
onlyFromCamera: true,
|
||||||
scanType: ['qrCode', 'barCode'],
|
scanType: ['qrCode', 'barCode'],
|
||||||
success: async (res) => {
|
success: (res) => {
|
||||||
setIsScanning(false)
|
setIsScanning(false)
|
||||||
const deviceId = res.result
|
const parsed = parseBindingPayload(res.result || '')
|
||||||
|
if (parsed.deviceId) setDeviceId(parsed.deviceId)
|
||||||
|
if (parsed.serialNumber) setSerialNumber(parsed.serialNumber)
|
||||||
|
|
||||||
if (!deviceId) {
|
Taro.showToast({
|
||||||
Taro.showToast({ title: 'Invalid device code', icon: 'none' })
|
title: parsed.serialNumber ? '已填入设备信息' : '已识别设备号,请补充序列号',
|
||||||
return
|
icon: 'none',
|
||||||
}
|
})
|
||||||
|
|
||||||
await doBind(deviceId)
|
|
||||||
},
|
},
|
||||||
fail: (err) => {
|
fail: (err) => {
|
||||||
setIsScanning(false)
|
setIsScanning(false)
|
||||||
|
|
||||||
if (err.errMsg && err.errMsg.includes('cancel')) {
|
if (err.errMsg && err.errMsg.includes('cancel')) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Taro.showToast({ title: 'Scan failed', icon: 'none' })
|
|
||||||
|
Taro.showToast({
|
||||||
|
title: '扫码失败,请重试',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleManualInput = () => {
|
const handleSubmit = async () => {
|
||||||
if (pendingDeviceId) {
|
if (submitting) return
|
||||||
Taro.showToast({ title: 'Complete child setup first', icon: 'none' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Taro.showModal({
|
setSubmitting(true)
|
||||||
title: 'Manual input',
|
Taro.showLoading({ title: pendingDeviceId ? '关联中...' : '绑定中...' })
|
||||||
editable: true,
|
|
||||||
placeholderText: 'Enter device code',
|
|
||||||
success: (res) => {
|
|
||||||
if (res.confirm && res.content) {
|
|
||||||
doBind(res.content)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddChild = async () => {
|
|
||||||
if (!newChildName.trim()) {
|
|
||||||
Taro.showToast({ title: 'Please enter child name', icon: 'none' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
try {
|
||||||
const child = await createChild({ child_name: newChildName.trim() })
|
const childId = await ensureChildId()
|
||||||
setNewChildName('')
|
|
||||||
|
|
||||||
if (pendingDeviceId) {
|
if (pendingDeviceId) {
|
||||||
await completeChildLink(pendingDeviceId, child.child_id)
|
if (!childId) {
|
||||||
Taro.showToast({ title: 'Child linked', icon: 'success' })
|
throw new Error('请选择儿童或填写新儿童昵称')
|
||||||
|
}
|
||||||
|
|
||||||
|
await setBindingChild(pendingDeviceId, { child_id: childId })
|
||||||
|
setSelectedBindingDeviceId(pendingDeviceId)
|
||||||
|
Taro.showToast({ title: '绑定完成', icon: 'success' })
|
||||||
|
setTimeout(() => {
|
||||||
goDevicePage()
|
goDevicePage()
|
||||||
|
}, 300)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const childRes = await getChildren()
|
if (!deviceId.trim()) {
|
||||||
setChildren(childRes.items || [])
|
throw new Error('请输入设备号')
|
||||||
Taro.showToast({ title: 'Child created', icon: 'success' })
|
|
||||||
} catch (err) {
|
|
||||||
Taro.showToast({ title: 'Create child failed', icon: 'none' })
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
}
|
||||||
|
if (!serialNumber.trim()) {
|
||||||
|
throw new Error('请输入设备序列号')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogout = () => {
|
const result = await directBind({
|
||||||
Taro.showModal({
|
device_id: deviceId.trim(),
|
||||||
title: 'Logout',
|
serial_number: serialNumber.trim(),
|
||||||
content: 'Clear login info and go to login page?',
|
...(childId ? { child_id: childId } : {}),
|
||||||
confirmColor: '#FF8C42',
|
|
||||||
success: (res) => {
|
|
||||||
if (!res.confirm) return
|
|
||||||
Taro.removeStorageSync('token')
|
|
||||||
Taro.removeStorageSync('user_id')
|
|
||||||
Taro.removeStorageSync('expires_in')
|
|
||||||
Taro.removeStorageSync('userInfo')
|
|
||||||
Taro.removeStorageSync('hasDevice')
|
|
||||||
Taro.removeStorageSync('deviceInfo')
|
|
||||||
Taro.reLaunch({ url: '/pages/login/index' })
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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',
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[bind] submit failed:', error)
|
||||||
|
Taro.showToast({
|
||||||
|
title: error?.message || '绑定失败,请重试',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
Taro.hideLoading()
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<View className='bind-page'>
|
<View className='bind-page'>
|
||||||
<View className='loading'>Loading...</View>
|
<View className='loading'>
|
||||||
|
<Text>加载中...</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -203,8 +235,10 @@ export default function Bind() {
|
|||||||
return (
|
return (
|
||||||
<View className='bind-page'>
|
<View className='bind-page'>
|
||||||
<View className='bind-header'>
|
<View className='bind-header'>
|
||||||
<Text className='title'>Bind Device</Text>
|
<Text className='title'>{pendingDeviceId ? '补全绑定' : '绑定设备'}</Text>
|
||||||
<Text className='subtitle'>Scan QR/device code to bind first, then set child profile.</Text>
|
<Text className='subtitle'>
|
||||||
|
{pendingDeviceId ? '设备已绑到当前账号,请补充儿童资料完成配置' : '扫描二维码或手动填写设备号和序列号'}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='scan-area'>
|
<View className='scan-area'>
|
||||||
@@ -216,8 +250,8 @@ export default function Bind() {
|
|||||||
mode='aspectFit'
|
mode='aspectFit'
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text className='scan-text'>{isScanning ? 'Scanning...' : 'Tap to scan'}</Text>
|
<Text className='scan-text'>{isScanning ? '正在扫码...' : '点击扫码'}</Text>
|
||||||
<Text className='scan-hint'>Place QR/barcode in frame</Text>
|
<Text className='scan-hint'>请将二维码放入框内</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='scan-corners'>
|
<View className='scan-corners'>
|
||||||
@@ -228,51 +262,95 @@ export default function Bind() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='bind-options'>
|
<View className='bind-form'>
|
||||||
<View className='option-item' onClick={handleManualInput}>
|
<View className='form-card'>
|
||||||
<Text className='option-icon'>#</Text>
|
<Text className='form-title'>设备信息</Text>
|
||||||
<Text className='option-text'>Manual device code input</Text>
|
|
||||||
<Text className='option-arrow'>{'>'}</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>
|
||||||
|
|
||||||
|
<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>
|
</View>
|
||||||
|
|
||||||
{pendingDeviceId && (
|
{pendingDeviceId && (
|
||||||
<View className='empty-tip'>
|
<View className='pending-tip'>
|
||||||
<Text className='empty-title'>Device bound: {pendingDeviceId}</Text>
|
<Text className='pending-tip-text'>当前待完成设备:{pendingDeviceId}</Text>
|
||||||
<Text className='empty-desc'>Add child profile to complete setup</Text>
|
|
||||||
<View className='empty-input-wrap'>
|
|
||||||
<Input
|
|
||||||
className='empty-input'
|
|
||||||
placeholder='Enter child name'
|
|
||||||
value={newChildName}
|
|
||||||
onInput={(e) => setNewChildName(e.detail.value)}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
<View className='empty-btn' onClick={handleAddChild}>
|
)}
|
||||||
<Text className='empty-btn-text'>{saving ? 'Saving...' : 'Create and link child'}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View className='form-card'>
|
||||||
|
<Text className='form-title'>儿童信息</Text>
|
||||||
|
|
||||||
|
{children.length > 0 && (
|
||||||
|
<View className='child-list'>
|
||||||
|
{children.map((child) => (
|
||||||
|
<View
|
||||||
|
key={child.child_id}
|
||||||
|
className={`child-item ${selectedChildId === child.child_id ? 'selected' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedChildId((currentChildId) => (currentChildId === child.child_id ? null : child.child_id))
|
||||||
|
setNewChildName('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='child-item-text'>{child.child_name}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<View className='field-item'>
|
||||||
|
<Text className='field-label'>新建儿童昵称</Text>
|
||||||
|
<Input
|
||||||
|
className='field-input'
|
||||||
|
value={newChildName}
|
||||||
|
placeholder={children.length > 0 ? '不选已有儿童时,可填写新昵称' : '请输入儿童昵称'}
|
||||||
|
onInput={(event) => {
|
||||||
|
setNewChildName(event.detail.value)
|
||||||
|
if (event.detail.value) {
|
||||||
|
setSelectedChildId(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text className='field-hint'>优先选择已有儿童,也可以直接输入一个新昵称。</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button className='submit-btn' loading={submitting} disabled={submitting} onClick={handleSubmit}>
|
||||||
|
{pendingDeviceId ? '完成儿童关联' : '绑定设备'}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className='bind-tips'>
|
<View className='bind-tips'>
|
||||||
<Text className='tips-title'>Binding Tips</Text>
|
<Text className='tips-title'>绑定帮助</Text>
|
||||||
<View className='tip-item'>
|
<View className='tip-item'>
|
||||||
<Text className='tip-number'>1</Text>
|
<Text className='tip-number'>1</Text>
|
||||||
<Text className='tip-text'>Find QR code or device code on device.</Text>
|
<Text className='tip-text'>先扫描二维码,自动填入设备号和序列号。</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='tip-item'>
|
<View className='tip-item'>
|
||||||
<Text className='tip-number'>2</Text>
|
<Text className='tip-number'>2</Text>
|
||||||
<Text className='tip-text'>Bind device first by scan/manual input.</Text>
|
<Text className='tip-text'>如果二维码里只有设备号,请手动补充序列号。</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='tip-item'>
|
<View className='tip-item'>
|
||||||
<Text className='tip-number'>3</Text>
|
<Text className='tip-number'>3</Text>
|
||||||
<Text className='tip-text'>Create or select child profile to finish setup.</Text>
|
<Text className='tip-text'>没有儿童资料时,可以直接在本页新建并完成绑定。</Text>
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className='bind-footer'>
|
|
||||||
<View className='logout-btn' onClick={handleLogout}>
|
|
||||||
<Text className='logout-btn-text'>Logout</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -14,15 +14,8 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1A1A1A;
|
color: #1A1A1A;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 12px;
|
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-subtitle {
|
|
||||||
font-size: 28px;
|
|
||||||
color: #666666;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-card {
|
.status-card {
|
||||||
@@ -289,3 +282,99 @@
|
|||||||
color: #999999;
|
color: #999999;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
background: #FFFFFF;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin: 0 24px 24px;
|
||||||
|
padding: 10px 24px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
border-bottom: 1px solid #F3F3F3;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
max-width: 60%;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #1A1A1A;
|
||||||
|
text-align: right;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-card {
|
||||||
|
background: #FFFFFF;
|
||||||
|
border-radius: 24px;
|
||||||
|
margin: 0 24px;
|
||||||
|
padding: 48px 32px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-robot {
|
||||||
|
width: 132px;
|
||||||
|
height: 132px;
|
||||||
|
background: #FEF3E8;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin: 0 auto 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.robot-img {
|
||||||
|
width: 68px;
|
||||||
|
height: 68px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1A1A1A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-btn {
|
||||||
|
margin-top: 28px;
|
||||||
|
height: 92px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #FF8C42;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-btn-text {
|
||||||
|
font-size: 30px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.helper-card {
|
||||||
|
background: #FFFFFF;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin: 0 24px 24px;
|
||||||
|
padding: 28px 24px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.helper-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1A1A1A;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,104 +1,65 @@
|
|||||||
import { View, Text, Switch, Slider, Image } from '@tarojs/components'
|
import { View, Text, Switch, Slider, Image } from '@tarojs/components'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState } from 'react'
|
||||||
import Taro from '@tarojs/taro'
|
import Taro, { useDidShow } from '@tarojs/taro'
|
||||||
import { getCurrentBinding } from '@/services/binding'
|
import { getToken } from '@/services/auth'
|
||||||
|
import { Binding, resolveActiveBinding } from '@/services/binding'
|
||||||
|
import { Child, getChild } from '@/services/child'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
interface DeviceData {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
status: 'online' | 'offline'
|
|
||||||
battery: number
|
|
||||||
daysLeft: number
|
|
||||||
sleepEnabled: boolean
|
|
||||||
sleepTime: string
|
|
||||||
volume: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Device() {
|
export default function Device() {
|
||||||
const [device, setDevice] = useState<DeviceData | null>(null)
|
const [binding, setBinding] = useState<Binding | null>(null)
|
||||||
|
const [child, setChild] = useState<Child | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [hasDevice, setHasDevice] = useState(false)
|
const [sleepEnabled, setSleepEnabled] = useState(true)
|
||||||
|
const [volume, setVolume] = useState(50)
|
||||||
|
|
||||||
useEffect(() => {
|
useDidShow(() => {
|
||||||
console.log('[Device] Checking login status...')
|
void loadDeviceInfo()
|
||||||
const token = Taro.getStorageSync('token')
|
})
|
||||||
console.log('[Device] Token exists:', !!token)
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
console.log('[Device] No token, redirecting to login')
|
|
||||||
Taro.redirectTo({ url: '/pages/login/index' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loadDeviceInfo()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadDeviceInfo = async () => {
|
const loadDeviceInfo = async () => {
|
||||||
try {
|
if (!getToken()) {
|
||||||
const binding = await getCurrentBinding()
|
Taro.reLaunch({ url: '/pages/login/index' })
|
||||||
console.log('[Device] Got binding:', binding)
|
|
||||||
|
|
||||||
if (binding) {
|
|
||||||
if (!binding.child_id) {
|
|
||||||
setHasDevice(false)
|
|
||||||
Taro.showToast({ title: 'Please link child profile first', icon: 'none' })
|
|
||||||
setTimeout(() => {
|
|
||||||
Taro.redirectTo({ url: '/pages/bind/index' })
|
|
||||||
}, 600)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setHasDevice(true)
|
setIsLoading(true)
|
||||||
Taro.setStorageSync('hasDevice', '1')
|
try {
|
||||||
Taro.setStorageSync('deviceInfo', {
|
const activeBinding = await resolveActiveBinding()
|
||||||
id: binding.device_id,
|
setBinding(activeBinding)
|
||||||
name: '伴伴设备',
|
|
||||||
status: 'online',
|
if (activeBinding?.child_id) {
|
||||||
battery: 82
|
try {
|
||||||
})
|
const currentChild = await getChild(activeBinding.child_id)
|
||||||
setDevice({
|
setChild(currentChild)
|
||||||
id: binding.device_id,
|
} catch (error) {
|
||||||
name: '伴伴设备',
|
console.error('[device] load child failed:', error)
|
||||||
status: 'online',
|
setChild(null)
|
||||||
battery: 82,
|
}
|
||||||
daysLeft: 4,
|
|
||||||
sleepEnabled: true,
|
|
||||||
sleepTime: '每天 22:00 - 07:00',
|
|
||||||
volume: 50
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
setHasDevice(false)
|
setChild(null)
|
||||||
Taro.removeStorageSync('hasDevice')
|
|
||||||
Taro.removeStorageSync('deviceInfo')
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
console.log('[Device] No binding or error:', err?.message || err)
|
|
||||||
if (err?.status === 404) {
|
|
||||||
setHasDevice(false)
|
|
||||||
Taro.removeStorageSync('hasDevice')
|
|
||||||
Taro.removeStorageSync('deviceInfo')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[device] load failed:', error)
|
||||||
|
Taro.showToast({
|
||||||
|
title: error?.message || '加载失败,请稍后重试',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSleepToggle = (e: any) => {
|
const handleSleepToggle = (e: any) => {
|
||||||
if (!device) return
|
setSleepEnabled(e.detail.value)
|
||||||
setDevice({ ...device, sleepEnabled: e.detail.value })
|
|
||||||
Taro.showToast({
|
Taro.showToast({
|
||||||
title: e.detail.value ? '已开启定时休眠' : '已关闭定时休眠',
|
title: e.detail.value ? '休眠展示已开启' : '休眠展示已关闭',
|
||||||
icon: 'success'
|
icon: 'none',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleVolumeChange = (e: any) => {
|
const handleVolumeChange = (e: any) => {
|
||||||
if (!device) return
|
setVolume(e.detail.value)
|
||||||
setDevice({ ...device, volume: e.detail.value })
|
|
||||||
}
|
|
||||||
|
|
||||||
const switchTab = (url: string) => {
|
|
||||||
Taro.switchTab({ url })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -111,27 +72,48 @@ export default function Device() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 未绑定设备,跳转到绑定页面
|
if (!binding) {
|
||||||
if (!hasDevice || !device) {
|
return (
|
||||||
Taro.redirectTo({ url: '/pages/bind/index' })
|
<View className='device-page'>
|
||||||
return null
|
<View className='page-header'>
|
||||||
|
<Text className='page-title'>设备首页</Text>
|
||||||
|
</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>
|
||||||
|
<View className='empty-btn' onClick={() => Taro.navigateTo({ url: '/pages/bind/index' })}>
|
||||||
|
<Text className='empty-btn-text'>去绑定设备</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const childName = child?.child_name || '未设置'
|
||||||
|
const pageTitle = child?.child_name ? `${child.child_name} 的伴伴` : '伴伴设备'
|
||||||
|
const battery = 82
|
||||||
|
const daysLeft = 4
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='device-page'>
|
<View className='device-page'>
|
||||||
<View className='page-header'>
|
<View className='page-header'>
|
||||||
<Text className='page-title'>{device.name}</Text>
|
<Text className='page-title'>{pageTitle}</Text>
|
||||||
<Text className='page-subtitle'>设备运行状态良好</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='status-card'>
|
<View className='status-card'>
|
||||||
<View className='status-badge'>
|
<View className='status-badge'>
|
||||||
<View className='status-dot'></View>
|
<View className='status-dot'></View>
|
||||||
<Text className='status-text'>4G 在线</Text>
|
<Text className='status-text'>{binding.child_id ? '已完成绑定' : '待关联儿童'}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='battery-section'>
|
<View className='battery-section'>
|
||||||
<Text className='battery-percent'>{device.battery}<Text className='percent'>%</Text></Text>
|
<Text className='battery-percent'>
|
||||||
<Text className='battery-label'>剩余电量 (预计续航 {device.daysLeft} 天)</Text>
|
{battery}
|
||||||
|
<Text className='percent'>%</Text>
|
||||||
|
</Text>
|
||||||
|
<Text className='battery-label'>模拟电量展示 (预计续航 {daysLeft} 天)</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='device-avatar'>
|
<View className='device-avatar'>
|
||||||
<View className='robot-icon'>
|
<View className='robot-icon'>
|
||||||
@@ -140,6 +122,21 @@ export default function Device() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View className='detail-card'>
|
||||||
|
<View className='detail-row'>
|
||||||
|
<Text className='detail-label'>设备号</Text>
|
||||||
|
<Text className='detail-value'>{binding.device_id}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='detail-row'>
|
||||||
|
<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'>{binding.bound_at || '--'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className='section-title'>基础控制</View>
|
<View className='section-title'>基础控制</View>
|
||||||
<View className='control-card'>
|
<View className='control-card'>
|
||||||
<View className='control-item'>
|
<View className='control-item'>
|
||||||
@@ -153,14 +150,10 @@ export default function Device() {
|
|||||||
</View>
|
</View>
|
||||||
<View className='control-info'>
|
<View className='control-info'>
|
||||||
<Text className='control-name'>定时休眠</Text>
|
<Text className='control-name'>定时休眠</Text>
|
||||||
<Text className='control-detail'>{device.sleepTime}</Text>
|
<Text className='control-detail'>开启后进入休眠展示</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<Switch checked={sleepEnabled} onChange={handleSleepToggle} color='#FF8C42' />
|
||||||
checked={device.sleepEnabled}
|
|
||||||
onChange={handleSleepToggle}
|
|
||||||
color='#FF8C42'
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='divider'></View>
|
<View className='divider'></View>
|
||||||
@@ -188,7 +181,7 @@ export default function Device() {
|
|||||||
className='slider'
|
className='slider'
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
value={device.volume}
|
value={volume}
|
||||||
onChange={handleVolumeChange}
|
onChange={handleVolumeChange}
|
||||||
activeColor='#FF8C42'
|
activeColor='#FF8C42'
|
||||||
backgroundColor='#E5E5E5'
|
backgroundColor='#E5E5E5'
|
||||||
@@ -203,6 +196,11 @@ export default function Device() {
|
|||||||
</View>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,149 +161,198 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-input-box {
|
.image-avatar {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
margin: 0 24px 24px;
|
margin: 0 24px 24px;
|
||||||
padding: 24px;
|
padding: 10px 24px;
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
.input-row {
|
.summary-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
align-items: center;
|
||||||
}
|
padding: 20px 0;
|
||||||
|
border-bottom: 1px solid #F2F2F2;
|
||||||
|
|
||||||
.input-wrapper {
|
&:last-child {
|
||||||
.name-input {
|
border-bottom: none;
|
||||||
background: #F5F7FA;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 20px 24px;
|
|
||||||
font-size: 30px;
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-hint {
|
|
||||||
font-size: 24px;
|
|
||||||
color: #999999;
|
|
||||||
margin-top: 12px;
|
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-btns {
|
.summary-label {
|
||||||
display: flex;
|
font-size: 28px;
|
||||||
justify-content: flex-end;
|
color: #666666;
|
||||||
gap: 24px;
|
|
||||||
|
|
||||||
.confirm-btn {
|
|
||||||
font-size: 30px;
|
|
||||||
color: #FF8C42;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cancel-btn {
|
.summary-value {
|
||||||
font-size: 30px;
|
max-width: 60%;
|
||||||
color: #999999;
|
font-size: 28px;
|
||||||
}
|
color: #1A1A1A;
|
||||||
}
|
text-align: right;
|
||||||
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item.disabled {
|
.menu-item.disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.45;
|
||||||
|
|
||||||
&:active {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-section {
|
.logout-section {
|
||||||
margin: 24px;
|
margin: 0 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-btn {
|
.logout-btn {
|
||||||
|
height: 92px;
|
||||||
|
border-radius: 18px;
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
border-radius: 20px;
|
display: flex;
|
||||||
padding: 26px 24px;
|
align-items: center;
|
||||||
text-align: center;
|
justify-content: center;
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
.logout-text {
|
.logout-text {
|
||||||
font-size: 30px;
|
font-size: 30px;
|
||||||
color: #FF3B30;
|
color: #FF6B35;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-mask {
|
.modal-mask {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
inset: 0;
|
||||||
left: 0;
|
background: rgba(0, 0, 0, 0.42);
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1000;
|
padding: 0 48px;
|
||||||
|
z-index: 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-card {
|
||||||
|
width: 100%;
|
||||||
background: #FFFFFF;
|
background: #FFFFFF;
|
||||||
border-radius: 20px;
|
border-radius: 24px;
|
||||||
width: 600px;
|
padding: 32px;
|
||||||
margin: 0 48px;
|
box-sizing: border-box;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
padding: 32px 32px 16px;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
.modal-title {
|
.modal-title {
|
||||||
font-size: 34px;
|
display: block;
|
||||||
font-weight: 600;
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
color: #1A1A1A;
|
color: #1A1A1A;
|
||||||
}
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body {
|
.modal-input-wrap {
|
||||||
padding: 16px 32px 32px;
|
background: #F7F8FA;
|
||||||
display: flex;
|
border-radius: 16px;
|
||||||
align-items: center;
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-input {
|
.modal-input {
|
||||||
background: #F5F7FA;
|
height: 88px;
|
||||||
border-radius: 12px;
|
|
||||||
padding: 0 24px;
|
|
||||||
font-size: 34px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 96px;
|
font-size: 28px;
|
||||||
box-sizing: border-box;
|
color: #1A1A1A;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-footer {
|
.modal-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
border-top: 1px solid #F0F0F0;
|
justify-content: flex-end;
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-btn {
|
.modal-action {
|
||||||
flex: 1;
|
font-size: 30px;
|
||||||
text-align: center;
|
margin-left: 32px;
|
||||||
padding: 24px 0;
|
|
||||||
font-size: 32px;
|
|
||||||
|
|
||||||
&.cancel {
|
&.cancel {
|
||||||
color: #666666;
|
color: #999999;
|
||||||
border-right: 1px solid #F0F0F0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.confirm {
|
&.confirm {
|
||||||
color: #FF8C42;
|
color: #FF8C42;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-switch-card {
|
||||||
|
max-height: 70vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-switch-empty {
|
||||||
|
display: block;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #999999;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-switch-list {
|
||||||
|
max-height: 52vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-switch-item {
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #F7F8FA;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #FFF2E7;
|
||||||
|
border: 2px solid #FFBE94;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-switch-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-switch-id {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #1A1A1A;
|
||||||
|
font-weight: 600;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-switch-tag {
|
||||||
|
margin-left: 16px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #FF8C42;
|
||||||
|
font-size: 22px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-switch-name {
|
||||||
|
font-size: 26px;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
text {
|
||||||
|
font-size: 32px;
|
||||||
|
color: #999999;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,210 +1,270 @@
|
|||||||
import { View, Text, Image, Input } from '@tarojs/components'
|
import { View, Text, Image, Input } from '@tarojs/components'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState } from 'react'
|
||||||
import Taro from '@tarojs/taro'
|
import Taro, { useDidShow } from '@tarojs/taro'
|
||||||
import { getToken, getCurrentUserId } from '@/services/api'
|
import { clearToken, getCurrentUserId, getToken } from '@/services/auth'
|
||||||
import { getChildren, createChild, updateChild } from '@/services/child'
|
import {
|
||||||
import { getCurrentBinding, unbindDevice } from '@/services/binding'
|
BindingListItem,
|
||||||
|
clearSelectedBindingDeviceId,
|
||||||
|
getBindings,
|
||||||
|
resolveBindingSelection,
|
||||||
|
setBindingChild,
|
||||||
|
setSelectedBindingDeviceId,
|
||||||
|
unbindDevice,
|
||||||
|
} from '@/services/binding'
|
||||||
|
import { Child, createChild, getChildren, updateChild } from '@/services/child'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
interface Child {
|
|
||||||
child_id: number
|
|
||||||
child_name: string
|
|
||||||
child_gender: number
|
|
||||||
status: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Binding {
|
|
||||||
device_id: string
|
|
||||||
child_id: number | null
|
|
||||||
status: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MenuItem {
|
interface MenuItem {
|
||||||
icon: string
|
icon: string
|
||||||
iconBgClass: string
|
iconBgClass: string
|
||||||
name: string
|
name: string
|
||||||
value?: string
|
value: string
|
||||||
arrow?: boolean
|
arrow?: boolean
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Sleep() {
|
export default function Sleep() {
|
||||||
const [children, setChildren] = useState<Child[]>([])
|
|
||||||
const [binding, setBinding] = useState<Binding | null>(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [children, setChildren] = useState<Child[]>([])
|
||||||
|
const [bindings, setBindings] = useState<BindingListItem[]>([])
|
||||||
|
const [binding, setBinding] = useState<BindingListItem | null>(null)
|
||||||
const [showModal, setShowModal] = useState(false)
|
const [showModal, setShowModal] = useState(false)
|
||||||
|
const [showDeviceModal, setShowDeviceModal] = useState(false)
|
||||||
const [modalType, setModalType] = useState<'add' | 'edit'>('add')
|
const [modalType, setModalType] = useState<'add' | 'edit'>('add')
|
||||||
const [childName, setChildName] = useState('')
|
const [childName, setChildName] = useState('')
|
||||||
const [editingChildId, setEditingChildId] = useState<number | null>(null)
|
const [editingChildId, setEditingChildId] = useState<number | null>(null)
|
||||||
const [parentInfo, setParentInfo] = useState<{ nickname?: string; avatar_url?: string }>({})
|
const [parentInfo, setParentInfo] = useState<{ nickname?: string; avatar_url?: string }>({})
|
||||||
|
|
||||||
const token = getToken()
|
useDidShow(() => {
|
||||||
const userId = getCurrentUserId()
|
void loadData()
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
const loadData = async () => {
|
||||||
if (!token) {
|
if (!getToken()) {
|
||||||
Taro.reLaunch({ url: '/pages/login/index' })
|
Taro.reLaunch({ url: '/pages/login/index' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loadData()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadData = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
// Get parent info
|
|
||||||
const userInfo = Taro.getStorageSync('userInfo') || {}
|
|
||||||
setParentInfo(userInfo)
|
|
||||||
|
|
||||||
// Get children
|
|
||||||
const childRes = await getChildren()
|
|
||||||
setChildren(childRes.items || [])
|
|
||||||
|
|
||||||
// Get binding
|
|
||||||
try {
|
try {
|
||||||
const bindRes = await getCurrentBinding()
|
const [childResponse, bindingResponse] = await Promise.all([getChildren(), getBindings(undefined, 100)])
|
||||||
setBinding(bindRes)
|
const bindingItems = bindingResponse.items || []
|
||||||
} catch (err: any) {
|
const activeBinding = resolveBindingSelection(bindingItems)
|
||||||
if (err.status === 404) {
|
|
||||||
setBinding(null)
|
setChildren(childResponse.items || [])
|
||||||
}
|
setBindings(bindingItems)
|
||||||
}
|
setBinding(activeBinding)
|
||||||
} catch (err) {
|
setParentInfo(Taro.getStorageSync('userInfo') || {})
|
||||||
console.error('Load failed:', err)
|
} catch (error: any) {
|
||||||
|
console.error('[manage] load failed:', error)
|
||||||
|
Taro.showToast({
|
||||||
|
title: error?.message || '加载失败,请稍后重试',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 handleOpenModal = (type: 'add' | 'edit', child?: Child) => {
|
const handleOpenModal = (type: 'add' | 'edit', child?: Child) => {
|
||||||
setModalType(type)
|
setModalType(type)
|
||||||
if (type === 'edit' && child) {
|
setChildName(child?.child_name || '')
|
||||||
setChildName(child.child_name)
|
setEditingChildId(child?.child_id || null)
|
||||||
setEditingChildId(child.child_id)
|
|
||||||
} else {
|
|
||||||
setChildName('')
|
|
||||||
setEditingChildId(null)
|
|
||||||
}
|
|
||||||
setShowModal(true)
|
setShowModal(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleModalConfirm = async () => {
|
const handleCloseModal = () => {
|
||||||
if (!childName.trim()) {
|
setShowModal(false)
|
||||||
Taro.showToast({ title: '请输入昵称', icon: 'none' })
|
setChildName('')
|
||||||
|
setEditingChildId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectBinding = (targetBinding: BindingListItem) => {
|
||||||
|
setSelectedBindingDeviceId(targetBinding.device_id)
|
||||||
|
setBinding(targetBinding)
|
||||||
|
setShowDeviceModal(false)
|
||||||
|
Taro.showToast({
|
||||||
|
title: '已切换设备',
|
||||||
|
icon: 'success',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmitModal = async () => {
|
||||||
|
const normalizedName = childName.trim()
|
||||||
|
if (!normalizedName) {
|
||||||
|
Taro.showToast({ title: '请输入儿童昵称', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (modalType === 'add') {
|
if (modalType === 'add') {
|
||||||
await createChild({ child_name: childName.trim() })
|
const createdChild = await createChild({ child_name: normalizedName })
|
||||||
Taro.showToast({ title: '添加成功', icon: 'success' })
|
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' })
|
||||||
|
}
|
||||||
} else if (editingChildId) {
|
} else if (editingChildId) {
|
||||||
await updateChild(editingChildId, { child_name: childName.trim() })
|
await updateChild(editingChildId, { child_name: normalizedName })
|
||||||
Taro.showToast({ title: '修改成功', icon: 'success' })
|
Taro.showToast({ title: '修改成功', icon: 'success' })
|
||||||
}
|
}
|
||||||
setShowModal(false)
|
|
||||||
setChildName('')
|
handleCloseModal()
|
||||||
setEditingChildId(null)
|
await loadData()
|
||||||
loadData()
|
} catch (error: any) {
|
||||||
} catch (err) {
|
console.error('[manage] save child failed:', error)
|
||||||
Taro.showToast({ title: '操作失败', icon: 'none' })
|
Taro.showToast({
|
||||||
|
title: error?.message || '保存失败,请重试',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleModalCancel = () => {
|
const handleChildMenu = () => {
|
||||||
setShowModal(false)
|
if (binding && !binding.child_id && children.length > 0) {
|
||||||
setChildName('')
|
Taro.showActionSheet({
|
||||||
setEditingChildId(null)
|
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 = () => {
|
const handleUnbind = () => {
|
||||||
if (!binding) return
|
if (!binding) return
|
||||||
|
|
||||||
Taro.showModal({
|
Taro.showModal({
|
||||||
title: '解除设备绑定',
|
title: '解除设备绑定',
|
||||||
content: '确定要解除当前设备的绑定吗?',
|
content: '确定要解除当前设备绑定吗?',
|
||||||
confirmColor: '#FF8C42',
|
confirmColor: '#FF8C42',
|
||||||
success: async (res) => {
|
success: async (result) => {
|
||||||
if (res.confirm) {
|
if (!result.confirm) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await unbindDevice(binding.device_id)
|
await unbindDevice(binding.device_id)
|
||||||
Taro.showToast({ title: '已解绑', icon: 'success' })
|
Taro.showToast({ title: '已解绑', icon: 'success' })
|
||||||
setBinding(null)
|
await loadData()
|
||||||
loadData()
|
} catch (error: any) {
|
||||||
} catch (err) {
|
Taro.showToast({
|
||||||
Taro.showToast({ title: '解绑失败', icon: 'none' })
|
title: error?.message || '解绑失败,请重试',
|
||||||
}
|
icon: 'none',
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = () => {
|
||||||
Taro.showModal({
|
Taro.showModal({
|
||||||
title: '退出登录',
|
title: '退出登录',
|
||||||
content: '确定要退出登录吗?',
|
content: '确定要退出当前账号吗?',
|
||||||
confirmColor: '#FF8C42',
|
confirmColor: '#FF8C42',
|
||||||
success: (res) => {
|
success: (result) => {
|
||||||
if (res.confirm) {
|
if (!result.confirm) return
|
||||||
Taro.removeStorageSync('token')
|
clearToken()
|
||||||
Taro.removeStorageSync('user_id')
|
clearSelectedBindingDeviceId()
|
||||||
Taro.removeStorageSync('userInfo')
|
Taro.removeStorageSync('userInfo')
|
||||||
Taro.reLaunch({ url: '/pages/login/index' })
|
Taro.reLaunch({ url: '/pages/login/index' })
|
||||||
}
|
},
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMenuClick = (item: MenuItem) => {
|
const handleMenuClick = (item: MenuItem) => {
|
||||||
if (item.disabled) return
|
if (item.disabled) return
|
||||||
|
|
||||||
|
if (item.name === '儿童资料 (用于称呼)') {
|
||||||
|
handleChildMenu()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (item.name === '绑定新设备') {
|
if (item.name === '绑定新设备') {
|
||||||
if (binding) {
|
|
||||||
Taro.showToast({ title: '已有设备绑定', icon: 'none' })
|
|
||||||
} else {
|
|
||||||
Taro.navigateTo({ url: '/pages/bind/index' })
|
Taro.navigateTo({ url: '/pages/bind/index' })
|
||||||
|
return
|
||||||
}
|
}
|
||||||
} else if (item.name === '解除设备绑定') {
|
|
||||||
|
if (item.name === '切换设备') {
|
||||||
|
setShowDeviceModal(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.name === '解除设备绑定') {
|
||||||
handleUnbind()
|
handleUnbind()
|
||||||
} else if (item.name.includes('儿童资料')) {
|
|
||||||
if (children.length > 0) {
|
|
||||||
handleOpenModal('edit', children[0])
|
|
||||||
} else {
|
|
||||||
handleOpenModal('add')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!token) return null
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View className='manage-page'>
|
||||||
|
<View className='loading'>
|
||||||
|
<Text>加载中...</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = getCurrentUserId()
|
||||||
const menuItems: MenuItem[] = [
|
const menuItems: MenuItem[] = [
|
||||||
{
|
{
|
||||||
icon: require('../../assets/tab-icons/orange-robot.png'),
|
icon: require('../../assets/tab-icons/orange-robot.png'),
|
||||||
iconBgClass: 'orange',
|
iconBgClass: 'orange',
|
||||||
name: '儿童资料 (用于称呼)',
|
name: '儿童资料 (用于称呼)',
|
||||||
value: children.length > 0 ? children[0].child_name : '未设置',
|
value: currentChildName,
|
||||||
arrow: true
|
arrow: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: require('../../assets/tab-icons/rings.png'),
|
icon: require('../../assets/tab-icons/rings.png'),
|
||||||
iconBgClass: 'green',
|
iconBgClass: 'green',
|
||||||
|
name: '切换设备',
|
||||||
|
value: bindings.length > 0 ? `共 ${bindings.length} 台` : '暂无设备',
|
||||||
|
arrow: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: require('../../assets/tab-icons/orange-robot.png'),
|
||||||
|
iconBgClass: 'orange',
|
||||||
name: '绑定新设备',
|
name: '绑定新设备',
|
||||||
value: '',
|
value: '',
|
||||||
arrow: true
|
arrow: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: require('../../assets/tab-icons/broken-rings.png'),
|
icon: require('../../assets/tab-icons/broken-rings.png'),
|
||||||
iconBgClass: 'red',
|
iconBgClass: 'red',
|
||||||
name: '解除设备绑定',
|
name: '解除设备绑定',
|
||||||
value: '',
|
value: binding?.device_id ? `当前: ${binding.device_id}` : '',
|
||||||
arrow: true,
|
arrow: true,
|
||||||
disabled: !binding
|
disabled: !binding,
|
||||||
}
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const firstChild = children[0]
|
|
||||||
const childDisplayValue = firstChild ? firstChild.child_name : ''
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='manage-page'>
|
<View className='manage-page'>
|
||||||
<View className='page-header'>
|
<View className='page-header'>
|
||||||
@@ -214,24 +274,39 @@ export default function Sleep() {
|
|||||||
<View className='user-card'>
|
<View className='user-card'>
|
||||||
<View className='user-avatar'>
|
<View className='user-avatar'>
|
||||||
{parentInfo.avatar_url ? (
|
{parentInfo.avatar_url ? (
|
||||||
<Image className='avatar-img' src={parentInfo.avatar_url} mode='aspectFill' />
|
<Image className='avatar-img image-avatar' src={parentInfo.avatar_url} mode='aspectFill' />
|
||||||
) : (
|
) : (
|
||||||
<Text className='avatar-img'>👤</Text>
|
<Text className='avatar-img'>👤</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View className='user-info'>
|
<View className='user-info'>
|
||||||
<Text className='user-name'>{parentInfo.nickname || '家长用户'}</Text>
|
<Text className='user-name'>{parentInfo.nickname || '家长用户'}</Text>
|
||||||
<Text className='user-role'>ID: {userId}</Text>
|
<Text className='user-role'>用户 ID: {userId || '--'}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='verified-badge'>
|
||||||
|
<Text>{bindings.length > 0 ? `已绑定 ${bindings.length} 台` : '未绑定设备'}</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>
|
||||||
|
</View>
|
||||||
|
<View className='summary-row'>
|
||||||
|
<Text className='summary-label'>当前儿童</Text>
|
||||||
|
<Text className='summary-value'>{currentChildName}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='menu-card'>
|
<View className='menu-card'>
|
||||||
{menuItems.map((item, index) => (
|
{menuItems.map((item, index) => (
|
||||||
<View key={index}>
|
<View key={index}>
|
||||||
<View
|
<View className={`menu-item ${item.disabled ? 'disabled' : ''}`} onClick={() => handleMenuClick(item)}>
|
||||||
className={`menu-item ${item.disabled ? 'disabled' : ''}`}
|
|
||||||
onClick={() => handleMenuClick(item)}
|
|
||||||
>
|
|
||||||
<View className='menu-left'>
|
<View className='menu-left'>
|
||||||
<View className={`icon-bg ${item.iconBgClass}`}>
|
<View className={`icon-bg ${item.iconBgClass}`}>
|
||||||
<Image className='control-icon-img' src={item.icon} mode='aspectFit' />
|
<Image className='control-icon-img' src={item.icon} mode='aspectFit' />
|
||||||
@@ -239,9 +314,7 @@ export default function Sleep() {
|
|||||||
<Text className='menu-name'>{item.name}</Text>
|
<Text className='menu-name'>{item.name}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='menu-right'>
|
<View className='menu-right'>
|
||||||
{item.name.includes('儿童资料') && (
|
{item.value && <Text className='menu-value'>{item.value}</Text>}
|
||||||
<Text className='menu-value'>{childDisplayValue}</Text>
|
|
||||||
)}
|
|
||||||
{item.arrow && <Text className='arrow'>›</Text>}
|
{item.arrow && <Text className='arrow'>›</Text>}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -260,25 +333,66 @@ export default function Sleep() {
|
|||||||
<Text className='version-text'>伴伴 Companion V1.1.0</Text>
|
<Text className='version-text'>伴伴 Companion V1.1.0</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Modal */}
|
{showDeviceModal && (
|
||||||
|
<View className='modal-mask' onClick={() => setShowDeviceModal(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>
|
||||||
|
) : (
|
||||||
|
<View className='device-switch-list'>
|
||||||
|
{bindings.map((item) => {
|
||||||
|
const isActive = binding?.device_id === item.device_id
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={item.device_id}
|
||||||
|
className={`device-switch-item ${isActive ? 'active' : ''}`}
|
||||||
|
onClick={() => handleSelectBinding(item)}
|
||||||
|
>
|
||||||
|
<View className='device-switch-head'>
|
||||||
|
<Text className='device-switch-id'>{item.device_id}</Text>
|
||||||
|
{isActive && <Text className='device-switch-tag'>当前</Text>}
|
||||||
|
</View>
|
||||||
|
<Text className='device-switch-name'>{item.child_name || '待关联儿童'}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View className='modal-actions'>
|
||||||
|
<Text className='modal-action cancel' onClick={() => setShowDeviceModal(false)}>
|
||||||
|
关闭
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{showModal && (
|
{showModal && (
|
||||||
<View className='modal-mask'>
|
<View className='modal-mask'>
|
||||||
<View className='modal-content'>
|
<View className='modal-card'>
|
||||||
<View className='modal-header'>
|
<Text className='modal-title'>{modalType === 'add' ? '新建儿童资料' : '修改儿童昵称'}</Text>
|
||||||
<Text className='modal-title'>{modalType === 'add' ? '添加儿童' : '修改昵称'}</Text>
|
<View className='modal-input-wrap'>
|
||||||
</View>
|
|
||||||
<View className='modal-body'>
|
|
||||||
<Input
|
<Input
|
||||||
className='modal-input'
|
className='modal-input'
|
||||||
placeholder='请输入儿童昵称'
|
|
||||||
value={childName}
|
value={childName}
|
||||||
onInput={(e) => setChildName(e.detail.value)}
|
placeholder='请输入儿童昵称'
|
||||||
focus={true}
|
focus
|
||||||
|
onInput={(event) => setChildName(event.detail.value)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View className='modal-footer'>
|
<View className='modal-actions'>
|
||||||
<Text className='modal-btn cancel' onClick={handleModalCancel}>取消</Text>
|
<Text className='modal-action cancel' onClick={handleCloseModal}>
|
||||||
<Text className='modal-btn confirm' onClick={handleModalConfirm}>确认</Text>
|
取消
|
||||||
|
</Text>
|
||||||
|
<Text className='modal-action confirm' onClick={handleSubmitModal}>
|
||||||
|
确认
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,97 +1,126 @@
|
|||||||
|
import Taro from '@tarojs/taro'
|
||||||
import { request } from './api'
|
import { request } from './api'
|
||||||
|
|
||||||
|
const SELECTED_BINDING_DEVICE_ID_KEY = 'selectedBindingDeviceId'
|
||||||
|
|
||||||
|
function buildQuery(params: Record<string, string | number | undefined | null>): string {
|
||||||
|
return Object.entries(params)
|
||||||
|
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
||||||
|
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
|
||||||
|
.join('&')
|
||||||
|
}
|
||||||
|
|
||||||
export interface Binding {
|
export interface Binding {
|
||||||
device_id: string
|
device_id: string
|
||||||
child_id: number | null
|
child_id: number | null
|
||||||
|
child_name?: string | null
|
||||||
status: number
|
status: number
|
||||||
bound_at: string
|
bound_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BindStartResponse {
|
export interface BindingListItem extends Binding {}
|
||||||
bind_token: string
|
|
||||||
expires_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BindHistoryItem {
|
export interface BindingListResponse {
|
||||||
device_id: string
|
items: BindingListItem[]
|
||||||
child_id: number | null
|
|
||||||
bound_at: string
|
|
||||||
unbound_at?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BindHistoryResponse {
|
|
||||||
items: BindHistoryItem[]
|
|
||||||
total: number
|
total: number
|
||||||
next_cursor?: string
|
next_cursor?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirectBindPayload {
|
||||||
|
device_id: string
|
||||||
|
serial_number: string
|
||||||
|
child_id?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSelectedBindingDeviceId(): string | null {
|
||||||
|
const value = Taro.getStorageSync(SELECTED_BINDING_DEVICE_ID_KEY)
|
||||||
|
const deviceId = String(value || '').trim()
|
||||||
|
return deviceId || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSelectedBindingDeviceId(deviceId?: string | null) {
|
||||||
|
const normalizedDeviceId = String(deviceId || '').trim()
|
||||||
|
if (!normalizedDeviceId) {
|
||||||
|
Taro.removeStorageSync(SELECTED_BINDING_DEVICE_ID_KEY)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Taro.setStorageSync(SELECTED_BINDING_DEVICE_ID_KEY, normalizedDeviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSelectedBindingDeviceId() {
|
||||||
|
Taro.removeStorageSync(SELECTED_BINDING_DEVICE_ID_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveBindingSelection<T extends { device_id: string }>(items: T[]): T | null {
|
||||||
|
const selectedDeviceId = getSelectedBindingDeviceId()
|
||||||
|
const selectedBinding = selectedDeviceId ? items.find((item) => item.device_id === selectedDeviceId) || null : null
|
||||||
|
const activeBinding = selectedBinding || items[0] || null
|
||||||
|
|
||||||
|
if (activeBinding) {
|
||||||
|
setSelectedBindingDeviceId(activeBinding.device_id)
|
||||||
|
} else {
|
||||||
|
clearSelectedBindingDeviceId()
|
||||||
|
}
|
||||||
|
|
||||||
|
return activeBinding
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取当前绑定信息
|
|
||||||
export async function getCurrentBinding(): Promise<Binding | null> {
|
export async function getCurrentBinding(): Promise<Binding | null> {
|
||||||
try {
|
try {
|
||||||
return await request<Binding>('/bindings/current')
|
return await request<Binding>('/bindings/current')
|
||||||
} catch (err: any) {
|
} catch (error: any) {
|
||||||
if (err.status === 404) {
|
if (error?.status === 404) return null
|
||||||
return null
|
throw error
|
||||||
}
|
|
||||||
throw err
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 开始绑定设备
|
export async function getBindings(cursor?: number, limit: number = 20): Promise<BindingListResponse> {
|
||||||
export async function startBind(data: {
|
const query = buildQuery({
|
||||||
device_id: string
|
cursor,
|
||||||
child_id?: number
|
limit,
|
||||||
}): Promise<BindStartResponse> {
|
|
||||||
return request<BindStartResponse>('/bindings/start', {
|
|
||||||
method: 'POST',
|
|
||||||
data,
|
|
||||||
})
|
})
|
||||||
|
return request<BindingListResponse>(`/bindings?${query}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确认绑定设备
|
|
||||||
export async function confirmBind(data: {
|
|
||||||
bind_token: string
|
|
||||||
challenge_code: string
|
|
||||||
}): Promise<{ device_id: string; child_id: number | null }> {
|
|
||||||
return request('/bindings/confirm', {
|
|
||||||
method: 'POST',
|
|
||||||
data,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取设备绑定信息
|
|
||||||
export async function getBinding(deviceId: string): Promise<Binding | null> {
|
export async function getBinding(deviceId: string): Promise<Binding | null> {
|
||||||
// If no deviceId, get the first binding for this user
|
|
||||||
if (!deviceId) {
|
|
||||||
try {
|
try {
|
||||||
// Try to get binding list - for now return null if no specific device
|
return await request<Binding>(`/bindings/${deviceId}`)
|
||||||
return null
|
} catch (error: any) {
|
||||||
} catch {
|
if (error?.status === 404) return null
|
||||||
return null
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return request<Binding>(`/bindings/${deviceId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解绑设备
|
export async function resolveActiveBinding(): Promise<Binding | null> {
|
||||||
export async function unbindDevice(deviceId: string): Promise<void> {
|
const selectedDeviceId = getSelectedBindingDeviceId()
|
||||||
return request(`/bindings/${deviceId}`, {
|
|
||||||
method: 'DELETE',
|
if (selectedDeviceId) {
|
||||||
})
|
const selectedBinding = await getBinding(selectedDeviceId)
|
||||||
|
if (selectedBinding) {
|
||||||
|
setSelectedBindingDeviceId(selectedBinding.device_id)
|
||||||
|
return selectedBinding
|
||||||
}
|
}
|
||||||
|
|
||||||
// 直接绑定设备(手动输入设备码)
|
clearSelectedBindingDeviceId()
|
||||||
export async function directBind(data: {
|
}
|
||||||
device_id: string
|
|
||||||
child_id?: number
|
const currentBinding = await getCurrentBinding()
|
||||||
}): Promise<{ device_id: string; child_id: number | null }> {
|
if (currentBinding?.device_id) {
|
||||||
|
setSelectedBindingDeviceId(currentBinding.device_id)
|
||||||
|
} else {
|
||||||
|
clearSelectedBindingDeviceId()
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentBinding
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function directBind(data: DirectBindPayload): Promise<{ device_id: string; child_id: number | null }> {
|
||||||
return request('/bindings/direct', {
|
return request('/bindings/direct', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data,
|
data,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bind child profile after device has been bound to parent account.
|
|
||||||
export async function setBindingChild(
|
export async function setBindingChild(
|
||||||
deviceId: string,
|
deviceId: string,
|
||||||
data: { child_id: number }
|
data: { child_id: number }
|
||||||
@@ -102,15 +131,8 @@ export async function setBindingChild(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取绑定历史
|
export async function unbindDevice(deviceId: string): Promise<void> {
|
||||||
export async function getBindHistory(
|
return request(`/bindings/${deviceId}`, {
|
||||||
deviceId: string,
|
method: 'DELETE',
|
||||||
cursor?: string,
|
})
|
||||||
limit: number = 20
|
|
||||||
): Promise<BindHistoryResponse> {
|
|
||||||
const params = new URLSearchParams()
|
|
||||||
if (cursor) params.append('cursor', cursor)
|
|
||||||
params.append('limit', String(limit))
|
|
||||||
|
|
||||||
return request<BindHistoryResponse>(`/bindings/history/${deviceId}?${params.toString()}`)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { request } from './api'
|
import { request } from './api'
|
||||||
|
|
||||||
|
function buildQuery(params: Record<string, string | number | undefined | null>): string {
|
||||||
|
return Object.entries(params)
|
||||||
|
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
||||||
|
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
|
||||||
|
.join('&')
|
||||||
|
}
|
||||||
|
|
||||||
export interface Child {
|
export interface Child {
|
||||||
child_id: number
|
child_id: number
|
||||||
child_name: string
|
child_name: string
|
||||||
@@ -11,10 +18,9 @@ export interface Child {
|
|||||||
export interface ChildListResponse {
|
export interface ChildListResponse {
|
||||||
items: Child[]
|
items: Child[]
|
||||||
total: number
|
total: number
|
||||||
next_cursor?: number
|
next_cursor?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建小孩档案
|
|
||||||
export async function createChild(data: {
|
export async function createChild(data: {
|
||||||
child_name: string
|
child_name: string
|
||||||
child_gender?: number
|
child_gender?: number
|
||||||
@@ -26,24 +32,18 @@ export async function createChild(data: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取小孩列表
|
export async function getChildren(cursor?: number, limit: number = 20): Promise<ChildListResponse> {
|
||||||
export async function getChildren(
|
const query = buildQuery({
|
||||||
cursor?: number,
|
cursor,
|
||||||
limit: number = 20
|
limit,
|
||||||
): Promise<ChildListResponse> {
|
})
|
||||||
const params = new URLSearchParams()
|
return request<ChildListResponse>(`/children?${query}`)
|
||||||
if (cursor) params.append('cursor', String(cursor))
|
|
||||||
params.append('limit', String(limit))
|
|
||||||
|
|
||||||
return request<ChildListResponse>(`/children?${params.toString()}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取单个小孩信息
|
|
||||||
export async function getChild(childId: number): Promise<Child> {
|
export async function getChild(childId: number): Promise<Child> {
|
||||||
return request<Child>(`/children/${childId}`)
|
return request<Child>(`/children/${childId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新小孩信息
|
|
||||||
export async function updateChild(
|
export async function updateChild(
|
||||||
childId: number,
|
childId: number,
|
||||||
data: { child_name?: string; child_gender?: number; child_birthday?: string }
|
data: { child_name?: string; child_gender?: number; child_birthday?: string }
|
||||||
|
|||||||
Reference in New Issue
Block a user