Compare commits

...

4 Commits

Author SHA1 Message Date
stu2not
6c77672ca7 feat(devices): skip realtime requests when device offline 2026-05-25 17:49:46 +08:00
stu2not
3449224cd2 feat(mini): add child delete picker in manage page 2026-05-25 17:49:43 +08:00
stu2not
8cff60f87b feat(children): support soft delete child profiles 2026-05-25 17:49:40 +08:00
stu2not
5d86ee5826 feat(mini): add voice recording compatibility logging 2026-05-25 17:49:34 +08:00
14 changed files with 698 additions and 28 deletions

View File

@@ -53,6 +53,135 @@ function formatAudioDuration(durationMs?: number | null): string {
return `${totalSeconds}s` return `${totalSeconds}s`
} }
type VoiceRuntimeInfo = {
platform: string
system: string
brand: string
model: string
version: string
SDKVersion: string
isIOS: boolean
isAndroid: boolean
isHarmony: boolean
canUseRecorderManager: boolean
canUseInnerAudioContext: boolean
supportsMp3Record: boolean
}
type RecordProfile = {
format: 'mp3' | 'aac'
mimeType: string
sampleRate: number
encodeBitRate: number
}
const DEFAULT_RECORD_PROFILE: RecordProfile = {
format: 'mp3',
mimeType: 'audio/mpeg',
sampleRate: 16000,
encodeBitRate: 96000,
}
const COMPAT_RECORD_PROFILE: RecordProfile = {
format: 'aac',
mimeType: 'audio/aac',
sampleRate: 16000,
encodeBitRate: 64000,
}
const FALLBACK_RUNTIME_INFO: VoiceRuntimeInfo = {
platform: '',
system: '',
brand: '',
model: '',
version: '',
SDKVersion: '',
isIOS: false,
isAndroid: false,
isHarmony: false,
canUseRecorderManager: false,
canUseInnerAudioContext: false,
supportsMp3Record: true,
}
function compareVersion(left: string, right: string): number {
const leftParts = left.split('.').map((item) => Number(item) || 0)
const rightParts = right.split('.').map((item) => Number(item) || 0)
const length = Math.max(leftParts.length, rightParts.length)
for (let index = 0; index < length; index += 1) {
const diff = (leftParts[index] || 0) - (rightParts[index] || 0)
if (diff !== 0) return diff
}
return 0
}
function getCanIUse(apiName: string): boolean {
try {
if (typeof Taro.canIUse !== 'function') return false
return Boolean(Taro.canIUse(apiName))
} catch {
return false
}
}
function readVoiceRuntimeInfo(): VoiceRuntimeInfo {
let systemInfo: Record<string, any> = {}
try {
systemInfo = Taro.getSystemInfoSync() as Record<string, any>
} catch (error) {
console.warn('[voice-runtime] getSystemInfoSync failed', error)
}
const platform = String(systemInfo.platform || '').toLowerCase()
const system = String(systemInfo.system || '')
const brand = String(systemInfo.brand || '')
const model = String(systemInfo.model || '')
const version = String(systemInfo.version || '')
const SDKVersion = String(systemInfo.SDKVersion || '')
const systemText = `${system} ${brand} ${model}`.toLowerCase()
const isHarmony = systemText.includes('harmony') || systemText.includes('openharmony') || systemText.includes('鸿蒙')
const isIOS = platform === 'ios' || systemText.includes('ios')
const isAndroid = platform === 'android' || systemText.includes('android') || isHarmony
return {
platform,
system,
brand,
model,
version,
SDKVersion,
isIOS,
isAndroid,
isHarmony,
canUseRecorderManager: typeof Taro.getRecorderManager === 'function',
canUseInnerAudioContext: typeof Taro.createInnerAudioContext === 'function',
supportsMp3Record: !SDKVersion || compareVersion(SDKVersion, '2.6.0') >= 0,
}
}
function getPreferredRecordProfile(runtime: VoiceRuntimeInfo): RecordProfile {
if (runtime.isHarmony || !runtime.supportsMp3Record) return COMPAT_RECORD_PROFILE
return DEFAULT_RECORD_PROFILE
}
function getAudioUrlDebugInfo(rawUrl: string) {
const protocolMatch = rawUrl.match(/^([a-z]+):\/\//i)
let host = ''
try {
const match = rawUrl.match(/^[a-z]+:\/\/([^/?#]+)/i)
host = match?.[1] || ''
} catch {
host = ''
}
return {
rawUrl,
protocol: protocolMatch?.[1]?.toLowerCase() || '',
host,
isHttp: rawUrl.startsWith('http://'),
isHttps: rawUrl.startsWith('https://'),
}
}
export default function ChatDetail() { export default function ChatDetail() {
const systemBanner = useSystemBanner() const systemBanner = useSystemBanner()
const router = useRouter() const router = useRouter()
@@ -99,21 +228,61 @@ export default function ChatDetail() {
const activeConversationIdRef = useRef(initialConversationId) const activeConversationIdRef = useRef(initialConversationId)
const latestLoadChatDataRef = useRef<(conversationId: number) => Promise<void>>(async () => {}) const latestLoadChatDataRef = useRef<(conversationId: number) => Promise<void>>(async () => {})
const recordingChildIdRef = useRef<number | null>(null) const recordingChildIdRef = useRef<number | null>(null)
const recordProfileRef = useRef<RecordProfile>(DEFAULT_RECORD_PROFILE)
const runtimeInfoRef = useRef<VoiceRuntimeInfo>(FALLBACK_RUNTIME_INFO)
const recordStartedAtRef = useRef<number>(0)
const playingMessageIdRef = useRef<number | null>(null)
useEffect(() => { useEffect(() => {
runtimeInfoRef.current = readVoiceRuntimeInfo()
recordProfileRef.current = getPreferredRecordProfile(runtimeInfoRef.current)
console.info('[voice-runtime] detected', {
...runtimeInfoRef.current,
preferredRecordProfile: recordProfileRef.current,
canIUse: {
getRecorderManager: getCanIUse('getRecorderManager'),
createInnerAudioContext: getCanIUse('createInnerAudioContext'),
uploadFile: getCanIUse('uploadFile'),
},
})
}, [])
useEffect(() => {
if (typeof Taro.createInnerAudioContext !== 'function') {
console.warn('[audio] InnerAudioContext unavailable', runtimeInfoRef.current)
return
}
const audioContext = Taro.createInnerAudioContext() const audioContext = Taro.createInnerAudioContext()
audioContextRef.current = audioContext audioContextRef.current = audioContext
audioContext.onEnded(() => { audioContext.onEnded(() => {
console.info('[audio] ended', { playingMessageId: playingMessageIdRef.current })
setPlayingMessageId(null) setPlayingMessageId(null)
}) })
audioContext.onStop(() => { audioContext.onStop(() => {
console.info('[audio] stopped', { playingMessageId: playingMessageIdRef.current })
setPlayingMessageId(null) setPlayingMessageId(null)
}) })
audioContext.onError(() => { audioContext.onError((error) => {
setPlayingMessageId(null) setPlayingMessageId(null)
console.error('[audio] error', {
playingMessageId: playingMessageIdRef.current,
src: audioContext.src,
error,
runtime: runtimeInfoRef.current,
})
Taro.showToast({ title: '语音播放失败', icon: 'none' }) Taro.showToast({ title: '语音播放失败', icon: 'none' })
}) })
audioContext.onPlay(() => {
console.info('[audio] play event', { playingMessageId: playingMessageIdRef.current, src: audioContext.src })
})
audioContext.onWaiting(() => {
console.info('[audio] waiting', { playingMessageId: playingMessageIdRef.current, src: audioContext.src })
})
audioContext.onCanplay(() => {
console.info('[audio] canplay', { playingMessageId: playingMessageIdRef.current, src: audioContext.src })
})
return () => { return () => {
audioContext.destroy() audioContext.destroy()
@@ -125,6 +294,10 @@ export default function ChatDetail() {
activeConversationIdRef.current = activeConversationId activeConversationIdRef.current = activeConversationId
}, [activeConversationId]) }, [activeConversationId])
useEffect(() => {
playingMessageIdRef.current = playingMessageId
}, [playingMessageId])
useEffect(() => { useEffect(() => {
void loadChatData(activeConversationId) void loadChatData(activeConversationId)
}, [activeConversationId, conversationSource, conversationPeerKind]) }, [activeConversationId, conversationSource, conversationPeerKind])
@@ -166,42 +339,85 @@ export default function ChatDetail() {
latestLoadChatDataRef.current = loadChatData latestLoadChatDataRef.current = loadChatData
useEffect(() => { useEffect(() => {
if (typeof Taro.getRecorderManager !== 'function') return if (typeof Taro.getRecorderManager !== 'function') {
console.warn('[record] RecorderManager unavailable', runtimeInfoRef.current)
return
}
const recorderManager = Taro.getRecorderManager() const recorderManager = Taro.getRecorderManager()
recorderManagerRef.current = recorderManager recorderManagerRef.current = recorderManager
recorderManager.onStart(() => {
console.info('[record] started', {
profile: recordProfileRef.current,
runtime: runtimeInfoRef.current,
})
})
recorderManager.onError((error) => { recorderManager.onError((error) => {
recordingChildIdRef.current = null recordingChildIdRef.current = null
recordStartedAtRef.current = 0
setRecording(false) setRecording(false)
setRecordHint('长按开始留言') setRecordHint('长按开始留言')
console.error('[chat-detail] recorder failed:', error) console.error('[record] error', {
error,
profile: recordProfileRef.current,
runtime: runtimeInfoRef.current,
})
Taro.showToast({ title: '录音失败,请重试', icon: 'none' }) Taro.showToast({ title: '录音失败,请重试', icon: 'none' })
}) })
recorderManager.onStop(async (result) => { recorderManager.onStop(async (result) => {
const currentChildId = recordingChildIdRef.current const currentChildId = recordingChildIdRef.current
const startedAt = recordStartedAtRef.current
const fallbackDurationMs = startedAt > 0 ? Date.now() - startedAt : 0
recordingChildIdRef.current = null recordingChildIdRef.current = null
recordStartedAtRef.current = 0
setRecording(false) setRecording(false)
setRecordHint('长按开始留言') setRecordHint('长按开始留言')
console.info('[record] stopped', {
childId: currentChildId,
tempFilePath: result.tempFilePath,
duration: result.duration,
fileSize: result.fileSize,
fallbackDurationMs,
profile: recordProfileRef.current,
runtime: runtimeInfoRef.current,
})
if (!currentChildId) return if (!currentChildId) return
if (!result.tempFilePath) { if (!result.tempFilePath) {
console.warn('[record] missing tempFilePath', { result })
Taro.showToast({ title: '录音文件不存在', icon: 'none' }) Taro.showToast({ title: '录音文件不存在', icon: 'none' })
return return
} }
const durationMs = Math.max(0, Number(result.duration || 0)) const durationMs = Math.max(0, Number(result.duration || 0) || fallbackDurationMs)
if (durationMs < 800) { if (durationMs < 800) {
console.warn('[record] too short', {
durationMs,
resultDuration: result.duration,
fallbackDurationMs,
})
Taro.showToast({ title: '留言太短,请重试', icon: 'none' }) Taro.showToast({ title: '留言太短,请重试', icon: 'none' })
return return
} }
setSending(true) setSending(true)
try { try {
const profile = recordProfileRef.current
const response = await sendParentVoiceMessage(currentChildId, { const response = await sendParentVoiceMessage(currentChildId, {
filePath: result.tempFilePath, filePath: result.tempFilePath,
durationMs, durationMs,
audioFormat: profile.format,
mimeType: profile.mimeType,
runtime: runtimeInfoRef.current,
})
console.info('[record] uploaded', {
childId: currentChildId,
conversationId: response.conversation_id,
messageId: response.message?.id,
}) })
if (response.conversation_id && response.conversation_id !== activeConversationIdRef.current) { if (response.conversation_id && response.conversation_id !== activeConversationIdRef.current) {
setActiveConversationId(response.conversation_id) setActiveConversationId(response.conversation_id)
@@ -232,16 +448,27 @@ export default function ChatDetail() {
const ensureRecordPermission = async (): Promise<boolean> => { const ensureRecordPermission = async (): Promise<boolean> => {
try { try {
const settings = await Taro.getSetting() const settings = await Taro.getSetting()
if (settings.authSetting['scope.record']) return true const granted = Boolean(settings.authSetting['scope.record'])
console.info('[record] permission status', {
granted,
runtime: runtimeInfoRef.current,
})
if (granted) return true
await Taro.authorize({ scope: 'scope.record' }) await Taro.authorize({ scope: 'scope.record' })
console.info('[record] permission granted by authorize')
return true return true
} catch { } catch (error) {
console.warn('[record] permission denied or authorize failed', {
error,
runtime: runtimeInfoRef.current,
})
const modal = await Taro.showModal({ const modal = await Taro.showModal({
title: '需要录音权限', title: '需要录音权限',
content: '请允许小程序使用麦克风后再留言', content: '请允许小程序使用麦克风后再留言',
confirmText: '去设置', confirmText: '去设置',
}) })
if (modal.confirm) { if (modal.confirm) {
console.info('[record] openSetting requested')
await Taro.openSetting() await Taro.openSetting()
} }
return false return false
@@ -264,22 +491,37 @@ export default function ChatDetail() {
const hasPermission = await ensureRecordPermission() const hasPermission = await ensureRecordPermission()
if (!hasPermission) return if (!hasPermission) return
const runtime = readVoiceRuntimeInfo()
const profile = getPreferredRecordProfile(runtime)
runtimeInfoRef.current = runtime
recordProfileRef.current = profile
recordingChildIdRef.current = character.childId recordingChildIdRef.current = character.childId
recordStartedAtRef.current = Date.now()
setRecording(true) setRecording(true)
setRecordHint('松开发送留言') setRecordHint('松开发送留言')
console.info('[record] start request', {
childId: character.childId,
profile,
runtime,
})
try { try {
recorderManager.start({ recorderManager.start({
duration: 60000, duration: 60000,
format: 'mp3', format: profile.format,
numberOfChannels: 1, numberOfChannels: 1,
sampleRate: 16000, sampleRate: profile.sampleRate,
encodeBitRate: 96000, encodeBitRate: profile.encodeBitRate,
}) })
} catch (error: any) { } catch (error: any) {
recordingChildIdRef.current = null recordingChildIdRef.current = null
recordStartedAtRef.current = 0
setRecording(false) setRecording(false)
setRecordHint('长按开始留言') setRecordHint('长按开始留言')
console.error('[chat-detail] recorder start failed:', error) console.error('[record] start failed:', {
error,
profile,
runtime,
})
Taro.showToast({ Taro.showToast({
title: '无法开始录音,请重试', title: '无法开始录音,请重试',
icon: 'none', icon: 'none',
@@ -290,9 +532,15 @@ export default function ChatDetail() {
const handleRecordStop = () => { const handleRecordStop = () => {
if (!recording) return if (!recording) return
const recorderManager = recorderManagerRef.current const recorderManager = recorderManagerRef.current
if (!recorderManager) return if (!recorderManager) {
console.warn('[record] stop skipped: recorder unavailable')
return
}
setRecordHint('正在处理留言...') setRecordHint('正在处理留言...')
console.info('[record] stop request', {
elapsedMs: recordStartedAtRef.current > 0 ? Date.now() - recordStartedAtRef.current : 0,
})
recorderManager.stop() recorderManager.stop()
} }
@@ -302,13 +550,29 @@ export default function ChatDetail() {
return return
} }
const urlInfo = getAudioUrlDebugInfo(message.mediaUrl)
console.info('[audio] play request', {
messageId: message.id,
conversationId: message.conversationId,
contentType: message.contentType,
mediaDurationMs: message.mediaDurationMs,
mediaMimeType: message.mediaMimeType,
url: urlInfo,
runtime: runtimeInfoRef.current,
})
if (urlInfo.isHttp) {
console.warn('[audio] http url may fail in experience/release builds', urlInfo)
}
const audioContext = audioContextRef.current const audioContext = audioContextRef.current
if (!audioContext) { if (!audioContext) {
console.warn('[audio] play skipped: audio context unavailable', runtimeInfoRef.current)
Taro.showToast({ title: '播放器未就绪', icon: 'none' }) Taro.showToast({ title: '播放器未就绪', icon: 'none' })
return return
} }
if (playingMessageId === message.id) { if (playingMessageId === message.id) {
console.info('[audio] stop current', { messageId: message.id })
audioContext.stop() audioContext.stop()
setPlayingMessageId(null) setPlayingMessageId(null)
return return
@@ -318,6 +582,11 @@ export default function ChatDetail() {
audioContext.src = message.mediaUrl audioContext.src = message.mediaUrl
audioContext.autoplay = true audioContext.autoplay = true
audioContext.play() audioContext.play()
console.info('[audio] play invoked', {
messageId: message.id,
srcRaw: message.mediaUrl,
src: audioContext.src,
})
setPlayingMessageId(message.id) setPlayingMessageId(message.id)
} }

View File

@@ -3,6 +3,7 @@ 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 { loadCurrentChildBindingContext } from '@/services/binding' import { loadCurrentChildBindingContext } from '@/services/binding'
import { getDeviceOnlineStatus } from '@/services/device'
import { import {
getCurrentDeviceLocation, getCurrentDeviceLocation,
getDeviceTrajectory, getDeviceTrajectory,
@@ -125,6 +126,7 @@ export default function Location() {
const [selectedPoint, setSelectedPoint] = useState<DeviceTrajectoryPoint | null>(null) const [selectedPoint, setSelectedPoint] = useState<DeviceTrajectoryPoint | null>(null)
const [trajectoryMode, setTrajectoryMode] = useState<TrajectoryMode>('current') const [trajectoryMode, setTrajectoryMode] = useState<TrajectoryMode>('current')
const [coordinates, setCoordinates] = useState(DEFAULT_COORDINATES) const [coordinates, setCoordinates] = useState(DEFAULT_COORDINATES)
const [locationNotice, setLocationNotice] = useState<string | null>(null)
const [emptyState, setEmptyState] = useState<{ title: string; desc: string; actionText: string; actionUrl: string } | null>( const [emptyState, setEmptyState] = useState<{ title: string; desc: string; actionText: string; actionUrl: string } | null>(
null null
) )
@@ -147,6 +149,7 @@ export default function Location() {
setLoading(true) setLoading(true)
setEmptyState(null) setEmptyState(null)
setLocationNotice(null)
if (nextMode) { if (nextMode) {
setTrajectoryMode(nextMode) setTrajectoryMode(nextMode)
setSelectedPoint(null) setSelectedPoint(null)
@@ -183,6 +186,16 @@ export default function Location() {
return return
} }
const onlineStatus = await getDeviceOnlineStatus(resolvedDeviceId)
if (onlineStatus && !onlineStatus.online) {
setDeviceLocation(null)
setTrajectory([])
setSelectedPoint(null)
setCoordinates(DEFAULT_COORDINATES)
setLocationNotice(onlineStatus.message || '设备不在线或暂时无法上报位置')
return
}
const currentLocation = await getCurrentDeviceLocation(resolvedDeviceId) const currentLocation = await getCurrentDeviceLocation(resolvedDeviceId)
setDeviceLocation(currentLocation) setDeviceLocation(currentLocation)
@@ -413,7 +426,9 @@ export default function Location() {
<View className='header-left'> <View className='header-left'>
<Text className='location-title'>{summaryTitle}</Text> <Text className='location-title'>{summaryTitle}</Text>
<Text className='location-update'> <Text className='location-update'>
{loading {locationNotice
? locationNotice
: loading
? '正在获取设备位置...' ? '正在获取设备位置...'
: trajectoryMode === 'current' : trajectoryMode === 'current'
? `更新于 ${formatTime(deviceLocation?.updated_at)}` ? `更新于 ${formatTime(deviceLocation?.updated_at)}`
@@ -444,7 +459,8 @@ 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'>{locationNotice || '当前孩子暂无设备位置'}</Text>
{locationNotice && <Text className='location-empty-desc'></Text>}
</View> </View>
) )
) : trajectory.length > 0 ? ( ) : trajectory.length > 0 ? (

View File

@@ -480,15 +480,24 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-bottom: 10px; margin-bottom: 10px;
gap: 16px;
} }
.device-switch-id { .device-switch-id {
min-width: 0;
font-size: 28px; font-size: 28px;
color: #1A1A1A; color: #1A1A1A;
font-weight: 600; font-weight: 600;
word-break: break-all; word-break: break-all;
} }
.device-switch-tags {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.device-switch-tag { .device-switch-tag {
margin-left: 16px; margin-left: 16px;
padding: 6px 14px; padding: 6px 14px;
@@ -497,6 +506,14 @@
font-size: 22px; font-size: 22px;
color: #FFFFFF; color: #FFFFFF;
flex-shrink: 0; flex-shrink: 0;
.device-switch-tags & {
margin-left: 0;
}
&.danger {
background: #E5484D;
}
} }
.device-switch-name { .device-switch-name {
@@ -504,6 +521,15 @@
color: #666666; color: #666666;
} }
.delete-child-item {
border: 2px solid transparent;
&:active {
background: #FFF1F2;
border-color: #FDA4AF;
}
}
.loading { .loading {
min-height: 100%; min-height: 100%;
display: flex; display: flex;

View File

@@ -9,12 +9,13 @@ import {
setSelectedBindingDeviceId, setSelectedBindingDeviceId,
unbindDevice, unbindDevice,
} from '@/services/binding' } from '@/services/binding'
import { Child, clearSelectedChildId, createChild, setSelectedChildId, updateChild } from '@/services/child' import { Child, clearSelectedChildId, createChild, deleteChild, setSelectedChildId, updateChild } from '@/services/child'
import { import {
DeviceCurrentRole, DeviceCurrentRole,
DeviceFirmwareStatus, DeviceFirmwareStatus,
DeviceRoleSummary, DeviceRoleSummary,
getDeviceFirmwareStatus, getDeviceFirmwareStatus,
getDeviceOnlineStatus,
getDeviceRole, getDeviceRole,
getDeviceRoles, getDeviceRoles,
startDeviceFirmwareUpdate, startDeviceFirmwareUpdate,
@@ -48,6 +49,7 @@ export default function Sleep() {
const [isUpdatingRole, setIsUpdatingRole] = useState(false) const [isUpdatingRole, setIsUpdatingRole] = useState(false)
const [showModal, setShowModal] = useState(false) const [showModal, setShowModal] = useState(false)
const [showChildModal, setShowChildModal] = useState(false) const [showChildModal, setShowChildModal] = useState(false)
const [showDeleteChildModal, setShowDeleteChildModal] = useState(false)
const [showRoleModal, setShowRoleModal] = useState(false) const [showRoleModal, setShowRoleModal] = useState(false)
const [modalType, setModalType] = useState<'add' | 'edit'>('add') const [modalType, setModalType] = useState<'add' | 'edit'>('add')
const [childName, setChildName] = useState('') const [childName, setChildName] = useState('')
@@ -207,6 +209,43 @@ export default function Sleep() {
} }
} }
const handleDeleteChild = (child: Child) => {
const childBinding = bindings.find((item) => item.child_id === child.child_id) || null
const isCurrentChild = currentChild?.child_id === child.child_id
const content = childBinding?.device_id
? '删除后,' + child.child_name + ' 将不再显示,设备 ' + childBinding.device_id + ' 会保留但不再分配给该孩子。确定删除吗?'
: '删除后,' + child.child_name + ' 将不再显示。确定删除吗?'
Taro.showModal({
title: '删除孩子',
content,
confirmText: '删除',
confirmColor: '#E5484D',
success: async (result) => {
if (!result.confirm) return
try {
await deleteChild(child.child_id)
if (isCurrentChild) {
clearSelectedChildId()
}
if (isCurrentChild && childBinding?.device_id && binding?.device_id === childBinding.device_id) {
clearSelectedBindingDeviceId()
}
setShowDeleteChildModal(false)
Taro.showToast({ title: '已删除', icon: 'success' })
await loadData()
} catch (error: any) {
console.error('[manage] delete child failed:', error)
Taro.showToast({
title: error?.message || '删除失败,请重试',
icon: 'none',
})
}
},
})
}
const handleStartFirmwareUpdate = () => { const handleStartFirmwareUpdate = () => {
if (!binding?.device_id || !firmwareStatus?.can_update || isLoadingFirmware || isUpdatingFirmware) return if (!binding?.device_id || !firmwareStatus?.can_update || isLoadingFirmware || isUpdatingFirmware) return
@@ -220,6 +259,15 @@ export default function Sleep() {
setIsUpdatingFirmware(true) setIsUpdatingFirmware(true)
try { try {
const onlineStatus = await getDeviceOnlineStatus(binding.device_id)
if (onlineStatus && !onlineStatus.online) {
Taro.showToast({
title: '设备不在线,暂时无法发送更新指令',
icon: 'none',
})
return
}
const nextFirmwareStatus = await startDeviceFirmwareUpdate(binding.device_id) const nextFirmwareStatus = await startDeviceFirmwareUpdate(binding.device_id)
setFirmwareStatus(nextFirmwareStatus) setFirmwareStatus(nextFirmwareStatus)
Taro.showToast({ Taro.showToast({
@@ -332,6 +380,11 @@ export default function Sleep() {
return return
} }
if (item.name === '删除孩子') {
setShowDeleteChildModal(true)
return
}
if (item.name === '绑定设备') { if (item.name === '绑定设备') {
if (!currentChild) { if (!currentChild) {
handleOpenModal('add') handleOpenModal('add')
@@ -405,6 +458,14 @@ export default function Sleep() {
value: '', value: '',
arrow: true, arrow: true,
}, },
{
icon: require('../../assets/tab-icons/broken-rings.png'),
iconBgClass: 'red',
name: '删除孩子',
value: children.length > 0 ? `${children.length} 个可选` : '未添加',
arrow: true,
disabled: children.length === 0,
},
{ {
icon: require('../../assets/tab-icons/rings.png'), icon: require('../../assets/tab-icons/rings.png'),
iconBgClass: 'green', iconBgClass: 'green',
@@ -572,6 +633,50 @@ export default function Sleep() {
</View> </View>
)} )}
{showDeleteChildModal && (
<View className='modal-mask' onClick={() => setShowDeleteChildModal(false)}>
<View
className='modal-card device-switch-card'
onClick={(event) => {
event.stopPropagation()
}}
>
<Text className='modal-title'></Text>
{children.length === 0 ? (
<Text className='device-switch-empty'></Text>
) : (
<View className='device-switch-list'>
{children.map((item) => {
const isActive = currentChild?.child_id === item.child_id
const itemBinding = bindings.find((bindingItem) => bindingItem.child_id === item.child_id) || null
return (
<View
key={item.child_id}
className='device-switch-item delete-child-item'
onClick={() => handleDeleteChild(item)}
>
<View className='device-switch-head'>
<Text className='device-switch-id'>{item.child_name}</Text>
<View className='device-switch-tags'>
{isActive && <Text className='device-switch-tag'></Text>}
<Text className='device-switch-tag danger'></Text>
</View>
</View>
<Text className='device-switch-name'>{itemBinding?.device_id || '未绑定设备'}</Text>
</View>
)
})}
</View>
)}
<View className='modal-actions'>
<Text className='modal-action cancel' onClick={() => setShowDeleteChildModal(false)}>
</Text>
</View>
</View>
</View>
)}
{showModal && ( {showModal && (
<View className='modal-mask'> <View className='modal-mask'>
<View className='modal-card'> <View className='modal-card'>

View File

@@ -690,7 +690,14 @@ function getUploadErrorMessage(data: any): string {
export async function sendParentVoiceMessage( export async function sendParentVoiceMessage(
childId: number, childId: number,
options: { filePath: string; durationMs: number; transcriptText?: string } options: {
filePath: string
durationMs: number
transcriptText?: string
audioFormat?: string
mimeType?: string
runtime?: Record<string, any>
}
): Promise<ConversationMessageCreateResponse> { ): Promise<ConversationMessageCreateResponse> {
if (!childId) { if (!childId) {
throw new Error('当前会话不支持留言') throw new Error('当前会话不支持留言')
@@ -701,6 +708,16 @@ export async function sendParentVoiceMessage(
const token = getToken() const token = getToken()
const requestUrl = `${BASE_URL}/banban/children/${childId}/voice-message` const requestUrl = `${BASE_URL}/banban/children/${childId}/voice-message`
console.info('[voice-upload] request', {
childId,
requestUrl,
filePath: options.filePath,
durationMs: options.durationMs,
audioFormat: options.audioFormat || '',
mimeType: options.mimeType || '',
runtime: options.runtime || {},
hasToken: Boolean(token),
})
const uploadResponse: any = await new Promise((resolve, reject) => { const uploadResponse: any = await new Promise((resolve, reject) => {
Taro.uploadFile({ Taro.uploadFile({
url: requestUrl, url: requestUrl,
@@ -715,9 +732,26 @@ export async function sendParentVoiceMessage(
duration_ms: `${Math.max(1, Math.round(options.durationMs || 0))}`, duration_ms: `${Math.max(1, Math.round(options.durationMs || 0))}`,
client_msg_id: createClientMessageId(), client_msg_id: createClientMessageId(),
transcript_text: options.transcriptText || '', transcript_text: options.transcriptText || '',
audio_format: options.audioFormat || '',
mime_type: options.mimeType || '',
},
success: (response) => {
console.info('[voice-upload] response', {
statusCode: response.statusCode,
errMsg: response.errMsg,
dataType: typeof response.data,
})
resolve(response)
},
fail: (error) => {
console.error('[voice-upload] fail', {
childId,
requestUrl,
errMsg: error?.errMsg,
message: error?.message,
})
reject(error)
}, },
success: resolve,
fail: reject,
}) })
}).catch((error: any) => { }).catch((error: any) => {
const message = String(error?.errMsg || error?.message || 'request:fail') const message = String(error?.errMsg || error?.message || 'request:fail')
@@ -730,6 +764,10 @@ export async function sendParentVoiceMessage(
const data = parseUploadResponseData(uploadResponse.data) const data = parseUploadResponseData(uploadResponse.data)
if (uploadResponse.statusCode >= 400) { if (uploadResponse.statusCode >= 400) {
console.error('[voice-upload] error response', {
statusCode: uploadResponse.statusCode,
data,
})
throw new ApiError(uploadResponse.statusCode, getUploadErrorMessage(data)) throw new ApiError(uploadResponse.statusCode, getUploadErrorMessage(data))
} }

View File

@@ -88,3 +88,9 @@ export async function updateChild(
data, data,
}) })
} }
export async function deleteChild(childId: number): Promise<void> {
return request<void>(`/banban/children/${childId}`, {
method: 'DELETE',
})
}

View File

@@ -28,6 +28,15 @@ export interface DeviceStatus {
location_updated_at?: string | null location_updated_at?: string | null
} }
export interface DeviceOnlineStatus {
device_id: string
online: boolean
reason: string
message: string
last_location_at?: string | null
checked_at: string
}
export interface DeviceAlarmItem { export interface DeviceAlarmItem {
alarm_id: number alarm_id: number
device_id: string device_id: string
@@ -136,6 +145,18 @@ export async function getDeviceStatus(deviceId?: string): Promise<DeviceStatus |
} }
} }
export async function getDeviceOnlineStatus(deviceId?: string): Promise<DeviceOnlineStatus | null> {
const resolvedDeviceId = await resolveDeviceId(deviceId)
if (!resolvedDeviceId) return null
try {
return await request<DeviceOnlineStatus>(`/banban/devices/${resolvedDeviceId}/online-status`)
} catch (error: any) {
if (error?.status === 404) return null
throw error
}
}
export async function setDeviceVolume(level: number, deviceId?: string): Promise<DeviceVolumeUpdateResponse> { export async function setDeviceVolume(level: number, deviceId?: string): Promise<DeviceVolumeUpdateResponse> {
const resolvedDeviceId = await resolveDeviceId(deviceId) const resolvedDeviceId = await resolveDeviceId(deviceId)
if (!resolvedDeviceId) { if (!resolvedDeviceId) {

View File

@@ -42,11 +42,29 @@ class ChildDAO(BaseDAO):
async def get_by_id(self, child_id: int) -> Optional[Mapping]: async def get_by_id(self, child_id: int) -> Optional[Mapping]:
return ( return (
await self.execute( await self.execute(
"SELECT * FROM children WHERE child_id = :child_id", "SELECT * FROM children WHERE child_id = :child_id AND status = 1",
{"child_id": child_id}, {"child_id": child_id},
) )
).mappings().first() ).mappings().first()
async def get_by_parent(self, child_id: int, user_id: int) -> Optional[Mapping]:
return (
await self.execute(
"""
SELECT c.*
FROM children AS c
JOIN parent_child_relations AS pcr
ON pcr.child_id = c.child_id
WHERE c.child_id = :child_id
AND pcr.user_id = :user_id
AND c.status = 1
AND pcr.status = 1
LIMIT 1
""",
{"child_id": child_id, "user_id": user_id},
)
).mappings().first()
async def list_by_parent(self, user_id: int, limit: int = 20, cursor: int = None) -> list[Mapping]: async def list_by_parent(self, user_id: int, limit: int = 20, cursor: int = None) -> list[Mapping]:
params = {"user_id": user_id, "limit": limit + 1} params = {"user_id": user_id, "limit": limit + 1}
where = "pcr.user_id = :user_id AND pcr.status = 1 AND c.status = 1" where = "pcr.user_id = :user_id AND pcr.status = 1 AND c.status = 1"
@@ -104,3 +122,41 @@ class ChildDAO(BaseDAO):
{"child_id": child_id, "user_id": user_id}, {"child_id": child_id, "user_id": user_id},
) )
return result.scalar_one_or_none() is not None return result.scalar_one_or_none() is not None
async def soft_delete_for_parent(self, child_id: int, user_id: int) -> bool:
if not await self.has_access(child_id, user_id):
return False
await self.execute(
"""
UPDATE children
SET status = 0,
updated_at = CURRENT_TIMESTAMP
WHERE child_id = :child_id
AND status = 1
""",
{"child_id": child_id},
)
await self.execute(
"""
UPDATE parent_child_relations
SET status = 0,
updated_at = CURRENT_TIMESTAMP
WHERE child_id = :child_id
AND user_id = :user_id
AND status = 1
""",
{"child_id": child_id, "user_id": user_id},
)
await self.execute(
"""
UPDATE device_bindings
SET child_id = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE child_id = :child_id
AND owner_user_id = :user_id
AND status = 1
""",
{"child_id": child_id, "user_id": user_id},
)
return True

View File

@@ -72,8 +72,9 @@ async def get_child(
request: Request, request: Request,
current_user_id: int = Depends(get_current_user_id), current_user_id: int = Depends(get_current_user_id),
) -> ChildResponse: ) -> ChildResponse:
del request
service = ChildService() service = ChildService()
child = await service.get(child_id) child = await service.get(child_id, current_user_id)
if not child: if not child:
raise HTTPException(status_code=404, detail="child not found") raise HTTPException(status_code=404, detail="child not found")
return ChildResponse(**child) return ChildResponse(**child)
@@ -94,3 +95,16 @@ async def update_child(
if not child: if not child:
raise HTTPException(status_code=404, detail="child not found") raise HTTPException(status_code=404, detail="child not found")
return ChildResponse(**child) return ChildResponse(**child)
@router.delete("/{child_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_child(
child_id: int,
request: Request,
current_user_id: int = Depends(get_current_user_id),
) -> None:
del request
service = ChildService()
deleted = await service.delete(child_id, current_user_id)
if not deleted:
raise HTTPException(status_code=404, detail="child not found")

View File

@@ -1,12 +1,13 @@
import logging import logging
from collections.abc import Mapping from collections.abc import Mapping
from datetime import datetime, time from datetime import datetime, time, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import text from sqlalchemy import text
import asyncio import asyncio
from datetime import timedelta from datetime import timedelta
from handlers.mqtt_handler import TalkingQMQTTService from handlers.mqtt_handler import TalkingQMQTTService
from services.connection_manager import connection_manager
try: try:
from banban.security import get_current_user_id from banban.security import get_current_user_id
from banban.schemas.location import ( from banban.schemas.location import (
@@ -95,6 +96,15 @@ class DeviceStatusResponse(BaseModel):
location_updated_at: datetime | None = None location_updated_at: datetime | None = None
class DeviceOnlineStatusResponse(BaseModel):
device_id: str
online: bool
reason: str
message: str
last_location_at: datetime | None = None
checked_at: datetime
class DeviceAlarmItem(BaseModel): class DeviceAlarmItem(BaseModel):
alarm_id: int alarm_id: int
device_id: str device_id: str
@@ -276,6 +286,18 @@ def _row_to_device_status_response(row: Mapping) -> DeviceStatusResponse:
) )
def _seconds_since(value: datetime | None, now: datetime) -> float | None:
if value is None:
return None
normalized_value = value
normalized_now = now
if normalized_value.tzinfo is not None and normalized_now.tzinfo is None:
normalized_now = normalized_now.replace(tzinfo=timezone.utc)
elif normalized_value.tzinfo is None and normalized_now.tzinfo is not None:
normalized_value = normalized_value.replace(tzinfo=normalized_now.tzinfo)
return (normalized_now - normalized_value).total_seconds()
def _row_to_alarm_item(row: Mapping) -> DeviceAlarmItem: def _row_to_alarm_item(row: Mapping) -> DeviceAlarmItem:
return DeviceAlarmItem( return DeviceAlarmItem(
alarm_id=int(row["alarm_id"]), alarm_id=int(row["alarm_id"]),
@@ -385,6 +407,58 @@ async def get_device_status(
return _row_to_device_status_response(row) return _row_to_device_status_response(row)
@router.get("/{device_id}/online-status", response_model=DeviceOnlineStatusResponse)
async def get_device_online_status(
device_id: str,
request: Request,
current_user_id: int = Depends(get_current_user_id),
) -> DeviceOnlineStatusResponse:
row = await device_service.get_device_status(
device_id=device_id,
user_id=current_user_id,
)
checked_at = datetime.now()
websocket_online = await connection_manager.is_connected(device_id)
last_location_at = row.get("location_updated_at")
location_age_seconds = _seconds_since(last_location_at, checked_at)
recent_location_online = location_age_seconds is not None and location_age_seconds <= 300
online = bool(websocket_online or recent_location_online)
if websocket_online:
reason = "websocket_connected"
message = "设备在线"
elif recent_location_online:
reason = "recent_location"
message = "设备最近有位置上报"
elif last_location_at is None:
reason = "no_location"
message = "设备不在线或暂时无法上报位置"
else:
reason = "location_stale"
message = "设备不在线或暂时无法上报位置"
logger.info(
"device online status fetched",
extra={
"event": "device_online_status",
"request_id": getattr(request.state, "request_id", None),
"user_id": current_user_id,
"device_id": device_id,
"online": online,
"reason": reason,
"last_location_at": last_location_at,
},
)
return DeviceOnlineStatusResponse(
device_id=device_id,
online=online,
reason=reason,
message=message,
last_location_at=last_location_at,
checked_at=checked_at,
)
@router.get("/{device_id}/alarms", response_model=DeviceAlarmListResponse) @router.get("/{device_id}/alarms", response_model=DeviceAlarmListResponse)
async def list_device_alarms( async def list_device_alarms(
device_id: str, device_id: str,

View File

@@ -455,15 +455,37 @@ async def create_child_voice_message_for_parent(
duration_ms: int = Form(..., ge=0), duration_ms: int = Form(..., ge=0),
client_msg_id: str = Form(..., min_length=1, max_length=64), client_msg_id: str = Form(..., min_length=1, max_length=64),
transcript_text: str | None = Form(default=None, max_length=1000), transcript_text: str | None = Form(default=None, max_length=1000),
audio_format: str | None = Form(default=None, max_length=16),
mime_type: str | None = Form(default=None, max_length=64),
current_user_id: int = Depends(get_current_user_id), current_user_id: int = Depends(get_current_user_id),
) -> ConversationMessageCreateResponse: ) -> ConversationMessageCreateResponse:
try: try:
content = await file.read() content = await file.read()
normalized_format = (audio_format or "").strip().lower().lstrip(".")
filename = file.filename or (f"voice.{normalized_format}" if normalized_format else None)
content_type = (mime_type or "").strip() or file.content_type
logger.info(
"parent child voice upload received",
extra={
"event": "parent_child_voice_upload_receive",
"request_id": getattr(request.state, "request_id", None),
"user_id": current_user_id,
"child_id": child_id,
"file_name": file.filename,
"effective_file_name": filename,
"file_content_type": file.content_type,
"client_mime_type": mime_type,
"effective_content_type": content_type,
"client_audio_format": audio_format,
"duration_ms": duration_ms,
"size_bytes": len(content),
},
)
result = await im_service.create_parent_child_voice_message( result = await im_service.create_parent_child_voice_message(
parent_user_id=current_user_id, parent_user_id=current_user_id,
child_id=child_id, child_id=child_id,
filename=file.filename, filename=filename,
content_type=file.content_type, content_type=content_type,
content=content, content=content,
media_duration_ms=duration_ms, media_duration_ms=duration_ms,
media_transcript_text=transcript_text, media_transcript_text=transcript_text,

View File

@@ -52,11 +52,11 @@ class ChildService(DatabaseServiceBase):
finally: finally:
await db_session.close() await db_session.close()
async def get(self, child_id: int) -> Optional[Mapping]: async def get(self, child_id: int, user_id: int) -> Optional[Mapping]:
db_session = await self.get_session() db_session = await self.get_session()
try: try:
dao = ChildDAO(db_session) dao = ChildDAO(db_session)
return await dao.get_by_id(child_id) return await dao.get_by_parent(child_id, user_id)
finally: finally:
await db_session.close() await db_session.close()
@@ -75,6 +75,21 @@ class ChildService(DatabaseServiceBase):
raise PermissionError("No access to this child") raise PermissionError("No access to this child")
await dao.update(child_id, child_name, child_gender, child_birthday) await dao.update(child_id, child_name, child_gender, child_birthday)
await db_session.commit() await db_session.commit()
return await self.get(child_id) return await self.get(child_id, user_id)
finally:
await db_session.close()
async def delete(self, child_id: int, user_id: int) -> bool:
db_session = await self.get_session()
try:
dao = ChildDAO(db_session)
deleted = await dao.soft_delete_for_parent(child_id, user_id)
if not deleted:
return False
await db_session.commit()
return True
except Exception:
await db_session.rollback()
raise
finally: finally:
await db_session.close() await db_session.close()

View File

@@ -58,7 +58,7 @@ async def websocket_endpoint(websocket: WebSocket):
) )
finally: finally:
if device_id: if device_id:
# await connection_manager.remove_connection(device_id) await connection_manager.remove_connection(device_id, websocket)
await cleanup_device_sessions(device_id) await cleanup_device_sessions(device_id)
# 清理设备相关的所有异步任务 # 清理设备相关的所有异步任务
await task_manager.cancel_device_tasks(device_id) await task_manager.cancel_device_tasks(device_id)

View File

@@ -12,15 +12,23 @@ class ConnectionManager:
async with self.lock: async with self.lock:
self.connections[device_id] = websocket self.connections[device_id] = websocket
async def remove_connection(self, device_id: str): async def remove_connection(self, device_id: str, websocket: Optional[WebSocket] = None):
async with self.lock: async with self.lock:
if device_id in self.connections: if device_id in self.connections and (websocket is None or self.connections[device_id] is websocket):
del self.connections[device_id] del self.connections[device_id]
async def get_connection(self, device_id: str) -> Optional[WebSocket]: async def get_connection(self, device_id: str) -> Optional[WebSocket]:
async with self.lock: async with self.lock:
return self.connections.get(device_id) return self.connections.get(device_id)
async def is_connected(self, device_id: str) -> bool:
websocket = await self.get_connection(device_id)
return (
websocket is not None
and getattr(getattr(websocket, "client_state", None), "name", None) == "CONNECTED"
and not getattr(websocket, "_closed", False)
)
async def get_all_connections(self) -> Dict[str, WebSocket]: async def get_all_connections(self) -> Dict[str, WebSocket]:
async with self.lock: async with self.lock:
return dict(self.connections) return dict(self.connections)