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`
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'>
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user