上传小程序修改合入
This commit is contained in:
@@ -5,7 +5,7 @@ module.exports = {
|
|||||||
defineConstants: {
|
defineConstants: {
|
||||||
__APP_ENV__: '"development"',
|
__APP_ENV__: '"development"',
|
||||||
// Update this to your LAN backend when testing on a real device.
|
// Update this to your LAN backend when testing on a real device.
|
||||||
__API_BASE_URL__: '"http://192.168.101.86:8001"',
|
__API_BASE_URL__: '"https://banban.api.talkingq.com"',
|
||||||
},
|
},
|
||||||
mini: {},
|
mini: {},
|
||||||
h5: {}
|
h5: {}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ module.exports = {
|
|||||||
defineConstants: {
|
defineConstants: {
|
||||||
__APP_ENV__: '"production"',
|
__APP_ENV__: '"production"',
|
||||||
// Replace with the production backend before release builds.
|
// Replace with the production backend before release builds.
|
||||||
__API_BASE_URL__: '"http://192.168.101.86:8001"',
|
__API_BASE_URL__: '"https://banban.api.talkingq.com"',
|
||||||
},
|
},
|
||||||
mini: {},
|
mini: {},
|
||||||
h5: {}
|
h5: {}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export interface AppConfig {
|
|||||||
|
|
||||||
export const APP_CONFIG: AppConfig = {
|
export const APP_CONFIG: AppConfig = {
|
||||||
appEnv: typeof __APP_ENV__ === 'undefined' ? 'development' : __APP_ENV__,
|
appEnv: typeof __APP_ENV__ === 'undefined' ? 'development' : __APP_ENV__,
|
||||||
apiBaseUrl: typeof __API_BASE_URL__ === 'undefined' ? 'http://127.0.0.1:8001' : __API_BASE_URL__,
|
apiBaseUrl: typeof __API_BASE_URL__ === 'undefined' ? 'https://banban.api.talkingq.com' : __API_BASE_URL__,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const API_BASE_URL = APP_CONFIG.apiBaseUrl
|
export const API_BASE_URL = APP_CONFIG.apiBaseUrl
|
||||||
|
|||||||
@@ -218,6 +218,13 @@
|
|||||||
color: #D46B08;
|
color: #D46B08;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pending-tip-subtext {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #8C5400;
|
||||||
|
}
|
||||||
|
|
||||||
.child-list {
|
.child-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -1,11 +1,31 @@
|
|||||||
import { View, Text, Image, Input, Button } from '@tarojs/components'
|
import { View, Text, Image, Input, Button } from '@tarojs/components'
|
||||||
import { useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import Taro, { useDidShow } from '@tarojs/taro'
|
import Taro, { useDidShow, useDidHide } from '@tarojs/taro'
|
||||||
|
|
||||||
import { getToken } from '@/services/auth'
|
import { getToken } from '@/services/auth'
|
||||||
import { directBind, resolveActiveBinding, setBindingChild, setSelectedBindingDeviceId } from '@/services/binding'
|
import {
|
||||||
import { Child, createChild, getChildren } from '@/services/child'
|
getNFCBindSession,
|
||||||
|
resolveActiveBinding,
|
||||||
|
setBindingChild,
|
||||||
|
setSelectedBindingDeviceId,
|
||||||
|
startNFCBind,
|
||||||
|
} from '@/services/binding'
|
||||||
|
import {
|
||||||
|
Child,
|
||||||
|
createChild,
|
||||||
|
getChildren,
|
||||||
|
resolveChildSelection,
|
||||||
|
setSelectedChildId as setStoredSelectedChildId,
|
||||||
|
} from '@/services/child'
|
||||||
|
|
||||||
import './index.scss'
|
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 } {
|
function parseBindingPayload(rawValue: string): { deviceId: string; serialNumber: string } {
|
||||||
const raw = String(rawValue || '').trim()
|
const raw = String(rawValue || '').trim()
|
||||||
if (!raw) return { deviceId: '', serialNumber: '' }
|
if (!raw) return { deviceId: '', serialNumber: '' }
|
||||||
@@ -71,15 +91,99 @@ export default function Bind() {
|
|||||||
const [selectedChildId, setSelectedChildId] = useState<number | null>(null)
|
const [selectedChildId, setSelectedChildId] = useState<number | null>(null)
|
||||||
const [newChildName, setNewChildName] = useState('')
|
const [newChildName, setNewChildName] = useState('')
|
||||||
const [pendingDeviceId, setPendingDeviceId] = useState<string | null>(null)
|
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(() => {
|
useDidShow(() => {
|
||||||
void loadPageData()
|
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 = () => {
|
const goDevicePage = () => {
|
||||||
Taro.reLaunch({ url: '/pages/device/index' })
|
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 () => {
|
const loadPageData = async () => {
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
Taro.reLaunch({ url: '/pages/login/index' })
|
Taro.reLaunch({ url: '/pages/login/index' })
|
||||||
@@ -90,17 +194,15 @@ export default function Bind() {
|
|||||||
try {
|
try {
|
||||||
const [childResponse, activeBinding] = await Promise.all([getChildren(), resolveActiveBinding()])
|
const [childResponse, activeBinding] = await Promise.all([getChildren(), resolveActiveBinding()])
|
||||||
const currentChildren = childResponse.items || []
|
const currentChildren = childResponse.items || []
|
||||||
|
const currentChild = resolveChildSelection(currentChildren)
|
||||||
setChildren(currentChildren)
|
setChildren(currentChildren)
|
||||||
setSelectedChildId((currentSelectedChildId) => currentSelectedChildId || currentChildren[0]?.child_id || null)
|
setSelectedChildId(currentChild?.child_id || null)
|
||||||
|
|
||||||
if (activeBinding?.device_id && !activeBinding.child_id) {
|
if (activeBinding?.device_id && !activeBinding.child_id) {
|
||||||
setPendingDeviceId(activeBinding.device_id)
|
setPendingDeviceId(activeBinding.device_id)
|
||||||
setDeviceId(activeBinding.device_id)
|
setDeviceId(activeBinding.device_id)
|
||||||
} else {
|
} else {
|
||||||
setPendingDeviceId(null)
|
setPendingDeviceId(null)
|
||||||
setDeviceId('')
|
|
||||||
setSerialNumber('')
|
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[bind] load failed:', error)
|
console.error('[bind] load failed:', error)
|
||||||
@@ -122,13 +224,21 @@ export default function Bind() {
|
|||||||
const child = await createChild({ child_name: childName })
|
const child = await createChild({ child_name: childName })
|
||||||
setChildren((currentChildren) => [child, ...currentChildren])
|
setChildren((currentChildren) => [child, ...currentChildren])
|
||||||
setSelectedChildId(child.child_id)
|
setSelectedChildId(child.child_id)
|
||||||
|
setStoredSelectedChildId(child.child_id)
|
||||||
setNewChildName('')
|
setNewChildName('')
|
||||||
return child.child_id
|
return child.child_id
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleScanCode = () => {
|
const handleScanCode = () => {
|
||||||
setIsScanning(true)
|
if (!canEditDeviceFields) {
|
||||||
|
Taro.showToast({
|
||||||
|
title: '请先选择或新建儿童资料',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsScanning(true)
|
||||||
Taro.scanCode({
|
Taro.scanCode({
|
||||||
onlyFromCamera: true,
|
onlyFromCamera: true,
|
||||||
scanType: ['qrCode', 'barCode'],
|
scanType: ['qrCode', 'barCode'],
|
||||||
@@ -145,11 +255,7 @@ export default function Bind() {
|
|||||||
},
|
},
|
||||||
fail: (err) => {
|
fail: (err) => {
|
||||||
setIsScanning(false)
|
setIsScanning(false)
|
||||||
|
if (err.errMsg && err.errMsg.includes('cancel')) return
|
||||||
if (err.errMsg && err.errMsg.includes('cancel')) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Taro.showToast({
|
Taro.showToast({
|
||||||
title: '扫码失败,请重试',
|
title: '扫码失败,请重试',
|
||||||
icon: 'none',
|
icon: 'none',
|
||||||
@@ -159,22 +265,21 @@ export default function Bind() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (submitting) return
|
if (submitting || isPollingBind) return
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
Taro.showLoading({ title: pendingDeviceId ? '关联中...' : '绑定中...' })
|
Taro.showLoading({ title: isPendingBinding ? '关联中...' : '发送绑卡指令...' })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const childId = await ensureChildId()
|
const childId = await ensureChildId()
|
||||||
|
if (!childId) {
|
||||||
|
throw new Error('请先选择儿童或填写新的儿童昵称')
|
||||||
|
}
|
||||||
|
|
||||||
if (pendingDeviceId) {
|
if (isPendingBinding) {
|
||||||
if (!childId) {
|
await setBindingChild(pendingDeviceId!, { child_id: childId })
|
||||||
throw new Error('请选择儿童或填写新儿童昵称')
|
|
||||||
}
|
|
||||||
|
|
||||||
await setBindingChild(pendingDeviceId, { child_id: childId })
|
|
||||||
setSelectedBindingDeviceId(pendingDeviceId)
|
setSelectedBindingDeviceId(pendingDeviceId)
|
||||||
Taro.showToast({ title: '绑定完成', icon: 'success' })
|
Taro.showToast({ title: '关联完成', icon: 'success' })
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
goDevicePage()
|
goDevicePage()
|
||||||
}, 300)
|
}, 300)
|
||||||
@@ -188,30 +293,21 @@ export default function Bind() {
|
|||||||
throw new Error('请输入设备序列号')
|
throw new Error('请输入设备序列号')
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await directBind({
|
resetBindSessionState()
|
||||||
|
const session = await startNFCBind({
|
||||||
device_id: deviceId.trim(),
|
device_id: deviceId.trim(),
|
||||||
serial_number: serialNumber.trim(),
|
serial_number: serialNumber.trim(),
|
||||||
...(childId ? { child_id: childId } : {}),
|
child_id: childId,
|
||||||
})
|
})
|
||||||
|
|
||||||
setSelectedBindingDeviceId(result.device_id)
|
setBindToken(session.bind_token)
|
||||||
|
setBindStatus(session.status)
|
||||||
if (childId || result.child_id) {
|
setBindHint('已发送绑卡指令,请去设备上贴自己的卡')
|
||||||
Taro.showToast({ title: '设备绑定成功', icon: 'success' })
|
schedulePoll()
|
||||||
setTimeout(() => {
|
Taro.showToast({ title: '请去设备上贴卡', icon: 'none' })
|
||||||
goDevicePage()
|
|
||||||
}, 300)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setPendingDeviceId(result.device_id)
|
|
||||||
setDeviceId(result.device_id)
|
|
||||||
Taro.showToast({
|
|
||||||
title: '设备已绑定,请继续关联儿童',
|
|
||||||
icon: 'none',
|
|
||||||
})
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[bind] submit failed:', error)
|
console.error('[bind] submit failed:', error)
|
||||||
|
setBindHint(error?.message || '绑定失败,请重试')
|
||||||
Taro.showToast({
|
Taro.showToast({
|
||||||
title: error?.message || '绑定失败,请重试',
|
title: error?.message || '绑定失败,请重试',
|
||||||
icon: 'none',
|
icon: 'none',
|
||||||
@@ -235,23 +331,25 @@ 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'>{pendingDeviceId ? '补全绑定' : '绑定设备'}</Text>
|
<Text className='title'>{isPendingBinding ? '补全绑定' : '扫码贴卡绑定设备'}</Text>
|
||||||
<Text className='subtitle'>
|
<Text className='subtitle'>
|
||||||
{pendingDeviceId ? '设备已绑到当前账号,请补充儿童资料完成配置' : '扫描二维码或手动填写设备号和序列号'}
|
{isPendingBinding
|
||||||
|
? '当前设备已存在待补全关系,请先补充儿童资料'
|
||||||
|
: '先选择儿童,再扫码设备,系统会下发绑卡指令,随后去设备上贴卡确认'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='scan-area'>
|
<View className='scan-area'>
|
||||||
<View className='scan-frame' onClick={handleScanCode}>
|
<View className='scan-frame' onClick={handleScanCode}>
|
||||||
<View className='icon-bg orange'>
|
<View className='icon-bg orange'>
|
||||||
<Image
|
<Image className='control-icon-img' src={require('../../assets/tab-icons/rings.png')} mode='aspectFit' />
|
||||||
className='control-icon-img'
|
|
||||||
src={require('../../assets/tab-icons/rings.png')}
|
|
||||||
mode='aspectFit'
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
<Text className='scan-text'>{isScanning ? '正在扫码...' : '点击扫码'}</Text>
|
<Text className='scan-text'>
|
||||||
<Text className='scan-hint'>请将二维码放入框内</Text>
|
{!canEditDeviceFields ? '请先完成儿童资料' : isScanning ? '正在扫码...' : '点击扫码'}
|
||||||
|
</Text>
|
||||||
|
<Text className='scan-hint'>
|
||||||
|
{!canEditDeviceFields ? '先选儿童,再扫设备二维码' : '扫描设备二维码或条码'}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='scan-corners'>
|
<View className='scan-corners'>
|
||||||
@@ -264,39 +362,7 @@ export default function Bind() {
|
|||||||
|
|
||||||
<View className='bind-form'>
|
<View className='bind-form'>
|
||||||
<View className='form-card'>
|
<View className='form-card'>
|
||||||
<Text className='form-title'>设备信息</Text>
|
<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>
|
|
||||||
|
|
||||||
{children.length > 0 && (
|
{children.length > 0 && (
|
||||||
<View className='child-list'>
|
<View className='child-list'>
|
||||||
@@ -305,7 +371,11 @@ export default function Bind() {
|
|||||||
key={child.child_id}
|
key={child.child_id}
|
||||||
className={`child-item ${selectedChildId === child.child_id ? 'selected' : ''}`}
|
className={`child-item ${selectedChildId === child.child_id ? 'selected' : ''}`}
|
||||||
onClick={() => {
|
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('')
|
setNewChildName('')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -325,16 +395,65 @@ export default function Bind() {
|
|||||||
setNewChildName(event.detail.value)
|
setNewChildName(event.detail.value)
|
||||||
if (event.detail.value) {
|
if (event.detail.value) {
|
||||||
setSelectedChildId(null)
|
setSelectedChildId(null)
|
||||||
|
setStoredSelectedChildId(null)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text className='field-hint'>优先选择已有儿童,也可以直接输入一个新昵称。</Text>
|
<Text className='field-hint'>
|
||||||
|
{children.length > 0
|
||||||
|
? '可以选已有儿童,也可以在这里新建一个儿童后再绑定设备'
|
||||||
|
: '当前还没有儿童资料,请先创建一个儿童资料'}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Button className='submit-btn' loading={submitting} disabled={submitting} onClick={handleSubmit}>
|
<View className='form-card'>
|
||||||
{pendingDeviceId ? '完成儿童关联' : '绑定设备'}
|
<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>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -342,15 +461,15 @@ export default function Bind() {
|
|||||||
<Text className='tips-title'>绑定帮助</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'>先扫描二维码,自动填入设备号和序列号。</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'>如果二维码里只有设备号,请手动补充序列号。</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'>没有儿童资料时,可以直接在本页新建并完成绑定。</Text>
|
<Text className='tip-text'>点击发送绑卡指令,然后去设备上贴自己的卡完成确认</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -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 {
|
.empty-chat {
|
||||||
min-height: 320px;
|
min-height: 320px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { View, Text, ScrollView, Image, Input } from '@tarojs/components'
|
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 Taro, { useRouter } from '@tarojs/taro'
|
||||||
import {
|
import {
|
||||||
getMessages,
|
getMessages,
|
||||||
@@ -47,6 +47,11 @@ function formatParticipantLabel(message: ChatMessage): string {
|
|||||||
return `${senderType} ${senderId}`
|
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() {
|
export default function ChatDetail() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const params = router.params
|
const params = router.params
|
||||||
@@ -85,6 +90,29 @@ export default function ChatDetail() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [inputText, setInputText] = useState('')
|
const [inputText, setInputText] = useState('')
|
||||||
const [sending, setSending] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
void loadChatData(activeConversationId)
|
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 (
|
return (
|
||||||
<View className='chat-detail-page'>
|
<View className='chat-detail-page'>
|
||||||
<View className='chat-header'>
|
<View className='chat-header'>
|
||||||
@@ -199,7 +267,7 @@ export default function ChatDetail() {
|
|||||||
<View className='message-main'>
|
<View className='message-main'>
|
||||||
<Text className='message-meta'>{formatParticipantLabel(msg)}</Text>
|
<Text className='message-meta'>{formatParticipantLabel(msg)}</Text>
|
||||||
<View className='message-bubble'>
|
<View className='message-bubble'>
|
||||||
<Text>{msg.content}</Text>
|
{renderMessageBody(msg)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className='user-avatar'>
|
<View className='user-avatar'>
|
||||||
@@ -214,7 +282,7 @@ export default function ChatDetail() {
|
|||||||
<View className='message-main'>
|
<View className='message-main'>
|
||||||
<Text className='message-meta'>{formatParticipantLabel(msg)}</Text>
|
<Text className='message-meta'>{formatParticipantLabel(msg)}</Text>
|
||||||
<View className='message-bubble'>
|
<View className='message-bubble'>
|
||||||
<Text>{msg.content}</Text>
|
{renderMessageBody(msg)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default function Chat() {
|
|||||||
</View>
|
</View>
|
||||||
) : conversations.length === 0 ? (
|
) : conversations.length === 0 ? (
|
||||||
<View className='empty-card'>
|
<View className='empty-card'>
|
||||||
<Text className='empty-title'>暂无玩伴会话</Text>
|
<Text className='empty-title'>当前孩子暂无会话</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<ScrollView className='conversation-list' scrollY>
|
<ScrollView className='conversation-list' scrollY>
|
||||||
|
|||||||
@@ -348,6 +348,14 @@
|
|||||||
color: #1A1A1A;
|
color: #1A1A1A;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty-subtitle {
|
||||||
|
display: block;
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-btn {
|
.empty-btn {
|
||||||
margin-top: 28px;
|
margin-top: 28px;
|
||||||
height: 92px;
|
height: 92px;
|
||||||
@@ -378,3 +386,46 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1A1A1A;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { View, Text, Switch, Slider, Image } from '@tarojs/components'
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import Taro, { useDidShow } from '@tarojs/taro'
|
import Taro, { useDidShow } from '@tarojs/taro'
|
||||||
import { getToken } from '@/services/auth'
|
import { getToken } from '@/services/auth'
|
||||||
import { Binding, resolveActiveBinding } from '@/services/binding'
|
import { Binding, loadCurrentChildBindingContext } from '@/services/binding'
|
||||||
import { Child, getChild } from '@/services/child'
|
import { Child } from '@/services/child'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
export default function Device() {
|
export default function Device() {
|
||||||
@@ -25,20 +25,9 @@ export default function Device() {
|
|||||||
|
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const activeBinding = await resolveActiveBinding()
|
const context = await loadCurrentChildBindingContext()
|
||||||
setBinding(activeBinding)
|
setChild(context.currentChild)
|
||||||
|
setBinding(context.currentBinding)
|
||||||
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)
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[device] load failed:', error)
|
console.error('[device] load failed:', error)
|
||||||
Taro.showToast({
|
Taro.showToast({
|
||||||
@@ -72,7 +61,7 @@ export default function Device() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!binding) {
|
if (!child) {
|
||||||
return (
|
return (
|
||||||
<View className='device-page'>
|
<View className='device-page'>
|
||||||
<View className='page-header'>
|
<View className='page-header'>
|
||||||
@@ -83,7 +72,39 @@ export default function Device() {
|
|||||||
<View className='empty-robot'>
|
<View className='empty-robot'>
|
||||||
<Image className='robot-img' src={require('../../assets/tab-icons/robot.png')} mode='aspectFit' />
|
<Image className='robot-img' src={require('../../assets/tab-icons/robot.png')} mode='aspectFit' />
|
||||||
</View>
|
</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' })}>
|
<View className='empty-btn' onClick={() => Taro.navigateTo({ url: '/pages/bind/index' })}>
|
||||||
<Text className='empty-btn-text'>去绑定设备</Text>
|
<Text className='empty-btn-text'>去绑定设备</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -92,8 +113,8 @@ export default function Device() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const childName = child?.child_name || '未设置'
|
const childName = child.child_name || '未设置'
|
||||||
const pageTitle = child?.child_name ? `${child.child_name} 的伴伴` : '伴伴设备'
|
const pageTitle = `${childName} 的伴伴`
|
||||||
const battery = 82
|
const battery = 82
|
||||||
const daysLeft = 4
|
const daysLeft = 4
|
||||||
|
|
||||||
@@ -106,7 +127,7 @@ export default function Device() {
|
|||||||
<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'>{binding.child_id ? '已完成绑定' : '待关联儿童'}</Text>
|
<Text className='status-text'>已完成绑定</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='battery-section'>
|
<View className='battery-section'>
|
||||||
<Text className='battery-percent'>
|
<Text className='battery-percent'>
|
||||||
@@ -124,12 +145,12 @@ export default function Device() {
|
|||||||
|
|
||||||
<View className='detail-card'>
|
<View className='detail-card'>
|
||||||
<View className='detail-row'>
|
<View className='detail-row'>
|
||||||
<Text className='detail-label'>设备号</Text>
|
<Text className='detail-label'>当前儿童</Text>
|
||||||
<Text className='detail-value'>{binding.device_id}</Text>
|
<Text className='detail-value'>{childName}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='detail-row'>
|
<View className='detail-row'>
|
||||||
<Text className='detail-label'>儿童昵称</Text>
|
<Text className='detail-label'>当前设备</Text>
|
||||||
<Text className='detail-value'>{childName}</Text>
|
<Text className='detail-value'>{binding.device_id}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='detail-row'>
|
<View className='detail-row'>
|
||||||
<Text className='detail-label'>绑定时间</Text>
|
<Text className='detail-label'>绑定时间</Text>
|
||||||
@@ -195,12 +216,6 @@ export default function Device() {
|
|||||||
</View>
|
</View>
|
||||||
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ const DEFAULT_COORDINATES = {
|
|||||||
|
|
||||||
const MODE_OPTIONS: Array<{ key: TrajectoryMode; label: string }> = [
|
const MODE_OPTIONS: Array<{ key: TrajectoryMode; label: string }> = [
|
||||||
{ key: 'current', label: '当前位置' },
|
{ key: 'current', label: '当前位置' },
|
||||||
{ key: 'today', label: '今日轨迹' },
|
// { key: 'today', label: '今日轨迹' },
|
||||||
{ key: 'recent', label: '最近点位' },
|
// { key: 'recent', label: '最近点位' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function formatTime(value?: string | null): string {
|
function formatTime(value?: string | null): string {
|
||||||
@@ -339,7 +339,7 @@ export default function Location() {
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View className='location-empty'>
|
<View className='location-empty'>
|
||||||
<Text className='location-empty-title'>暂无设备位置</Text>
|
<Text className='location-empty-title'>当前孩子暂无设备位置</Text>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
) : trajectory.length > 0 ? (
|
) : trajectory.length > 0 ? (
|
||||||
@@ -384,7 +384,7 @@ export default function Location() {
|
|||||||
<View className='location-empty'>
|
<View className='location-empty'>
|
||||||
<Text className='location-empty-title'>暂无轨迹</Text>
|
<Text className='location-empty-title'>暂无轨迹</Text>
|
||||||
<Text className='location-empty-desc'>
|
<Text className='location-empty-desc'>
|
||||||
{trajectoryMode === 'today' ? '今天还没有新的历史点位。' : '最近时间范围内没有新的历史点位。'}
|
{trajectoryMode === 'today' ? '当前孩子今天还没有新的历史点位。' : '当前孩子最近没有新的历史点位。'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -62,9 +62,11 @@ export default function Login() {
|
|||||||
}, 300)
|
}, 300)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[login] failed:', error)
|
console.error('[login] failed:', error)
|
||||||
Taro.showToast({
|
const message = String(error?.message || '登录失败,请重试')
|
||||||
title: error?.message || '登录失败,请重试',
|
Taro.showModal({
|
||||||
icon: 'none',
|
title: '登录失败',
|
||||||
|
content: message,
|
||||||
|
showCancel: false,
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
Taro.hideLoading()
|
Taro.hideLoading()
|
||||||
|
|||||||
@@ -5,13 +5,11 @@ import { clearToken, getCurrentUserId, getToken } from '@/services/auth'
|
|||||||
import {
|
import {
|
||||||
BindingListItem,
|
BindingListItem,
|
||||||
clearSelectedBindingDeviceId,
|
clearSelectedBindingDeviceId,
|
||||||
getBindings,
|
loadCurrentChildBindingContext,
|
||||||
resolveBindingSelection,
|
|
||||||
setBindingChild,
|
|
||||||
setSelectedBindingDeviceId,
|
setSelectedBindingDeviceId,
|
||||||
unbindDevice,
|
unbindDevice,
|
||||||
} from '@/services/binding'
|
} from '@/services/binding'
|
||||||
import { Child, createChild, getChildren, updateChild } from '@/services/child'
|
import { Child, clearSelectedChildId, createChild, setSelectedChildId, updateChild } from '@/services/child'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
interface MenuItem {
|
interface MenuItem {
|
||||||
@@ -28,8 +26,9 @@ export default function Sleep() {
|
|||||||
const [children, setChildren] = useState<Child[]>([])
|
const [children, setChildren] = useState<Child[]>([])
|
||||||
const [bindings, setBindings] = useState<BindingListItem[]>([])
|
const [bindings, setBindings] = useState<BindingListItem[]>([])
|
||||||
const [binding, setBinding] = useState<BindingListItem | null>(null)
|
const [binding, setBinding] = useState<BindingListItem | null>(null)
|
||||||
|
const [currentChild, setCurrentChild] = useState<Child | null>(null)
|
||||||
const [showModal, setShowModal] = useState(false)
|
const [showModal, setShowModal] = useState(false)
|
||||||
const [showDeviceModal, setShowDeviceModal] = useState(false)
|
const [showChildModal, setShowChildModal] = 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)
|
||||||
@@ -47,13 +46,11 @@ export default function Sleep() {
|
|||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const [childResponse, bindingResponse] = await Promise.all([getChildren(), getBindings(undefined, 100)])
|
const context = await loadCurrentChildBindingContext()
|
||||||
const bindingItems = bindingResponse.items || []
|
setChildren(context.children)
|
||||||
const activeBinding = resolveBindingSelection(bindingItems)
|
setBindings(context.bindings as BindingListItem[])
|
||||||
|
setCurrentChild(context.currentChild)
|
||||||
setChildren(childResponse.items || [])
|
setBinding((context.currentBinding as BindingListItem | null) || null)
|
||||||
setBindings(bindingItems)
|
|
||||||
setBinding(activeBinding)
|
|
||||||
setParentInfo(Taro.getStorageSync('userInfo') || {})
|
setParentInfo(Taro.getStorageSync('userInfo') || {})
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[manage] load failed:', error)
|
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 || '未设置'
|
||||||
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)
|
||||||
@@ -82,12 +78,19 @@ export default function Sleep() {
|
|||||||
setEditingChildId(null)
|
setEditingChildId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelectBinding = (targetBinding: BindingListItem) => {
|
const handleSelectChild = (child: Child) => {
|
||||||
setSelectedBindingDeviceId(targetBinding.device_id)
|
setSelectedChildId(child.child_id)
|
||||||
setBinding(targetBinding)
|
const nextBinding = bindings.find((item) => item.child_id === child.child_id) || null
|
||||||
setShowDeviceModal(false)
|
if (nextBinding?.device_id) {
|
||||||
|
setSelectedBindingDeviceId(nextBinding.device_id)
|
||||||
|
} else {
|
||||||
|
clearSelectedBindingDeviceId()
|
||||||
|
}
|
||||||
|
setCurrentChild(child)
|
||||||
|
setBinding(nextBinding)
|
||||||
|
setShowChildModal(false)
|
||||||
Taro.showToast({
|
Taro.showToast({
|
||||||
title: '已切换设备',
|
title: '已切换当前孩子',
|
||||||
icon: 'success',
|
icon: 'success',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -102,12 +105,8 @@ export default function Sleep() {
|
|||||||
try {
|
try {
|
||||||
if (modalType === 'add') {
|
if (modalType === 'add') {
|
||||||
const createdChild = await createChild({ child_name: normalizedName })
|
const createdChild = await createChild({ child_name: normalizedName })
|
||||||
if (binding && !binding.child_id) {
|
setSelectedChildId(createdChild.child_id)
|
||||||
await setBindingChild(binding.device_id, { child_id: createdChild.child_id })
|
Taro.showToast({ title: '创建成功', icon: 'success' })
|
||||||
Taro.showToast({ title: '已创建并关联', icon: 'success' })
|
|
||||||
} else {
|
|
||||||
Taro.showToast({ title: '创建成功', icon: 'success' })
|
|
||||||
}
|
|
||||||
} else if (editingChildId) {
|
} else if (editingChildId) {
|
||||||
await updateChild(editingChildId, { child_name: normalizedName })
|
await updateChild(editingChildId, { child_name: normalizedName })
|
||||||
Taro.showToast({ title: '修改成功', icon: 'success' })
|
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 = () => {
|
const handleUnbind = () => {
|
||||||
if (!binding) return
|
if (!binding) return
|
||||||
|
|
||||||
Taro.showModal({
|
Taro.showModal({
|
||||||
title: '解除设备绑定',
|
title: '解除设备绑定',
|
||||||
content: '确定要解除当前设备绑定吗?',
|
content: `确定要解除 ${currentChildName} 当前绑定的设备吗?`,
|
||||||
confirmColor: '#FF8C42',
|
confirmColor: '#FF8C42',
|
||||||
success: async (result) => {
|
success: async (result) => {
|
||||||
if (!result.confirm) return
|
if (!result.confirm) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await unbindDevice(binding.device_id)
|
await unbindDevice(binding.device_id)
|
||||||
|
clearSelectedBindingDeviceId()
|
||||||
Taro.showToast({ title: '已解绑', icon: 'success' })
|
Taro.showToast({ title: '已解绑', icon: 'success' })
|
||||||
await loadData()
|
await loadData()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -193,6 +157,7 @@ export default function Sleep() {
|
|||||||
if (!result.confirm) return
|
if (!result.confirm) return
|
||||||
clearToken()
|
clearToken()
|
||||||
clearSelectedBindingDeviceId()
|
clearSelectedBindingDeviceId()
|
||||||
|
clearSelectedChildId()
|
||||||
Taro.removeStorageSync('userInfo')
|
Taro.removeStorageSync('userInfo')
|
||||||
Taro.reLaunch({ url: '/pages/login/index' })
|
Taro.reLaunch({ url: '/pages/login/index' })
|
||||||
},
|
},
|
||||||
@@ -202,21 +167,33 @@ export default function Sleep() {
|
|||||||
const handleMenuClick = (item: MenuItem) => {
|
const handleMenuClick = (item: MenuItem) => {
|
||||||
if (item.disabled) return
|
if (item.disabled) return
|
||||||
|
|
||||||
if (item.name === '儿童资料 (用于称呼)') {
|
if (item.name === '当前孩子') {
|
||||||
handleChildMenu()
|
setShowChildModal(true)
|
||||||
return
|
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' })
|
Taro.navigateTo({ url: '/pages/bind/index' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.name === '切换设备') {
|
|
||||||
setShowDeviceModal(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.name === '解除设备绑定') {
|
if (item.name === '解除设备绑定') {
|
||||||
handleUnbind()
|
handleUnbind()
|
||||||
}
|
}
|
||||||
@@ -237,24 +214,24 @@ export default function Sleep() {
|
|||||||
{
|
{
|
||||||
icon: require('../../assets/tab-icons/orange-robot.png'),
|
icon: require('../../assets/tab-icons/orange-robot.png'),
|
||||||
iconBgClass: 'orange',
|
iconBgClass: 'orange',
|
||||||
name: '儿童资料 (用于称呼)',
|
name: '当前孩子',
|
||||||
value: currentChildName,
|
value: currentChildName,
|
||||||
arrow: true,
|
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'),
|
icon: require('../../assets/tab-icons/orange-robot.png'),
|
||||||
iconBgClass: 'orange',
|
iconBgClass: 'orange',
|
||||||
name: '绑定新设备',
|
name: '编辑当前孩子',
|
||||||
value: '',
|
value: '',
|
||||||
arrow: true,
|
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'),
|
icon: require('../../assets/tab-icons/broken-rings.png'),
|
||||||
iconBgClass: 'red',
|
iconBgClass: 'red',
|
||||||
@@ -284,23 +261,23 @@ export default function Sleep() {
|
|||||||
<Text className='user-role'>用户 ID: {userId || '--'}</Text>
|
<Text className='user-role'>用户 ID: {userId || '--'}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className='verified-badge'>
|
<View className='verified-badge'>
|
||||||
<Text>{bindings.length > 0 ? `已绑定 ${bindings.length} 台` : '未绑定设备'}</Text>
|
<Text>{currentChild ? '已选择当前孩子' : '未选择孩子'}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='summary-card'>
|
<View className='summary-card'>
|
||||||
<View className='summary-row'>
|
<View className='summary-row'>
|
||||||
<Text className='summary-label'>已绑定设备数</Text>
|
<Text className='summary-label'>儿童资料数</Text>
|
||||||
<Text className='summary-value'>{bindings.length} 台</Text>
|
<Text className='summary-value'>{children.length} 个</Text>
|
||||||
</View>
|
|
||||||
<View className='summary-row'>
|
|
||||||
<Text className='summary-label'>当前设备</Text>
|
|
||||||
<Text className='summary-value'>{binding?.device_id || '未绑定'}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<View className='summary-row'>
|
<View className='summary-row'>
|
||||||
<Text className='summary-label'>当前儿童</Text>
|
<Text className='summary-label'>当前儿童</Text>
|
||||||
<Text className='summary-value'>{currentChildName}</Text>
|
<Text className='summary-value'>{currentChildName}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<View className='summary-row'>
|
||||||
|
<Text className='summary-label'>当前设备</Text>
|
||||||
|
<Text className='summary-value'>{binding?.device_id || '未绑定'}</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='menu-card'>
|
<View className='menu-card'>
|
||||||
@@ -333,39 +310,40 @@ export default function Sleep() {
|
|||||||
<Text className='version-text'>伴伴 Companion V1.1.0</Text>
|
<Text className='version-text'>伴伴 Companion V1.1.0</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{showDeviceModal && (
|
{showChildModal && (
|
||||||
<View className='modal-mask' onClick={() => setShowDeviceModal(false)}>
|
<View className='modal-mask' onClick={() => setShowChildModal(false)}>
|
||||||
<View
|
<View
|
||||||
className='modal-card device-switch-card'
|
className='modal-card device-switch-card'
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text className='modal-title'>切换设备</Text>
|
<Text className='modal-title'>切换当前孩子</Text>
|
||||||
{bindings.length === 0 ? (
|
{children.length === 0 ? (
|
||||||
<Text className='device-switch-empty'>当前还没有已绑定设备</Text>
|
<Text className='device-switch-empty'>当前还没有儿童资料</Text>
|
||||||
) : (
|
) : (
|
||||||
<View className='device-switch-list'>
|
<View className='device-switch-list'>
|
||||||
{bindings.map((item) => {
|
{children.map((item) => {
|
||||||
const isActive = binding?.device_id === item.device_id
|
const isActive = currentChild?.child_id === item.child_id
|
||||||
|
const itemBinding = bindings.find((bindingItem) => bindingItem.child_id === item.child_id) || null
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
key={item.device_id}
|
key={item.child_id}
|
||||||
className={`device-switch-item ${isActive ? 'active' : ''}`}
|
className={`device-switch-item ${isActive ? 'active' : ''}`}
|
||||||
onClick={() => handleSelectBinding(item)}
|
onClick={() => handleSelectChild(item)}
|
||||||
>
|
>
|
||||||
<View className='device-switch-head'>
|
<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>}
|
{isActive && <Text className='device-switch-tag'>当前</Text>}
|
||||||
</View>
|
</View>
|
||||||
<Text className='device-switch-name'>{item.child_name || '待关联儿童'}</Text>
|
<Text className='device-switch-name'>{itemBinding?.device_id || '未绑定设备'}</Text>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<View className='modal-actions'>
|
<View className='modal-actions'>
|
||||||
<Text className='modal-action cancel' onClick={() => setShowDeviceModal(false)}>
|
<Text className='modal-action cancel' onClick={() => setShowChildModal(false)}>
|
||||||
关闭
|
关闭
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class ApiError extends Error {
|
|||||||
|
|
||||||
function getErrorDetail(data: any): string {
|
function getErrorDetail(data: any): string {
|
||||||
if (!data) return 'Request failed'
|
if (!data) return 'Request failed'
|
||||||
|
if (typeof data.errMsg === 'string') return data.errMsg
|
||||||
if (typeof data.detail === 'string') return data.detail
|
if (typeof data.detail === 'string') return data.detail
|
||||||
if (Array.isArray(data.detail)) {
|
if (Array.isArray(data.detail)) {
|
||||||
return data.detail.map((item: any) => String(item?.msg || item)).join('; ')
|
return data.detail.map((item: any) => String(item?.msg || item)).join('; ')
|
||||||
@@ -30,6 +31,8 @@ async function request<T>(
|
|||||||
} = {}
|
} = {}
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
|
const requestUrl = `${BASE_URL}${url}`
|
||||||
|
const requestMethod = options.method || 'GET'
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...options.headers,
|
...options.headers,
|
||||||
@@ -41,22 +44,19 @@ async function request<T>(
|
|||||||
let response
|
let response
|
||||||
try {
|
try {
|
||||||
response = await Taro.request({
|
response = await Taro.request({
|
||||||
url: `${BASE_URL}${url}`,
|
url: requestUrl,
|
||||||
method: options.method || 'GET',
|
method: requestMethod,
|
||||||
data: options.data,
|
data: options.data,
|
||||||
header: headers,
|
header: headers,
|
||||||
timeout: REQUEST_TIMEOUT_MS,
|
timeout: REQUEST_TIMEOUT_MS,
|
||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const message = String(error?.errMsg || error?.message || '')
|
const message = String(error?.errMsg || error?.message || 'request:fail')
|
||||||
const detail = message.includes('timeout')
|
throw new ApiError(0, message)
|
||||||
? '请求超时,请检查后端服务是否可访问'
|
|
||||||
: '网络请求失败,请检查后端地址和网络连接'
|
|
||||||
throw new ApiError(0, detail)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.statusCode === 401) {
|
if (response.statusCode === 401) {
|
||||||
handleUnauthorized({ redirect: !url.startsWith('/auth/') })
|
handleUnauthorized({ redirect: !url.startsWith('/banban/auth/') })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.statusCode >= 400) {
|
if (response.statusCode >= 400) {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export interface Parent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function wechatLogin(data: WechatLoginPayload): Promise<LoginSession> {
|
export async function wechatLogin(data: WechatLoginPayload): Promise<LoginSession> {
|
||||||
const session = await request<LoginSession>('/auth/login', {
|
const session = await request<LoginSession>('/banban/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data,
|
data,
|
||||||
})
|
})
|
||||||
@@ -39,14 +39,14 @@ export async function wechatLogin(data: WechatLoginPayload): Promise<LoginSessio
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getParent(userId: number): Promise<Parent> {
|
export async function getParent(userId: number): Promise<Parent> {
|
||||||
return request<Parent>(`/parents/${userId}`)
|
return request<Parent>(`/banban/parents/${userId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateParent(
|
export async function updateParent(
|
||||||
userId: number,
|
userId: number,
|
||||||
data: { nickname?: string; avatar_url?: string; phone?: string }
|
data: { nickname?: string; avatar_url?: string; phone?: string }
|
||||||
): Promise<Parent> {
|
): Promise<Parent> {
|
||||||
return request<Parent>(`/parents/${userId}`, {
|
return request<Parent>(`/banban/parents/${userId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
data,
|
data,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
|
|
||||||
import { request } from './api'
|
import { request } from './api'
|
||||||
|
import { Child, clearSelectedChildId, getChildren, resolveChildSelection, setSelectedChildId } from './child'
|
||||||
|
|
||||||
const SELECTED_BINDING_DEVICE_ID_KEY = 'selectedBindingDeviceId'
|
const SELECTED_BINDING_DEVICE_ID_KEY = 'selectedBindingDeviceId'
|
||||||
|
|
||||||
@@ -26,12 +28,38 @@ export interface BindingListResponse {
|
|||||||
next_cursor?: number | null
|
next_cursor?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BindingChildContext {
|
||||||
|
currentChild: Child | null
|
||||||
|
currentBinding: Binding | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface DirectBindPayload {
|
export interface DirectBindPayload {
|
||||||
device_id: string
|
device_id: string
|
||||||
serial_number: string
|
serial_number: string
|
||||||
child_id?: number
|
child_id?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NFCSessionStartPayload {
|
||||||
|
device_id: string
|
||||||
|
serial_number: string
|
||||||
|
child_id?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NFCSessionStartResponse {
|
||||||
|
bind_token: string
|
||||||
|
expires_at: string
|
||||||
|
status: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NFCSessionStatus {
|
||||||
|
bind_token: string
|
||||||
|
device_id: string
|
||||||
|
child_id: number | null
|
||||||
|
status: number
|
||||||
|
expires_at: string
|
||||||
|
card_uuid?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export function getSelectedBindingDeviceId(): string | null {
|
export function getSelectedBindingDeviceId(): string | null {
|
||||||
const value = Taro.getStorageSync(SELECTED_BINDING_DEVICE_ID_KEY)
|
const value = Taro.getStorageSync(SELECTED_BINDING_DEVICE_ID_KEY)
|
||||||
const deviceId = String(value || '').trim()
|
const deviceId = String(value || '').trim()
|
||||||
@@ -67,7 +95,7 @@ export function resolveBindingSelection<T extends { device_id: string }>(items:
|
|||||||
|
|
||||||
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>('/banban/bindings/current')
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error?.status === 404) return null
|
if (error?.status === 404) return null
|
||||||
throw error
|
throw error
|
||||||
@@ -75,16 +103,13 @@ export async function getCurrentBinding(): Promise<Binding | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getBindings(cursor?: number, limit: number = 20): Promise<BindingListResponse> {
|
export async function getBindings(cursor?: number, limit: number = 20): Promise<BindingListResponse> {
|
||||||
const query = buildQuery({
|
const query = buildQuery({ cursor, limit })
|
||||||
cursor,
|
return request<BindingListResponse>(`/banban/bindings?${query}`)
|
||||||
limit,
|
|
||||||
})
|
|
||||||
return request<BindingListResponse>(`/bindings?${query}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getBinding(deviceId: string): Promise<Binding | null> {
|
export async function getBinding(deviceId: string): Promise<Binding | null> {
|
||||||
try {
|
try {
|
||||||
return await request<Binding>(`/bindings/${deviceId}`)
|
return await request<Binding>(`/banban/bindings/${deviceId}`)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error?.status === 404) return null
|
if (error?.status === 404) return null
|
||||||
throw error
|
throw error
|
||||||
@@ -100,7 +125,6 @@ export async function resolveActiveBinding(): Promise<Binding | null> {
|
|||||||
setSelectedBindingDeviceId(selectedBinding.device_id)
|
setSelectedBindingDeviceId(selectedBinding.device_id)
|
||||||
return selectedBinding
|
return selectedBinding
|
||||||
}
|
}
|
||||||
|
|
||||||
clearSelectedBindingDeviceId()
|
clearSelectedBindingDeviceId()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,24 +139,92 @@ export async function resolveActiveBinding(): Promise<Binding | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function directBind(data: DirectBindPayload): Promise<{ device_id: string; child_id: number | null }> {
|
export async function directBind(data: DirectBindPayload): Promise<{ device_id: string; child_id: number | null }> {
|
||||||
return request('/bindings/direct', {
|
return request('/banban/bindings/direct', { method: 'POST', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startNFCBind(data: NFCSessionStartPayload): Promise<NFCSessionStartResponse> {
|
||||||
|
return request<NFCSessionStartResponse>('/banban/bindings/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data,
|
data,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getNFCBindSession(bindToken: string): Promise<NFCSessionStatus> {
|
||||||
|
return request<NFCSessionStatus>(`/banban/bindings/sessions/${bindToken}`)
|
||||||
|
}
|
||||||
|
|
||||||
export async function setBindingChild(
|
export async function setBindingChild(
|
||||||
deviceId: string,
|
deviceId: string,
|
||||||
data: { child_id: number }
|
data: { child_id: number }
|
||||||
): Promise<{ device_id: string; child_id: number | null }> {
|
): Promise<{ device_id: string; child_id: number | null }> {
|
||||||
return request(`/bindings/${deviceId}/child`, {
|
return request(`/banban/bindings/${deviceId}/child`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
data,
|
data,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unbindDevice(deviceId: string): Promise<void> {
|
export async function unbindDevice(deviceId: string): Promise<void> {
|
||||||
return request(`/bindings/${deviceId}`, {
|
return request(`/banban/bindings/${deviceId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveBindingForChild<T extends { child_id: number | null }>(
|
||||||
|
items: T[],
|
||||||
|
childId?: number | null
|
||||||
|
): T | null {
|
||||||
|
const normalizedChildId = Number(childId || 0)
|
||||||
|
if (!Number.isFinite(normalizedChildId) || normalizedChildId <= 0) return null
|
||||||
|
|
||||||
|
const childBindings = items.filter((item) => item.child_id === normalizedChildId)
|
||||||
|
if (childBindings.length === 0) return null
|
||||||
|
|
||||||
|
const selectedDeviceId = getSelectedBindingDeviceId()
|
||||||
|
if (selectedDeviceId) {
|
||||||
|
const selectedBinding = childBindings.find((item: any) => item.device_id === selectedDeviceId) || null
|
||||||
|
if (selectedBinding) return selectedBinding
|
||||||
|
}
|
||||||
|
|
||||||
|
return childBindings[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncChildAndBindingSelection(children: Child[], bindings: Binding[]): BindingChildContext {
|
||||||
|
const currentChild = resolveChildSelection(children)
|
||||||
|
const currentBinding = currentChild ? resolveBindingForChild(bindings, currentChild.child_id) : null
|
||||||
|
|
||||||
|
if (currentChild) {
|
||||||
|
setSelectedChildId(currentChild.child_id)
|
||||||
|
} else {
|
||||||
|
clearSelectedChildId()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentBinding?.device_id) {
|
||||||
|
setSelectedBindingDeviceId(currentBinding.device_id)
|
||||||
|
} else {
|
||||||
|
clearSelectedBindingDeviceId()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentChild,
|
||||||
|
currentBinding,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCurrentChildBindingContext(): Promise<{
|
||||||
|
children: Child[]
|
||||||
|
bindings: Binding[]
|
||||||
|
currentChild: Child | null
|
||||||
|
currentBinding: Binding | null
|
||||||
|
}> {
|
||||||
|
const [childResponse, bindingResponse] = await Promise.all([getChildren(undefined, 100), getBindings(undefined, 100)])
|
||||||
|
const children = childResponse.items || []
|
||||||
|
const bindings = bindingResponse.items || []
|
||||||
|
const { currentChild, currentBinding } = syncChildAndBindingSelection(children, bindings)
|
||||||
|
|
||||||
|
return {
|
||||||
|
children,
|
||||||
|
bindings,
|
||||||
|
currentChild,
|
||||||
|
currentBinding,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { getCurrentUserId } from './auth'
|
import { getCurrentUserId } from './auth'
|
||||||
import { request } from './api'
|
import { request } from './api'
|
||||||
import { resolveActiveBinding } from './binding'
|
import { loadCurrentChildBindingContext } from './binding'
|
||||||
import { getChild } from './child'
|
|
||||||
|
|
||||||
export type ConversationSource = 'im' | 'ai'
|
export type ConversationSource = 'im' | 'ai'
|
||||||
export type PeerKind = 'child' | 'parent' | 'ai'
|
export type PeerKind = 'child' | 'parent' | 'ai'
|
||||||
@@ -38,6 +37,11 @@ export interface ChatMessage {
|
|||||||
content: string
|
content: string
|
||||||
time: string
|
time: string
|
||||||
conversationId: number
|
conversationId: number
|
||||||
|
contentType?: number
|
||||||
|
mediaUrl?: string | null
|
||||||
|
mediaDurationMs?: number | null
|
||||||
|
mediaMimeType?: string | null
|
||||||
|
mediaTranscriptText?: string | null
|
||||||
senderType?: string
|
senderType?: string
|
||||||
senderId?: string
|
senderId?: string
|
||||||
receiverType?: string
|
receiverType?: string
|
||||||
@@ -240,21 +244,12 @@ function getConversationRank(conversation: ChatConversation): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getBindingContext(): Promise<BindingContext> {
|
async function getBindingContext(): Promise<BindingContext> {
|
||||||
const binding = await resolveActiveBinding()
|
const context = await loadCurrentChildBindingContext()
|
||||||
let childName = String(binding?.child_name || '').trim()
|
const childName = String(context.currentChild?.child_name || context.currentBinding?.child_name || '').trim()
|
||||||
|
|
||||||
if (!childName && binding?.child_id) {
|
|
||||||
try {
|
|
||||||
const child = await getChild(binding.child_id)
|
|
||||||
childName = String(child?.child_name || '').trim()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[chat] load child name failed:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
deviceId: binding?.device_id || '',
|
deviceId: context.currentBinding?.device_id || '',
|
||||||
childId: binding?.child_id || null,
|
childId: context.currentChild?.child_id || null,
|
||||||
childName,
|
childName,
|
||||||
currentUserId: getCurrentUserId(),
|
currentUserId: getCurrentUserId(),
|
||||||
}
|
}
|
||||||
@@ -265,7 +260,7 @@ async function getDeviceMessages(context?: BindingContext): Promise<DeviceAiMess
|
|||||||
if (!deviceId) return []
|
if (!deviceId) return []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await request<DeviceAiMessageListResponse>(`/devices/${deviceId}/messages?limit=100`)
|
const response = await request<DeviceAiMessageListResponse>(`/banban/devices/${deviceId}/messages?limit=100`)
|
||||||
return response.items || []
|
return response.items || []
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error?.status === 404) return []
|
if (error?.status === 404) return []
|
||||||
@@ -278,7 +273,7 @@ async function getChildConversations(context?: BindingContext): Promise<ChildCon
|
|||||||
if (!childId) return []
|
if (!childId) return []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await request<ChildConversationListResponse>(`/children/${childId}/conversations?limit=100`)
|
const response = await request<ChildConversationListResponse>(`/banban/children/${childId}/conversations?limit=100`)
|
||||||
return response.items || []
|
return response.items || []
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error?.status === 404) return []
|
if (error?.status === 404) return []
|
||||||
@@ -294,7 +289,7 @@ async function getChildConversationMessages(
|
|||||||
if (!childId || conversationId <= 0) return []
|
if (!childId || conversationId <= 0) return []
|
||||||
|
|
||||||
const response = await request<ChildConversationMessageListResponse>(
|
const response = await request<ChildConversationMessageListResponse>(
|
||||||
`/children/${childId}/conversations/${conversationId}/messages?limit=100`
|
`/banban/children/${childId}/conversations/${conversationId}/messages?limit=100`
|
||||||
)
|
)
|
||||||
return response.items || []
|
return response.items || []
|
||||||
}
|
}
|
||||||
@@ -437,6 +432,11 @@ export async function getMessages(
|
|||||||
content: item.content || '暂无内容',
|
content: item.content || '暂无内容',
|
||||||
time: formatDetailTime(item.created_at),
|
time: formatDetailTime(item.created_at),
|
||||||
conversationId: item.conversation_id,
|
conversationId: item.conversation_id,
|
||||||
|
contentType: 1,
|
||||||
|
mediaUrl: null,
|
||||||
|
mediaDurationMs: null,
|
||||||
|
mediaMimeType: null,
|
||||||
|
mediaTranscriptText: null,
|
||||||
senderType: item.is_user ? 'device' : 'ai',
|
senderType: item.is_user ? 'device' : 'ai',
|
||||||
senderId: item.is_user ? context.deviceId : item.role_key,
|
senderId: item.is_user ? context.deviceId : item.role_key,
|
||||||
receiverType: item.is_user ? 'ai' : 'device',
|
receiverType: item.is_user ? 'ai' : 'device',
|
||||||
@@ -462,6 +462,11 @@ export async function getMessages(
|
|||||||
content: formatImPreview(item),
|
content: formatImPreview(item),
|
||||||
time: formatDetailTime(item.created_at),
|
time: formatDetailTime(item.created_at),
|
||||||
conversationId: item.conversation_id,
|
conversationId: item.conversation_id,
|
||||||
|
contentType: item.content_type,
|
||||||
|
mediaUrl: item.media_file_key || null,
|
||||||
|
mediaDurationMs: item.media_duration_ms || null,
|
||||||
|
mediaMimeType: item.media_mime_type || null,
|
||||||
|
mediaTranscriptText: item.media_transcript_text || null,
|
||||||
senderType: item.sender_type,
|
senderType: item.sender_type,
|
||||||
senderId: item.sender_id,
|
senderId: item.sender_id,
|
||||||
receiverType: item.receiver_type,
|
receiverType: item.receiver_type,
|
||||||
@@ -580,7 +585,7 @@ export async function sendParentMessage(childId: number, content: string): Promi
|
|||||||
throw new Error('请输入消息内容')
|
throw new Error('请输入消息内容')
|
||||||
}
|
}
|
||||||
|
|
||||||
return request<ConversationMessageCreateResponse>(`/children/${childId}/messages`, {
|
return request<ConversationMessageCreateResponse>(`/banban/children/${childId}/messages`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: {
|
data: {
|
||||||
content_type: 1,
|
content_type: 1,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import Taro from '@tarojs/taro'
|
||||||
import { request } from './api'
|
import { request } from './api'
|
||||||
|
|
||||||
|
const SELECTED_CHILD_ID_KEY = 'selectedChildId'
|
||||||
|
|
||||||
function buildQuery(params: Record<string, string | number | undefined | null>): string {
|
function buildQuery(params: Record<string, string | number | undefined | null>): string {
|
||||||
return Object.entries(params)
|
return Object.entries(params)
|
||||||
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
||||||
@@ -21,12 +24,44 @@ export interface ChildListResponse {
|
|||||||
next_cursor?: number | null
|
next_cursor?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSelectedChildId(): number | null {
|
||||||
|
const value = Number(Taro.getStorageSync(SELECTED_CHILD_ID_KEY) || 0)
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSelectedChildId(childId?: number | null) {
|
||||||
|
const normalizedChildId = Number(childId || 0)
|
||||||
|
if (!Number.isFinite(normalizedChildId) || normalizedChildId <= 0) {
|
||||||
|
Taro.removeStorageSync(SELECTED_CHILD_ID_KEY)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Taro.setStorageSync(SELECTED_CHILD_ID_KEY, normalizedChildId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSelectedChildId() {
|
||||||
|
Taro.removeStorageSync(SELECTED_CHILD_ID_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveChildSelection<T extends { child_id: number }>(items: T[]): T | null {
|
||||||
|
const selectedChildId = getSelectedChildId()
|
||||||
|
const selectedChild = selectedChildId ? items.find((item) => item.child_id === selectedChildId) || null : null
|
||||||
|
const currentChild = selectedChild || items[0] || null
|
||||||
|
|
||||||
|
if (currentChild) {
|
||||||
|
setSelectedChildId(currentChild.child_id)
|
||||||
|
} else {
|
||||||
|
clearSelectedChildId()
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentChild
|
||||||
|
}
|
||||||
|
|
||||||
export async function createChild(data: {
|
export async function createChild(data: {
|
||||||
child_name: string
|
child_name: string
|
||||||
child_gender?: number
|
child_gender?: number
|
||||||
child_birthday?: string
|
child_birthday?: string
|
||||||
}): Promise<Child> {
|
}): Promise<Child> {
|
||||||
return request<Child>('/children', {
|
return request<Child>('/banban/children', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data,
|
data,
|
||||||
})
|
})
|
||||||
@@ -37,18 +72,18 @@ export async function getChildren(cursor?: number, limit: number = 20): Promise<
|
|||||||
cursor,
|
cursor,
|
||||||
limit,
|
limit,
|
||||||
})
|
})
|
||||||
return request<ChildListResponse>(`/children?${query}`)
|
return request<ChildListResponse>(`/banban/children?${query}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getChild(childId: number): Promise<Child> {
|
export async function getChild(childId: number): Promise<Child> {
|
||||||
return request<Child>(`/children/${childId}`)
|
return request<Child>(`/banban/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 }
|
||||||
): Promise<Child> {
|
): Promise<Child> {
|
||||||
return request<Child>(`/children/${childId}`, {
|
return request<Child>(`/banban/children/${childId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
data,
|
data,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { request } from './api'
|
import { request } from './api'
|
||||||
import { resolveActiveBinding } from './binding'
|
import { loadCurrentChildBindingContext } from './binding'
|
||||||
|
|
||||||
export interface DeviceLocation {
|
export interface DeviceLocation {
|
||||||
child_id: number
|
child_id: number
|
||||||
@@ -32,11 +32,12 @@ export interface DeviceTrajectoryResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getCurrentDeviceLocation(deviceId?: string): Promise<DeviceLocation | null> {
|
export async function getCurrentDeviceLocation(deviceId?: string): Promise<DeviceLocation | null> {
|
||||||
const resolvedDeviceId = String(deviceId || '').trim() || (await resolveActiveBinding())?.device_id || ''
|
const resolvedDeviceId =
|
||||||
|
String(deviceId || '').trim() || (await loadCurrentChildBindingContext()).currentBinding?.device_id || ''
|
||||||
if (!resolvedDeviceId) return null
|
if (!resolvedDeviceId) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await request<DeviceLocation>(`/devices/${resolvedDeviceId}/location`)
|
return await request<DeviceLocation>(`/banban/devices/${resolvedDeviceId}/location`)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error?.status === 404) return null
|
if (error?.status === 404) return null
|
||||||
throw error
|
throw error
|
||||||
@@ -49,7 +50,8 @@ export async function getDeviceTrajectory(params?: {
|
|||||||
endAt?: string
|
endAt?: string
|
||||||
limit?: number
|
limit?: number
|
||||||
}): Promise<DeviceTrajectoryResponse | null> {
|
}): Promise<DeviceTrajectoryResponse | null> {
|
||||||
const resolvedDeviceId = String(params?.deviceId || '').trim() || (await resolveActiveBinding())?.device_id || ''
|
const resolvedDeviceId =
|
||||||
|
String(params?.deviceId || '').trim() || (await loadCurrentChildBindingContext()).currentBinding?.device_id || ''
|
||||||
if (!resolvedDeviceId) return null
|
if (!resolvedDeviceId) return null
|
||||||
|
|
||||||
const query = new URLSearchParams()
|
const query = new URLSearchParams()
|
||||||
@@ -59,7 +61,7 @@ export async function getDeviceTrajectory(params?: {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const suffix = query.toString() ? `?${query.toString()}` : ''
|
const suffix = query.toString() ? `?${query.toString()}` : ''
|
||||||
return await request<DeviceTrajectoryResponse>(`/devices/${resolvedDeviceId}/trajectory${suffix}`)
|
return await request<DeviceTrajectoryResponse>(`/banban/devices/${resolvedDeviceId}/trajectory${suffix}`)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error?.status === 404) return null
|
if (error?.status === 404) return null
|
||||||
throw error
|
throw error
|
||||||
|
|||||||
Reference in New Issue
Block a user