feat: 支持先绑定设备后关联儿童档案

- 绑定模型新增 owner_user_id,支持 child_id 为空\n- 新增绑定后设置儿童接口 /bindings/{device_id}/child\n- 前端绑定页改为先绑设备后补充儿童\n- 补充数据库迁移脚本与 DAO 测试用例
This commit is contained in:
ChengCan
2026-04-14 12:47:52 +08:00
parent a22fcafea9
commit 1b4c47f77a
11 changed files with 699 additions and 246 deletions

View File

@@ -1,8 +1,8 @@
import { View, Text, Button, Image, Input } from '@tarojs/components'
import { View, Text, Image, Input } from '@tarojs/components'
import { useState, useEffect } from 'react'
import Taro from '@tarojs/taro'
import { getToken } from '@/services/api'
import { getCurrentBinding, directBind } from '@/services/binding'
import { getCurrentBinding, directBind, setBindingChild } from '@/services/binding'
import { getChildren, createChild } from '@/services/child'
import './index.scss'
@@ -17,11 +17,23 @@ export default function Bind() {
const [children, setChildren] = useState<Child[]>([])
const [newChildName, setNewChildName] = useState('')
const [saving, setSaving] = useState(false)
const [pendingDeviceId, setPendingDeviceId] = useState<string | null>(null)
useEffect(() => {
checkAuth()
}, [])
const goDevicePage = () => {
setTimeout(() => {
Taro.reLaunch({ url: '/pages/device/index' })
}, 500)
}
const completeChildLink = async (deviceId: string, childId: number) => {
await setBindingChild(deviceId, { child_id: childId })
setPendingDeviceId(null)
}
const checkAuth = async () => {
const token = getToken()
if (!token) {
@@ -31,42 +43,76 @@ export default function Bind() {
try {
const childRes = await getChildren()
setChildren(childRes.items || [])
if (childRes.items?.length === 0) {
setLoading(false)
return
}
} catch (err) {
console.error('Get children failed:', err)
}
const currentChildren = childRes.items || []
setChildren(currentChildren)
try {
const binding = await getCurrentBinding()
if (binding) {
Taro.showToast({ title: '已有设备绑定', icon: 'none' })
setTimeout(() => {
Taro.reLaunch({ url: '/pages/device/index' })
}, 1000)
return
if (binding.child_id) {
Taro.showToast({ title: 'Already bound', icon: 'none' })
goDevicePage()
return
}
setPendingDeviceId(binding.device_id)
if (currentChildren.length > 0) {
await completeChildLink(binding.device_id, currentChildren[0].child_id)
Taro.showToast({ title: 'Binding completed', icon: 'success' })
goDevicePage()
return
}
}
} catch (err) {
console.error('Load bind page data failed:', err)
} finally {
setLoading(false)
}
}
const doBind = async (deviceId: string) => {
Taro.showLoading({ title: 'Binding...' })
try {
await directBind({ device_id: deviceId })
if (children.length > 0) {
await completeChildLink(deviceId, children[0].child_id)
Taro.hideLoading()
Taro.showToast({
title: 'Bind success',
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' })
}
setLoading(false)
}
const handleScanCode = () => {
if (pendingDeviceId) {
Taro.showToast({ title: 'Complete child setup first', icon: 'none' })
return
}
setIsScanning(true)
Taro.scanCode({
onlyFromCamera: true,
scanType: ['qrCode', 'barCode'],
success: async (res) => {
setIsScanning(false)
const deviceId = res.result
if (!deviceId) {
Taro.showToast({ title: '无效的设备码', icon: 'none' })
Taro.showToast({ title: 'Invalid device code', icon: 'none' })
return
}
@@ -77,98 +123,79 @@ export default function Bind() {
if (err.errMsg && err.errMsg.includes('cancel')) {
return
}
Taro.showToast({ title: '扫码失败', icon: 'none' })
}
Taro.showToast({ title: 'Scan failed', icon: 'none' })
},
})
}
const doBind = async (deviceId: string) => {
if (children.length === 0) {
Taro.showToast({ title: '请先添加儿童', icon: 'none' })
const handleManualInput = () => {
if (pendingDeviceId) {
Taro.showToast({ title: 'Complete child setup first', icon: 'none' })
return
}
Taro.showLoading({ title: '绑定中...' })
try {
await directBind({
device_id: deviceId,
child_id: children[0].child_id
})
Taro.hideLoading()
Taro.showToast({
title: '设备绑定成功',
icon: 'success',
duration: 1500,
complete: () => {
setTimeout(() => {
Taro.reLaunch({ url: '/pages/device/index' })
}, 500)
}
})
} catch (err: any) {
Taro.hideLoading()
Taro.showToast({ title: err.message || '绑定失败', icon: 'none' })
}
}
const handleManualInput = () => {
Taro.showModal({
title: '手动输入设备码',
title: 'Manual input',
editable: true,
placeholderText: '请输入设备底部的设备码',
placeholderText: 'Enter device code',
success: (res) => {
if (res.confirm && res.content) {
doBind(res.content)
}
}
},
})
}
const handleAddChild = async () => {
if (!newChildName.trim()) {
Taro.showToast({ title: '请输入儿童姓名', icon: 'none' })
Taro.showToast({ title: 'Please enter child name', icon: 'none' })
return
}
setSaving(true)
try {
await createChild({ child_name: newChildName.trim() })
Taro.showToast({ title: '添加成功', icon: 'success' })
const child = await createChild({ child_name: newChildName.trim() })
setNewChildName('')
if (pendingDeviceId) {
await completeChildLink(pendingDeviceId, child.child_id)
Taro.showToast({ title: 'Child linked', icon: 'success' })
goDevicePage()
return
}
const childRes = await getChildren()
setChildren(childRes.items || [])
Taro.showToast({ title: 'Child created', icon: 'success' })
} catch (err) {
Taro.showToast({ title: '添加失败', icon: 'none' })
Taro.showToast({ title: 'Create child failed', icon: 'none' })
} finally {
setSaving(false)
}
}
const handleLogout = () => {
Taro.showModal({
title: 'Logout',
content: 'Clear login info and go to login page?',
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' })
},
})
}
if (loading) {
return (
<View className='bind-page'>
<View className='loading'>...</View>
</View>
)
}
if (children.length === 0) {
return (
<View className='bind-page'>
<View className='empty-tip'>
<Text className='empty-title'></Text>
<Text className='empty-desc'></Text>
<View className='empty-input-wrap'>
<Input
className='empty-input'
placeholder='请输入儿童姓名'
value={newChildName}
onInput={(e) => setNewChildName(e.detail.value)}
/>
</View>
<View className='empty-btn' onClick={handleAddChild}>
<Text className='empty-btn-text'>{saving ? '保存中...' : '确认添加'}</Text>
</View>
</View>
<View className='loading'>Loading...</View>
</View>
)
}
@@ -176,8 +203,8 @@ export default function Bind() {
return (
<View className='bind-page'>
<View className='bind-header'>
<Text className='title'></Text>
<Text className='subtitle'></Text>
<Text className='title'>Bind Device</Text>
<Text className='subtitle'>Scan QR/device code to bind first, then set child profile.</Text>
</View>
<View className='scan-area'>
@@ -189,8 +216,8 @@ export default function Bind() {
mode='aspectFit'
/>
</View>
<Text className='scan-text'>{isScanning ? '正在扫码...' : '点击扫码'}</Text>
<Text className='scan-hint'></Text>
<Text className='scan-text'>{isScanning ? 'Scanning...' : 'Tap to scan'}</Text>
<Text className='scan-hint'>Place QR/barcode in frame</Text>
</View>
<View className='scan-corners'>
@@ -203,27 +230,51 @@ export default function Bind() {
<View className='bind-options'>
<View className='option-item' onClick={handleManualInput}>
<Text className='option-icon'></Text>
<Text className='option-text'></Text>
<Text className='option-arrow'></Text>
<Text className='option-icon'>#</Text>
<Text className='option-text'>Manual device code input</Text>
<Text className='option-arrow'>{'>'}</Text>
</View>
</View>
{pendingDeviceId && (
<View className='empty-tip'>
<Text className='empty-title'>Device bound: {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 className='empty-btn' onClick={handleAddChild}>
<Text className='empty-btn-text'>{saving ? 'Saving...' : 'Create and link child'}</Text>
</View>
</View>
)}
<View className='bind-tips'>
<Text className='tips-title'></Text>
<Text className='tips-title'>Binding Tips</Text>
<View className='tip-item'>
<Text className='tip-number'>1</Text>
<Text className='tip-text'></Text>
<Text className='tip-text'>Find QR code or device code on device.</Text>
</View>
<View className='tip-item'>
<Text className='tip-number'>2</Text>
<Text className='tip-text'></Text>
<Text className='tip-text'>Bind device first by scan/manual input.</Text>
</View>
<View className='tip-item'>
<Text className='tip-number'>3</Text>
<Text className='tip-text'></Text>
<Text className='tip-text'>Create or select child profile to finish setup.</Text>
</View>
</View>
<View className='bind-footer'>
<View className='logout-btn' onClick={handleLogout}>
<Text className='logout-btn-text'>Logout</Text>
</View>
</View>
</View>
)
}
}

View File

@@ -40,6 +40,15 @@ export default function Device() {
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
}
setHasDevice(true)
Taro.setStorageSync('hasDevice', '1')
Taro.setStorageSync('deviceInfo', {

View File

@@ -15,7 +15,7 @@ interface Child {
interface Binding {
device_id: string
child_id: number
child_id: number | null
status: number
}
@@ -285,4 +285,4 @@ export default function Sleep() {
)}
</View>
)
}
}

View File

@@ -2,7 +2,7 @@ import { request } from './api'
export interface Binding {
device_id: string
child_id: number
child_id: number | null
status: number
bound_at: string
}
@@ -14,7 +14,7 @@ export interface BindStartResponse {
export interface BindHistoryItem {
device_id: string
child_id: number
child_id: number | null
bound_at: string
unbound_at?: string
}
@@ -40,7 +40,7 @@ export async function getCurrentBinding(): Promise<Binding | null> {
// 开始绑定设备
export async function startBind(data: {
device_id: string
child_id: number
child_id?: number
}): Promise<BindStartResponse> {
return request<BindStartResponse>('/bindings/start', {
method: 'POST',
@@ -52,7 +52,7 @@ export async function startBind(data: {
export async function confirmBind(data: {
bind_token: string
challenge_code: string
}): Promise<{ device_id: string; child_id: number }> {
}): Promise<{ device_id: string; child_id: number | null }> {
return request('/bindings/confirm', {
method: 'POST',
data,
@@ -83,14 +83,25 @@ export async function unbindDevice(deviceId: string): Promise<void> {
// 直接绑定设备(手动输入设备码)
export async function directBind(data: {
device_id: string
child_id: number
}): Promise<{ device_id: string; child_id: number }> {
child_id?: number
}): Promise<{ device_id: string; child_id: number | null }> {
return request('/bindings/direct', {
method: 'POST',
data,
})
}
// Bind child profile after device has been bound to parent account.
export async function setBindingChild(
deviceId: string,
data: { child_id: number }
): Promise<{ device_id: string; child_id: number | null }> {
return request(`/bindings/${deviceId}/child`, {
method: 'PATCH',
data,
})
}
// 获取绑定历史
export async function getBindHistory(
deviceId: string,
@@ -102,4 +113,4 @@ export async function getBindHistory(
params.append('limit', String(limit))
return request<BindHistoryResponse>(`/bindings/history/${deviceId}?${params.toString()}`)
}
}