Compare commits
4 Commits
2639ab06ab
...
6c77672ca7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c77672ca7 | ||
|
|
3449224cd2 | ||
|
|
8cff60f87b | ||
|
|
5d86ee5826 |
@@ -53,6 +53,135 @@ function formatAudioDuration(durationMs?: number | null): string {
|
||||
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() {
|
||||
const systemBanner = useSystemBanner()
|
||||
const router = useRouter()
|
||||
@@ -99,21 +228,61 @@ export default function ChatDetail() {
|
||||
const activeConversationIdRef = useRef(initialConversationId)
|
||||
const latestLoadChatDataRef = useRef<(conversationId: number) => Promise<void>>(async () => {})
|
||||
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(() => {
|
||||
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()
|
||||
audioContextRef.current = audioContext
|
||||
|
||||
audioContext.onEnded(() => {
|
||||
console.info('[audio] ended', { playingMessageId: playingMessageIdRef.current })
|
||||
setPlayingMessageId(null)
|
||||
})
|
||||
audioContext.onStop(() => {
|
||||
console.info('[audio] stopped', { playingMessageId: playingMessageIdRef.current })
|
||||
setPlayingMessageId(null)
|
||||
})
|
||||
audioContext.onError(() => {
|
||||
audioContext.onError((error) => {
|
||||
setPlayingMessageId(null)
|
||||
console.error('[audio] error', {
|
||||
playingMessageId: playingMessageIdRef.current,
|
||||
src: audioContext.src,
|
||||
error,
|
||||
runtime: runtimeInfoRef.current,
|
||||
})
|
||||
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 () => {
|
||||
audioContext.destroy()
|
||||
@@ -125,6 +294,10 @@ export default function ChatDetail() {
|
||||
activeConversationIdRef.current = activeConversationId
|
||||
}, [activeConversationId])
|
||||
|
||||
useEffect(() => {
|
||||
playingMessageIdRef.current = playingMessageId
|
||||
}, [playingMessageId])
|
||||
|
||||
useEffect(() => {
|
||||
void loadChatData(activeConversationId)
|
||||
}, [activeConversationId, conversationSource, conversationPeerKind])
|
||||
@@ -166,42 +339,85 @@ export default function ChatDetail() {
|
||||
latestLoadChatDataRef.current = loadChatData
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof Taro.getRecorderManager !== 'function') return
|
||||
if (typeof Taro.getRecorderManager !== 'function') {
|
||||
console.warn('[record] RecorderManager unavailable', runtimeInfoRef.current)
|
||||
return
|
||||
}
|
||||
|
||||
const recorderManager = Taro.getRecorderManager()
|
||||
recorderManagerRef.current = recorderManager
|
||||
|
||||
recorderManager.onStart(() => {
|
||||
console.info('[record] started', {
|
||||
profile: recordProfileRef.current,
|
||||
runtime: runtimeInfoRef.current,
|
||||
})
|
||||
})
|
||||
|
||||
recorderManager.onError((error) => {
|
||||
recordingChildIdRef.current = null
|
||||
recordStartedAtRef.current = 0
|
||||
setRecording(false)
|
||||
setRecordHint('长按开始留言')
|
||||
console.error('[chat-detail] recorder failed:', error)
|
||||
console.error('[record] error', {
|
||||
error,
|
||||
profile: recordProfileRef.current,
|
||||
runtime: runtimeInfoRef.current,
|
||||
})
|
||||
Taro.showToast({ title: '录音失败,请重试', icon: 'none' })
|
||||
})
|
||||
|
||||
recorderManager.onStop(async (result) => {
|
||||
const currentChildId = recordingChildIdRef.current
|
||||
const startedAt = recordStartedAtRef.current
|
||||
const fallbackDurationMs = startedAt > 0 ? Date.now() - startedAt : 0
|
||||
recordingChildIdRef.current = null
|
||||
recordStartedAtRef.current = 0
|
||||
setRecording(false)
|
||||
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 (!result.tempFilePath) {
|
||||
console.warn('[record] missing tempFilePath', { result })
|
||||
Taro.showToast({ title: '录音文件不存在', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const durationMs = Math.max(0, Number(result.duration || 0))
|
||||
const durationMs = Math.max(0, Number(result.duration || 0) || fallbackDurationMs)
|
||||
if (durationMs < 800) {
|
||||
console.warn('[record] too short', {
|
||||
durationMs,
|
||||
resultDuration: result.duration,
|
||||
fallbackDurationMs,
|
||||
})
|
||||
Taro.showToast({ title: '留言太短,请重试', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
setSending(true)
|
||||
try {
|
||||
const profile = recordProfileRef.current
|
||||
const response = await sendParentVoiceMessage(currentChildId, {
|
||||
filePath: result.tempFilePath,
|
||||
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) {
|
||||
setActiveConversationId(response.conversation_id)
|
||||
@@ -232,16 +448,27 @@ export default function ChatDetail() {
|
||||
const ensureRecordPermission = async (): Promise<boolean> => {
|
||||
try {
|
||||
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' })
|
||||
console.info('[record] permission granted by authorize')
|
||||
return true
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.warn('[record] permission denied or authorize failed', {
|
||||
error,
|
||||
runtime: runtimeInfoRef.current,
|
||||
})
|
||||
const modal = await Taro.showModal({
|
||||
title: '需要录音权限',
|
||||
content: '请允许小程序使用麦克风后再留言',
|
||||
confirmText: '去设置',
|
||||
})
|
||||
if (modal.confirm) {
|
||||
console.info('[record] openSetting requested')
|
||||
await Taro.openSetting()
|
||||
}
|
||||
return false
|
||||
@@ -264,22 +491,37 @@ export default function ChatDetail() {
|
||||
const hasPermission = await ensureRecordPermission()
|
||||
if (!hasPermission) return
|
||||
|
||||
const runtime = readVoiceRuntimeInfo()
|
||||
const profile = getPreferredRecordProfile(runtime)
|
||||
runtimeInfoRef.current = runtime
|
||||
recordProfileRef.current = profile
|
||||
recordingChildIdRef.current = character.childId
|
||||
recordStartedAtRef.current = Date.now()
|
||||
setRecording(true)
|
||||
setRecordHint('松开发送留言')
|
||||
console.info('[record] start request', {
|
||||
childId: character.childId,
|
||||
profile,
|
||||
runtime,
|
||||
})
|
||||
try {
|
||||
recorderManager.start({
|
||||
duration: 60000,
|
||||
format: 'mp3',
|
||||
format: profile.format,
|
||||
numberOfChannels: 1,
|
||||
sampleRate: 16000,
|
||||
encodeBitRate: 96000,
|
||||
sampleRate: profile.sampleRate,
|
||||
encodeBitRate: profile.encodeBitRate,
|
||||
})
|
||||
} catch (error: any) {
|
||||
recordingChildIdRef.current = null
|
||||
recordStartedAtRef.current = 0
|
||||
setRecording(false)
|
||||
setRecordHint('长按开始留言')
|
||||
console.error('[chat-detail] recorder start failed:', error)
|
||||
console.error('[record] start failed:', {
|
||||
error,
|
||||
profile,
|
||||
runtime,
|
||||
})
|
||||
Taro.showToast({
|
||||
title: '无法开始录音,请重试',
|
||||
icon: 'none',
|
||||
@@ -290,9 +532,15 @@ export default function ChatDetail() {
|
||||
const handleRecordStop = () => {
|
||||
if (!recording) return
|
||||
const recorderManager = recorderManagerRef.current
|
||||
if (!recorderManager) return
|
||||
if (!recorderManager) {
|
||||
console.warn('[record] stop skipped: recorder unavailable')
|
||||
return
|
||||
}
|
||||
|
||||
setRecordHint('正在处理留言...')
|
||||
console.info('[record] stop request', {
|
||||
elapsedMs: recordStartedAtRef.current > 0 ? Date.now() - recordStartedAtRef.current : 0,
|
||||
})
|
||||
recorderManager.stop()
|
||||
}
|
||||
|
||||
@@ -302,13 +550,29 @@ export default function ChatDetail() {
|
||||
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
|
||||
if (!audioContext) {
|
||||
console.warn('[audio] play skipped: audio context unavailable', runtimeInfoRef.current)
|
||||
Taro.showToast({ title: '播放器未就绪', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
if (playingMessageId === message.id) {
|
||||
console.info('[audio] stop current', { messageId: message.id })
|
||||
audioContext.stop()
|
||||
setPlayingMessageId(null)
|
||||
return
|
||||
@@ -318,6 +582,11 @@ export default function ChatDetail() {
|
||||
audioContext.src = message.mediaUrl
|
||||
audioContext.autoplay = true
|
||||
audioContext.play()
|
||||
console.info('[audio] play invoked', {
|
||||
messageId: message.id,
|
||||
srcRaw: message.mediaUrl,
|
||||
src: audioContext.src,
|
||||
})
|
||||
setPlayingMessageId(message.id)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState } from 'react'
|
||||
import Taro, { useDidShow } from '@tarojs/taro'
|
||||
import { getToken } from '@/services/auth'
|
||||
import { loadCurrentChildBindingContext } from '@/services/binding'
|
||||
import { getDeviceOnlineStatus } from '@/services/device'
|
||||
import {
|
||||
getCurrentDeviceLocation,
|
||||
getDeviceTrajectory,
|
||||
@@ -125,6 +126,7 @@ export default function Location() {
|
||||
const [selectedPoint, setSelectedPoint] = useState<DeviceTrajectoryPoint | null>(null)
|
||||
const [trajectoryMode, setTrajectoryMode] = useState<TrajectoryMode>('current')
|
||||
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>(
|
||||
null
|
||||
)
|
||||
@@ -147,6 +149,7 @@ export default function Location() {
|
||||
|
||||
setLoading(true)
|
||||
setEmptyState(null)
|
||||
setLocationNotice(null)
|
||||
if (nextMode) {
|
||||
setTrajectoryMode(nextMode)
|
||||
setSelectedPoint(null)
|
||||
@@ -183,6 +186,16 @@ export default function Location() {
|
||||
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)
|
||||
setDeviceLocation(currentLocation)
|
||||
|
||||
@@ -413,7 +426,9 @@ export default function Location() {
|
||||
<View className='header-left'>
|
||||
<Text className='location-title'>{summaryTitle}</Text>
|
||||
<Text className='location-update'>
|
||||
{loading
|
||||
{locationNotice
|
||||
? locationNotice
|
||||
: loading
|
||||
? '正在获取设备位置...'
|
||||
: trajectoryMode === 'current'
|
||||
? `更新于 ${formatTime(deviceLocation?.updated_at)}`
|
||||
@@ -444,7 +459,8 @@ export default function Location() {
|
||||
</View>
|
||||
) : (
|
||||
<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>
|
||||
)
|
||||
) : trajectory.length > 0 ? (
|
||||
|
||||
@@ -480,15 +480,24 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.device-switch-id {
|
||||
min-width: 0;
|
||||
font-size: 28px;
|
||||
color: #1A1A1A;
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.device-switch-tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-switch-tag {
|
||||
margin-left: 16px;
|
||||
padding: 6px 14px;
|
||||
@@ -497,6 +506,14 @@
|
||||
font-size: 22px;
|
||||
color: #FFFFFF;
|
||||
flex-shrink: 0;
|
||||
|
||||
.device-switch-tags & {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background: #E5484D;
|
||||
}
|
||||
}
|
||||
|
||||
.device-switch-name {
|
||||
@@ -504,6 +521,15 @@
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.delete-child-item {
|
||||
border: 2px solid transparent;
|
||||
|
||||
&:active {
|
||||
background: #FFF1F2;
|
||||
border-color: #FDA4AF;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
|
||||
@@ -9,12 +9,13 @@ import {
|
||||
setSelectedBindingDeviceId,
|
||||
unbindDevice,
|
||||
} from '@/services/binding'
|
||||
import { Child, clearSelectedChildId, createChild, setSelectedChildId, updateChild } from '@/services/child'
|
||||
import { Child, clearSelectedChildId, createChild, deleteChild, setSelectedChildId, updateChild } from '@/services/child'
|
||||
import {
|
||||
DeviceCurrentRole,
|
||||
DeviceFirmwareStatus,
|
||||
DeviceRoleSummary,
|
||||
getDeviceFirmwareStatus,
|
||||
getDeviceOnlineStatus,
|
||||
getDeviceRole,
|
||||
getDeviceRoles,
|
||||
startDeviceFirmwareUpdate,
|
||||
@@ -48,6 +49,7 @@ export default function Sleep() {
|
||||
const [isUpdatingRole, setIsUpdatingRole] = useState(false)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [showChildModal, setShowChildModal] = useState(false)
|
||||
const [showDeleteChildModal, setShowDeleteChildModal] = useState(false)
|
||||
const [showRoleModal, setShowRoleModal] = useState(false)
|
||||
const [modalType, setModalType] = useState<'add' | 'edit'>('add')
|
||||
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 = () => {
|
||||
if (!binding?.device_id || !firmwareStatus?.can_update || isLoadingFirmware || isUpdatingFirmware) return
|
||||
|
||||
@@ -220,6 +259,15 @@ export default function Sleep() {
|
||||
|
||||
setIsUpdatingFirmware(true)
|
||||
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)
|
||||
setFirmwareStatus(nextFirmwareStatus)
|
||||
Taro.showToast({
|
||||
@@ -332,6 +380,11 @@ export default function Sleep() {
|
||||
return
|
||||
}
|
||||
|
||||
if (item.name === '删除孩子') {
|
||||
setShowDeleteChildModal(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (item.name === '绑定设备') {
|
||||
if (!currentChild) {
|
||||
handleOpenModal('add')
|
||||
@@ -405,6 +458,14 @@ export default function Sleep() {
|
||||
value: '',
|
||||
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'),
|
||||
iconBgClass: 'green',
|
||||
@@ -572,6 +633,50 @@ export default function Sleep() {
|
||||
</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 && (
|
||||
<View className='modal-mask'>
|
||||
<View className='modal-card'>
|
||||
|
||||
@@ -690,7 +690,14 @@ function getUploadErrorMessage(data: any): string {
|
||||
|
||||
export async function sendParentVoiceMessage(
|
||||
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> {
|
||||
if (!childId) {
|
||||
throw new Error('当前会话不支持留言')
|
||||
@@ -701,6 +708,16 @@ export async function sendParentVoiceMessage(
|
||||
|
||||
const token = getToken()
|
||||
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) => {
|
||||
Taro.uploadFile({
|
||||
url: requestUrl,
|
||||
@@ -715,9 +732,26 @@ export async function sendParentVoiceMessage(
|
||||
duration_ms: `${Math.max(1, Math.round(options.durationMs || 0))}`,
|
||||
client_msg_id: createClientMessageId(),
|
||||
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) => {
|
||||
const message = String(error?.errMsg || error?.message || 'request:fail')
|
||||
@@ -730,6 +764,10 @@ export async function sendParentVoiceMessage(
|
||||
|
||||
const data = parseUploadResponseData(uploadResponse.data)
|
||||
if (uploadResponse.statusCode >= 400) {
|
||||
console.error('[voice-upload] error response', {
|
||||
statusCode: uploadResponse.statusCode,
|
||||
data,
|
||||
})
|
||||
throw new ApiError(uploadResponse.statusCode, getUploadErrorMessage(data))
|
||||
}
|
||||
|
||||
|
||||
@@ -88,3 +88,9 @@ export async function updateChild(
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteChild(childId: number): Promise<void> {
|
||||
return request<void>(`/banban/children/${childId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -28,6 +28,15 @@ export interface DeviceStatus {
|
||||
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 {
|
||||
alarm_id: number
|
||||
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> {
|
||||
const resolvedDeviceId = await resolveDeviceId(deviceId)
|
||||
if (!resolvedDeviceId) {
|
||||
|
||||
@@ -42,11 +42,29 @@ class ChildDAO(BaseDAO):
|
||||
async def get_by_id(self, child_id: int) -> Optional[Mapping]:
|
||||
return (
|
||||
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},
|
||||
)
|
||||
).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]:
|
||||
params = {"user_id": user_id, "limit": limit + 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},
|
||||
)
|
||||
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
|
||||
|
||||
@@ -72,8 +72,9 @@ async def get_child(
|
||||
request: Request,
|
||||
current_user_id: int = Depends(get_current_user_id),
|
||||
) -> ChildResponse:
|
||||
del request
|
||||
service = ChildService()
|
||||
child = await service.get(child_id)
|
||||
child = await service.get(child_id, current_user_id)
|
||||
if not child:
|
||||
raise HTTPException(status_code=404, detail="child not found")
|
||||
return ChildResponse(**child)
|
||||
@@ -94,3 +95,16 @@ async def update_child(
|
||||
if not child:
|
||||
raise HTTPException(status_code=404, detail="child not found")
|
||||
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")
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import logging
|
||||
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 pydantic import BaseModel, Field
|
||||
from sqlalchemy import text
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
from handlers.mqtt_handler import TalkingQMQTTService
|
||||
from services.connection_manager import connection_manager
|
||||
try:
|
||||
from banban.security import get_current_user_id
|
||||
from banban.schemas.location import (
|
||||
@@ -95,6 +96,15 @@ class DeviceStatusResponse(BaseModel):
|
||||
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):
|
||||
alarm_id: int
|
||||
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:
|
||||
return DeviceAlarmItem(
|
||||
alarm_id=int(row["alarm_id"]),
|
||||
@@ -385,6 +407,58 @@ async def get_device_status(
|
||||
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)
|
||||
async def list_device_alarms(
|
||||
device_id: str,
|
||||
|
||||
@@ -455,15 +455,37 @@ async def create_child_voice_message_for_parent(
|
||||
duration_ms: int = Form(..., ge=0),
|
||||
client_msg_id: str = Form(..., min_length=1, max_length=64),
|
||||
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),
|
||||
) -> ConversationMessageCreateResponse:
|
||||
try:
|
||||
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(
|
||||
parent_user_id=current_user_id,
|
||||
child_id=child_id,
|
||||
filename=file.filename,
|
||||
content_type=file.content_type,
|
||||
filename=filename,
|
||||
content_type=content_type,
|
||||
content=content,
|
||||
media_duration_ms=duration_ms,
|
||||
media_transcript_text=transcript_text,
|
||||
|
||||
@@ -52,11 +52,11 @@ class ChildService(DatabaseServiceBase):
|
||||
finally:
|
||||
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()
|
||||
try:
|
||||
dao = ChildDAO(db_session)
|
||||
return await dao.get_by_id(child_id)
|
||||
return await dao.get_by_parent(child_id, user_id)
|
||||
finally:
|
||||
await db_session.close()
|
||||
|
||||
@@ -75,6 +75,21 @@ class ChildService(DatabaseServiceBase):
|
||||
raise PermissionError("No access to this child")
|
||||
await dao.update(child_id, child_name, child_gender, child_birthday)
|
||||
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:
|
||||
await db_session.close()
|
||||
|
||||
@@ -58,7 +58,7 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
)
|
||||
finally:
|
||||
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 task_manager.cancel_device_tasks(device_id)
|
||||
|
||||
@@ -12,15 +12,23 @@ class ConnectionManager:
|
||||
async with self.lock:
|
||||
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:
|
||||
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]
|
||||
|
||||
async def get_connection(self, device_id: str) -> Optional[WebSocket]:
|
||||
async with self.lock:
|
||||
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 with self.lock:
|
||||
return dict(self.connections)
|
||||
|
||||
Reference in New Issue
Block a user