1208 lines
43 KiB
TypeScript
1208 lines
43 KiB
TypeScript
import apiManager from '../../utils/api'
|
||
import logger from '../../utils/logger'
|
||
const recorderManager = wx.getRecorderManager()
|
||
let recorderHandlersBound = false
|
||
|
||
interface IData {
|
||
loadingMaskVisible: boolean
|
||
imageId: string
|
||
taskId?: string
|
||
statusText: string
|
||
scene?: {
|
||
id: string
|
||
image_id: string
|
||
list: Array<{
|
||
collocations: string[]
|
||
coreVocab: string[]
|
||
coreVocabDesc?: string[]
|
||
functionTags: string[]
|
||
pronunciationTip: string
|
||
pronunciationUrl?: string | null
|
||
imageTextId?: string
|
||
sceneExplanation: string
|
||
sentenceEn: string
|
||
sentenceZh: string
|
||
avoidScenarios?: string
|
||
}>
|
||
}
|
||
currentIndex: number
|
||
transDisplayMode: 'en' | 'en_zh'
|
||
contentVisible: boolean
|
||
isPlaying: boolean
|
||
isRecording: boolean
|
||
hasScoreInfo: boolean
|
||
isScoreModalOpen: boolean
|
||
scoreModalVisible: boolean
|
||
currentSentence?: {
|
||
content: string
|
||
file_id?: string
|
||
id?: string
|
||
}
|
||
totalScore: number
|
||
accuracyScore: number
|
||
completenessScore: number
|
||
fluencyScore: number
|
||
displayTotalScore: number
|
||
displayAccuracyScore: number
|
||
displayCompletenessScore: number
|
||
displayFluencyScore: number
|
||
circleProgressStyle: string
|
||
accuracyCircleStyle: string
|
||
completenessCircleStyle: string
|
||
fluencyCircleStyle: string
|
||
wordScores: Array<any>
|
||
englishWords?: Array<string>
|
||
showDictPopup?: boolean
|
||
showDictExtended?: boolean
|
||
dictLoading?: boolean
|
||
wordDict?: any
|
||
isWordEmptyResult?: boolean
|
||
dictDefaultTabValue?: string
|
||
standardAudioMap?: Record<string, string>
|
||
standardAudioLocalMap?: Record<string, string>
|
||
assessmentAudioLocalMap?: Record<string, string>
|
||
assessmentCache?: Record<string, {
|
||
hasScoreInfo: boolean
|
||
totalScore: number
|
||
accuracyScore: number
|
||
completenessScore: number
|
||
fluencyScore: number
|
||
wordScores: Array<any>
|
||
file_id?: string
|
||
content?: string
|
||
}>
|
||
wordAudioPlaying?: boolean
|
||
wordAudioIconName?: string
|
||
activeWordAudioType?: string
|
||
buttonsVisible?: boolean
|
||
commonMistakes?: string[]
|
||
pragmaticAlternative?: string[]
|
||
responsePairs?: string[]
|
||
recordStartTime?: number
|
||
recordDuration?: number
|
||
remainingTime?: number
|
||
overlayVisible?: boolean
|
||
highlightWords?: Array<any>
|
||
highlightShow?: boolean
|
||
highlightZoom?: boolean
|
||
analizing?: boolean
|
||
cachedHighlightWords?: Array<any>
|
||
cachedSentenceIndex?: number
|
||
loadingDots?: string
|
||
loadingLabel?: string
|
||
showBackIcon?: boolean
|
||
previousWord?: string
|
||
isReturningFromPrevious?: boolean
|
||
prototypeWord?: string
|
||
forceHidePrototype?: boolean
|
||
}
|
||
|
||
interface IPageInstance {
|
||
pollTimer?: number
|
||
audioCtx?: any
|
||
wordAudioContext?: WechatMiniprogram.InnerAudioContext
|
||
wordAudioIconTimer?: number
|
||
circleAnimTimer?: number
|
||
recordTimer?: number
|
||
loadingDotsTimer?: number
|
||
fetchSceneSentence: (imageId: string) => Promise<void>
|
||
startPolling: (taskId: string, imageId: string) => void
|
||
onTransTap: () => void
|
||
onPrevTap: () => void
|
||
onNextTap: () => void
|
||
switchSentence: (delta: number) => void
|
||
playStandardVoice: () => void
|
||
handleRecordStart: () => void
|
||
handleRecordEnd: () => void
|
||
onScoreTap: () => void
|
||
onCloseScoreModal: () => void
|
||
onWordTap: (e: any) => void
|
||
handleDictClose: () => void
|
||
handleDictMore: () => void
|
||
processCollinsData: (collinsData: any) => any
|
||
getStandardVoice: (sentenceId: string) => Promise<void>
|
||
updateCircleProgress: () => void
|
||
playAssessmentVoice: () => void
|
||
noop: () => void
|
||
ensureRecordPermission: () => void
|
||
startRecording: () => void
|
||
stopRecording: () => void
|
||
onMicHighlight: () => void
|
||
computeHighlightLayout: () => void
|
||
fetchRecordResultForSentence: (textId: string) => Promise<void>
|
||
startLoadingDots: () => void
|
||
stopLoadingDots: () => void
|
||
handleBackToPreviousWord: () => void
|
||
}
|
||
|
||
Page<IData, IPageInstance>({
|
||
data: {
|
||
loadingMaskVisible: false,
|
||
imageId: '',
|
||
statusText: '加载中...',
|
||
scene: undefined,
|
||
currentIndex: 0,
|
||
transDisplayMode: 'en_zh',
|
||
contentVisible: false,
|
||
isPlaying: false,
|
||
isRecording: false,
|
||
hasScoreInfo: false,
|
||
isScoreModalOpen: false,
|
||
scoreModalVisible: false,
|
||
currentSentence: undefined,
|
||
totalScore: 0,
|
||
accuracyScore: 0,
|
||
completenessScore: 0,
|
||
fluencyScore: 0,
|
||
displayTotalScore: 0,
|
||
displayAccuracyScore: 0,
|
||
displayCompletenessScore: 0,
|
||
displayFluencyScore: 0,
|
||
circleProgressStyle: '',
|
||
accuracyCircleStyle: '',
|
||
completenessCircleStyle: '',
|
||
fluencyCircleStyle: '',
|
||
wordScores: [],
|
||
englishWords: [],
|
||
showDictPopup: false,
|
||
showDictExtended: false,
|
||
dictLoading: false,
|
||
wordDict: {},
|
||
isWordEmptyResult: false,
|
||
dictDefaultTabValue: '0',
|
||
standardAudioMap: {},
|
||
standardAudioLocalMap: {},
|
||
assessmentAudioLocalMap: {},
|
||
assessmentCache: {},
|
||
wordAudioPlaying: false,
|
||
wordAudioIconName: 'sound',
|
||
activeWordAudioType: '',
|
||
buttonsVisible: false,
|
||
commonMistakes: [],
|
||
pragmaticAlternative: [],
|
||
responsePairs: [],
|
||
recordStartTime: 0,
|
||
recordDuration: 0,
|
||
remainingTime: 30,
|
||
overlayVisible: false,
|
||
highlightWords: ["this", "dish"],
|
||
highlightShow: false,
|
||
highlightZoom: false,
|
||
analizing: false,
|
||
cachedHighlightWords: [],
|
||
cachedSentenceIndex: -1,
|
||
loadingDots: '',
|
||
loadingLabel: '场景分析中',
|
||
showBackIcon: false,
|
||
previousWord: '',
|
||
isReturningFromPrevious: false,
|
||
prototypeWord: '',
|
||
forceHidePrototype: false
|
||
},
|
||
noop() {},
|
||
|
||
async onLoad(options: Record<string, string>) {
|
||
const imageId = options.image_id || options.imageId || ''
|
||
this.setData({ imageId })
|
||
if (!imageId) {
|
||
wx.showToast({ title: '缺少 image_id', icon: 'none' })
|
||
return
|
||
}
|
||
this.ensureRecordPermission()
|
||
this.setData({ loadingMaskVisible: true, statusText: '加载中...', currentIndex: 0, transDisplayMode: 'en_zh', contentVisible: false, isPlaying: false, isRecording: false, hasScoreInfo: false, isScoreModalOpen: false, scoreModalVisible: false })
|
||
this.startLoadingDots()
|
||
if (!this.audioCtx) {
|
||
this.audioCtx = wx.createInnerAudioContext()
|
||
this.audioCtx.onEnded(() => { this.setData({ isPlaying: false }) })
|
||
this.audioCtx.onStop(() => { this.setData({ isPlaying: false }) })
|
||
this.audioCtx.onError(() => { this.setData({ isPlaying: false }) })
|
||
}
|
||
try { (recorderManager as any).offStop && (recorderManager as any).offStop() } catch (e) {}
|
||
try { (recorderManager as any).offError && (recorderManager as any).offError() } catch (e) {}
|
||
recorderHandlersBound = false
|
||
if (!recorderHandlersBound) {
|
||
recorderManager.onStop((res) => {
|
||
const ms = Date.now() - (this.data.recordStartTime || 0)
|
||
if (ms >= 3000) {
|
||
wx.showModal({
|
||
title: '提示',
|
||
content: '录音完成,是否确认提交?',
|
||
success: (r) => {
|
||
if (r.confirm) {
|
||
this.setData({ analizing: true })
|
||
apiManager.uploadFile(res.tempFilePath).then((fileId) => {
|
||
const cur = this.data.scene?.list?.[this.data.currentIndex]
|
||
const imageTextId = cur && cur.imageTextId ? String(cur.imageTextId) : ''
|
||
if (!imageTextId) {
|
||
this.setData({ analizing: false })
|
||
wx.showToast({ title: '缺少句子ID', icon: 'none' })
|
||
return
|
||
}
|
||
apiManager.getAssessmentResult(fileId, imageTextId).then((result) => {
|
||
const assessmentResult = result.assessment_result?.assessment?.result || {}
|
||
const suggestedScore = assessmentResult.SuggestedScore ?? -1
|
||
const pronAccuracy = assessmentResult.PronAccuracy ?? -1
|
||
const pronCompletion = assessmentResult.PronCompletion ?? -1
|
||
const pronFluency = assessmentResult.PronFluency ?? -1
|
||
const allNegative = [suggestedScore, pronAccuracy, pronCompletion, pronFluency].every((s: number) => s < 0)
|
||
const wordScores = (assessmentResult.Words || []).map((w: any) => ({
|
||
word: w.Word,
|
||
pronAccuracy: Number((w.PronAccuracy || 0).toFixed(2)),
|
||
pronFluency: Number((w.PronFluency || 0).toFixed(2)),
|
||
matchTag: w.MatchTag || 0,
|
||
phoneInfos: (w.PhoneInfos || []).map((p: any) => ({
|
||
phone: p.Phone,
|
||
pronAccuracy: Number((p.PronAccuracy || 0).toFixed(2)),
|
||
matchTag: p.MatchTag || 0
|
||
}))
|
||
}))
|
||
this.setData({
|
||
hasScoreInfo: !allNegative && !!assessmentResult,
|
||
totalScore: suggestedScore >= 0 ? Number(suggestedScore.toFixed(2)) : 0,
|
||
accuracyScore: pronAccuracy >= 0 ? Number(pronAccuracy.toFixed(2)) : 0,
|
||
completenessScore: pronCompletion >= 0 ? Number(pronCompletion.toFixed(2)) : 0,
|
||
fluencyScore: pronFluency >= 0 ? Number(pronFluency.toFixed(2)) : 0,
|
||
wordScores,
|
||
currentSentence: {
|
||
content: String(this.data.currentSentence?.content || ''),
|
||
id: this.data.currentSentence?.id,
|
||
file_id: fileId
|
||
},
|
||
isRecording: false,
|
||
overlayVisible: false,
|
||
highlightShow: false,
|
||
highlightZoom: false,
|
||
highlightWords: [],
|
||
analizing: false
|
||
})
|
||
const cache = this.data.assessmentCache || {}
|
||
this.setData({
|
||
assessmentCache: {
|
||
...cache,
|
||
[imageTextId]: {
|
||
hasScoreInfo: !allNegative && !!assessmentResult,
|
||
totalScore: suggestedScore >= 0 ? Number(suggestedScore.toFixed(2)) : 0,
|
||
accuracyScore: pronAccuracy >= 0 ? Number(pronAccuracy.toFixed(2)) : 0,
|
||
completenessScore: pronCompletion >= 0 ? Number(pronCompletion.toFixed(2)) : 0,
|
||
fluencyScore: pronFluency >= 0 ? Number(pronFluency.toFixed(2)) : 0,
|
||
wordScores,
|
||
file_id: fileId,
|
||
content: String(this.data.currentSentence?.content || '')
|
||
}
|
||
}
|
||
})
|
||
this.onScoreTap()
|
||
}).catch(err => {
|
||
this.setData({
|
||
isRecording: false,
|
||
overlayVisible: false,
|
||
highlightShow: false,
|
||
highlightZoom: false,
|
||
highlightWords: [],
|
||
analizing: false
|
||
})
|
||
const msg = (err as any)?.message || ''
|
||
if (typeof msg === 'string' && msg.indexOf('积分') !== -1) {
|
||
wx.showModal({
|
||
title: '积分不足',
|
||
content: '您的积分不足,是否前往获取?',
|
||
confirmText: '获取',
|
||
cancelText: '取消',
|
||
success: (res) => {
|
||
if (res.confirm) {
|
||
wx.redirectTo({ url: '/pages/coupon/coupon' })
|
||
} else {
|
||
// wx.navigateBack({ delta: 1 })
|
||
}
|
||
}
|
||
})
|
||
} else {
|
||
wx.showToast({ title: msg || '评估失败', icon: 'none' })
|
||
}
|
||
}).finally(() => {
|
||
this.setData({
|
||
isRecording: false,
|
||
overlayVisible: false,
|
||
highlightShow: false,
|
||
highlightZoom: false,
|
||
highlightWords: [],
|
||
analizing: false
|
||
})
|
||
})
|
||
}).catch(() => {
|
||
this.setData({
|
||
isRecording: false,
|
||
overlayVisible: false,
|
||
highlightShow: false,
|
||
highlightZoom: false,
|
||
highlightWords: [],
|
||
analizing: false
|
||
})
|
||
wx.showToast({ title: '上传失败', icon: 'none' })
|
||
})
|
||
} else {
|
||
this.setData({
|
||
isRecording: false,
|
||
overlayVisible: false,
|
||
highlightShow: false,
|
||
highlightZoom: false,
|
||
highlightWords: [],
|
||
analizing: false
|
||
})
|
||
}
|
||
}
|
||
})
|
||
} else {
|
||
this.setData({
|
||
isRecording: false,
|
||
overlayVisible: false,
|
||
highlightShow: false,
|
||
highlightZoom: false,
|
||
highlightWords: [],
|
||
analizing: false
|
||
})
|
||
wx.showToast({ title: '说话时间太短', icon: 'none' })
|
||
}
|
||
})
|
||
recorderManager.onError(() => {
|
||
wx.showToast({ title: '录音失败', icon: 'none' })
|
||
// this.setData({ overlayVisible: false, isRecording: false })
|
||
const timer = setTimeout(() => {
|
||
// this.setData({ highlightShow: false, highlightZoom: false, highlightWords: [], analizing: false })
|
||
clearTimeout(timer)
|
||
}, 320)
|
||
})
|
||
recorderHandlersBound = true
|
||
}
|
||
await this.fetchSceneSentence(imageId)
|
||
},
|
||
|
||
async fetchSceneSentence(imageId: string) {
|
||
try {
|
||
const resp = await apiManager.getSceneSentence(imageId)
|
||
if (resp && Object.keys(resp || {}).length > 0) {
|
||
logger.info('场景句子数据:', resp)
|
||
const scene = resp.data || resp
|
||
const idx = 0
|
||
const cur = scene && scene.list && scene.list[idx] ? scene.list[idx] : undefined
|
||
const currentSentence = cur ? { content: cur.sentenceEn, file_id: cur.pronunciationUrl || undefined, id: cur.imageTextId } : undefined
|
||
const englishWords = cur && cur.sentenceEn ? cur.sentenceEn.split(' ') : []
|
||
const commonMistakes = (cur as any)?.commonMistakes || []
|
||
const pragmaticAlternative = (cur as any)?.pragmaticAlternative || []
|
||
const responsePairs = (cur as any)?.responsePairs || []
|
||
this.setData({
|
||
scene,
|
||
loadingMaskVisible: false,
|
||
statusText: '已获取数据',
|
||
contentVisible: true,
|
||
currentIndex: idx,
|
||
currentSentence,
|
||
englishWords,
|
||
buttonsVisible: true,
|
||
commonMistakes,
|
||
pragmaticAlternative,
|
||
responsePairs,
|
||
hasScoreInfo: false
|
||
})
|
||
this.stopLoadingDots()
|
||
const sid = cur && cur.imageTextId
|
||
if (sid) {
|
||
try { this.fetchRecordResultForSentence(String(sid)) } catch (e) {}
|
||
}
|
||
} else {
|
||
const { task_id } = await apiManager.createScene(imageId, 'scene_sentence')
|
||
this.setData({ taskId: task_id, statusText: '解析中...' })
|
||
this.startLoadingDots()
|
||
this.startPolling(task_id, imageId)
|
||
}
|
||
} catch (e) {
|
||
logger.error('获取场景句子失败:', e)
|
||
const msg = (e as any)?.message || ''
|
||
if (typeof msg === 'string' && msg.indexOf('积分') !== -1) {
|
||
wx.showModal({
|
||
title: '积分不足',
|
||
content: '您的积分不足,是否前往购买?',
|
||
confirmText: '获取',
|
||
cancelText: '取消',
|
||
success: (res) => {
|
||
if (res.confirm) {
|
||
wx.redirectTo({ url: '/pages/coupon/coupon' })
|
||
} else {
|
||
wx.navigateBack({ delta: 1 })
|
||
}
|
||
}
|
||
})
|
||
} else {
|
||
wx.showToast({
|
||
title: msg || '上传失败',
|
||
icon: 'none'
|
||
})
|
||
}
|
||
this.setData({ loadingMaskVisible: false })
|
||
this.stopLoadingDots()
|
||
}
|
||
},
|
||
|
||
startPolling(taskId: string, imageId: string) {
|
||
if (this.pollTimer) {
|
||
clearInterval(this.pollTimer)
|
||
this.pollTimer = undefined
|
||
}
|
||
this.setData({ loadingMaskVisible: true })
|
||
this.startLoadingDots()
|
||
this.pollTimer = setInterval(async () => {
|
||
try {
|
||
const res = await apiManager.getSceneTask(taskId)
|
||
if (res.status === 'completed') {
|
||
clearInterval(this.pollTimer!)
|
||
this.pollTimer = undefined
|
||
await this.fetchSceneSentence(imageId)
|
||
} else if (res.status === 'failed') {
|
||
clearInterval(this.pollTimer!)
|
||
this.pollTimer = undefined
|
||
wx.showToast({ title: '任务失败', icon: 'none' })
|
||
this.setData({ loadingMaskVisible: false, statusText: '任务失败' })
|
||
this.stopLoadingDots()
|
||
}
|
||
} catch (err) {
|
||
logger.error('轮询任务状态失败:', err)
|
||
}
|
||
}, 3000) as any
|
||
},
|
||
startLoadingDots() {
|
||
if (this.loadingDotsTimer) { clearInterval(this.loadingDotsTimer) }
|
||
let n = 0
|
||
this.loadingDotsTimer = setInterval(() => {
|
||
n = (n % 3) + 1
|
||
const dots = '.'.repeat(n)
|
||
this.setData({ loadingDots: dots })
|
||
}, 500) as any
|
||
},
|
||
stopLoadingDots() {
|
||
if (this.loadingDotsTimer) {
|
||
clearInterval(this.loadingDotsTimer)
|
||
this.loadingDotsTimer = undefined
|
||
}
|
||
this.setData({ loadingDots: '' })
|
||
},
|
||
|
||
onTransTap() {
|
||
const mode = this.data.transDisplayMode === 'en' ? 'en_zh' : 'en'
|
||
this.setData({ transDisplayMode: mode })
|
||
},
|
||
|
||
onPrevTap() {
|
||
this.switchSentence(-1)
|
||
},
|
||
|
||
onNextTap() {
|
||
this.switchSentence(1)
|
||
},
|
||
|
||
switchSentence(delta: number) {
|
||
const list = this.data.scene?.list || []
|
||
const count = list.length
|
||
if (count === 0) return
|
||
const nextIndex = this.data.currentIndex + delta
|
||
if (nextIndex < 0 || nextIndex >= count) return
|
||
if (this.data.isPlaying) {
|
||
this.setData({ isPlaying: false })
|
||
}
|
||
this.setData({ contentVisible: false })
|
||
setTimeout(() => {
|
||
const list = this.data.scene?.list || []
|
||
const cur = list && list[nextIndex] ? list[nextIndex] : undefined
|
||
const currentSentence = cur ? { content: cur.sentenceEn, file_id: cur.pronunciationUrl || undefined, id: cur.imageTextId } : undefined
|
||
const englishWords = cur && cur.sentenceEn ? cur.sentenceEn.split(' ') : []
|
||
const commonMistakes = (cur as any)?.commonMistakes || [
|
||
'1. 漏冠词a(×bowl mint leaves)',
|
||
'2. refreshing误读为/ˈrefrʌʃɪŋ/'
|
||
]
|
||
const pragmaticAlternative = (cur as any)?.pragmaticAlternative || []
|
||
const responsePairs = (cur as any)?.responsePairs || []
|
||
this.setData({
|
||
currentIndex: nextIndex,
|
||
currentSentence,
|
||
englishWords,
|
||
commonMistakes,
|
||
pragmaticAlternative,
|
||
responsePairs,
|
||
contentVisible: true,
|
||
hasScoreInfo: false
|
||
})
|
||
const sid = cur && cur.imageTextId
|
||
if (sid) {
|
||
const sidStr = String(sid)
|
||
const cachedAssess = (this.data.assessmentCache || {})[sidStr]
|
||
if (cachedAssess && cachedAssess.hasScoreInfo) {
|
||
this.setData({
|
||
hasScoreInfo: true,
|
||
totalScore: cachedAssess.totalScore,
|
||
accuracyScore: cachedAssess.accuracyScore,
|
||
completenessScore: cachedAssess.completenessScore,
|
||
fluencyScore: cachedAssess.fluencyScore,
|
||
wordScores: cachedAssess.wordScores || [],
|
||
currentSentence: {
|
||
content: String(currentSentence?.content || cachedAssess.content || ''),
|
||
id: sidStr,
|
||
file_id: cachedAssess.file_id || currentSentence?.file_id
|
||
}
|
||
})
|
||
} else {
|
||
wx.nextTick(() => { this.fetchRecordResultForSentence(sidStr) })
|
||
}
|
||
const hasStd = !!((this.data.standardAudioMap || {})[sidStr])
|
||
if (!hasStd) {
|
||
wx.nextTick(() => { this.getStandardVoice(sidStr) })
|
||
}
|
||
}
|
||
}, 200)
|
||
},
|
||
|
||
async fetchRecordResultForSentence(textId: string) {
|
||
try {
|
||
const cached = (this.data.assessmentCache || {})[textId]
|
||
if (cached && cached.hasScoreInfo) {
|
||
const curSentence = this.data.currentSentence || { content: '' }
|
||
this.setData({
|
||
hasScoreInfo: true,
|
||
totalScore: cached.totalScore,
|
||
accuracyScore: cached.accuracyScore,
|
||
completenessScore: cached.completenessScore,
|
||
fluencyScore: cached.fluencyScore,
|
||
wordScores: cached.wordScores || [],
|
||
currentSentence: {
|
||
content: String(curSentence.content || cached.content || ''),
|
||
id: curSentence.id || textId,
|
||
file_id: cached.file_id || curSentence.file_id
|
||
}
|
||
})
|
||
return
|
||
}
|
||
const rec: any = await apiManager.getRecordResult(textId)
|
||
const hasDetails = !!(rec && rec.details && Object.keys(rec.details || {}).length > 0)
|
||
if (!hasDetails) {
|
||
this.setData({ hasScoreInfo: false })
|
||
return
|
||
}
|
||
const assessment = rec.details?.assessment || {}
|
||
const result = assessment?.result || {}
|
||
const suggestedScore = result?.SuggestedScore ?? 0
|
||
const pronAccuracy = result?.PronAccuracy ?? 0
|
||
const pronCompletion = result?.PronCompletion ?? 0
|
||
const pronFluency = result?.PronFluency ?? 0
|
||
const wordScores = (result?.Words || []).map((w: any) => ({
|
||
word: w.Word,
|
||
pronAccuracy: Number((w.PronAccuracy || 0).toFixed(2)),
|
||
pronFluency: Number((w.PronFluency || 0).toFixed(2)),
|
||
matchTag: w.MatchTag || 0,
|
||
phoneInfos: (w.PhoneInfos || []).map((p: any) => ({
|
||
phone: p.Phone,
|
||
pronAccuracy: Number((p.PronAccuracy || 0).toFixed(2)),
|
||
matchTag: p.MatchTag || 0
|
||
}))
|
||
}))
|
||
const curSentence = this.data.currentSentence || { content: '' }
|
||
this.setData({
|
||
hasScoreInfo: true,
|
||
totalScore: Number((suggestedScore || 0).toFixed(2)),
|
||
accuracyScore: Number((pronAccuracy || 0).toFixed(2)),
|
||
completenessScore: Number((pronCompletion || 0).toFixed(2)),
|
||
fluencyScore: Number((pronFluency || 0).toFixed(2)),
|
||
wordScores,
|
||
currentSentence: { ...curSentence, file_id: rec?.file_id || curSentence.file_id }
|
||
})
|
||
const cache = this.data.assessmentCache || {}
|
||
this.setData({
|
||
assessmentCache: {
|
||
...cache,
|
||
[textId]: {
|
||
hasScoreInfo: true,
|
||
totalScore: Number((suggestedScore || 0).toFixed(2)),
|
||
accuracyScore: Number((pronAccuracy || 0).toFixed(2)),
|
||
completenessScore: Number((pronCompletion || 0).toFixed(2)),
|
||
fluencyScore: Number((pronFluency || 0).toFixed(2)),
|
||
wordScores,
|
||
file_id: rec?.file_id || curSentence.file_id,
|
||
content: String(curSentence.content || '')
|
||
}
|
||
}
|
||
})
|
||
} catch (e) {
|
||
this.setData({ hasScoreInfo: false })
|
||
}
|
||
},
|
||
|
||
playStandardVoice() {
|
||
const sid = this.data.currentSentence?.id
|
||
if (!sid) { wx.showToast({ title: '缺少 imageTextId', icon: 'none' }); return }
|
||
const audioUrl = (this.data.standardAudioMap || {})[sid]
|
||
if (!this.audioCtx) {
|
||
this.audioCtx = wx.createInnerAudioContext()
|
||
try { (this.audioCtx as any).obeyMuteSwitch = false } catch (e) {}
|
||
try { (this.audioCtx as any).autoplay = false } catch (e) {}
|
||
this.audioCtx.onEnded(() => { this.setData({ isPlaying: false }) })
|
||
this.audioCtx.onStop(() => { this.setData({ isPlaying: false }) })
|
||
this.audioCtx.onError(() => { this.setData({ isPlaying: false }) })
|
||
}
|
||
if (!audioUrl) {
|
||
wx.showLoading({ title: '正在获取...' })
|
||
this.getStandardVoice(sid)
|
||
.then(() => {
|
||
wx.hideLoading()
|
||
this.playStandardVoice()
|
||
})
|
||
.catch(() => {
|
||
wx.hideLoading()
|
||
})
|
||
return
|
||
}
|
||
const cachedLocal = (this.data.standardAudioLocalMap || {})[sid]
|
||
const playWithPath = (filePath: string) => {
|
||
if (this.data.isPlaying && this.audioCtx?.src === filePath) {
|
||
try { this.audioCtx.pause() } catch (e) {}
|
||
try { this.audioCtx.seek(0) } catch (e) {}
|
||
this.setData({ isPlaying: false })
|
||
return
|
||
}
|
||
if (!this.audioCtx) {
|
||
this.audioCtx = wx.createInnerAudioContext()
|
||
try { (this.audioCtx as any).obeyMuteSwitch = false } catch (e) {}
|
||
try { (this.audioCtx as any).autoplay = false } catch (e) {}
|
||
this.audioCtx.onEnded(() => { this.setData({ isPlaying: false }) })
|
||
this.audioCtx.onError(() => { this.setData({ isPlaying: false }) })
|
||
}
|
||
if (this.audioCtx.src !== filePath) {
|
||
try { this.audioCtx.pause() } catch (e) {}
|
||
try { this.audioCtx.seek(0) } catch (e) {}
|
||
this.audioCtx.src = filePath
|
||
} else {
|
||
try { this.audioCtx.seek(0) } catch (e) {}
|
||
}
|
||
try {
|
||
this.audioCtx.play()
|
||
this.setData({ isPlaying: true })
|
||
} catch (error) {
|
||
wx.showToast({ title: '音频播放失败', icon: 'none' })
|
||
}
|
||
}
|
||
if (cachedLocal) {
|
||
playWithPath(cachedLocal)
|
||
} else {
|
||
apiManager.downloadFile(audioUrl).then((filePath) => {
|
||
const map = this.data.standardAudioLocalMap || {}
|
||
this.setData({ standardAudioLocalMap: { ...map, [sid]: filePath } })
|
||
playWithPath(filePath)
|
||
}).catch((error) => {
|
||
logger.error('下载音频失败:', error)
|
||
wx.showToast({ title: '音频下载失败', icon: 'none' })
|
||
})
|
||
}
|
||
},
|
||
playAssessmentVoice() {
|
||
if (this.data.isRecording) return
|
||
const fileId = this.data.currentSentence?.file_id
|
||
if (!fileId) {
|
||
wx.showToast({ title: '暂无评分音频', icon: 'none' })
|
||
return
|
||
}
|
||
const cachedLocal = (this.data.assessmentAudioLocalMap || {})[fileId]
|
||
const playWithPath = (filePath: string) => {
|
||
if (this.data.isPlaying && this.audioCtx?.src === filePath) {
|
||
try { this.audioCtx.pause() } catch (e) {}
|
||
try { this.audioCtx.seek(0) } catch (e) {}
|
||
this.setData({ isPlaying: false })
|
||
return
|
||
}
|
||
if (!this.audioCtx) {
|
||
this.audioCtx = wx.createInnerAudioContext()
|
||
try { (this.audioCtx as any).obeyMuteSwitch = false } catch (e) {}
|
||
try { (this.audioCtx as any).autoplay = false } catch (e) {}
|
||
this.audioCtx.onEnded(() => { this.setData({ isPlaying: false }) })
|
||
this.audioCtx.onStop(() => { this.setData({ isPlaying: false }) })
|
||
this.audioCtx.onError(() => { this.setData({ isPlaying: false }) })
|
||
}
|
||
if (this.audioCtx.src !== filePath) {
|
||
try { this.audioCtx.pause() } catch (e) {}
|
||
try { this.audioCtx.seek(0) } catch (e) {}
|
||
this.audioCtx.src = filePath
|
||
} else {
|
||
try { this.audioCtx.seek(0) } catch (e) {}
|
||
}
|
||
try {
|
||
this.audioCtx.play()
|
||
this.setData({ isPlaying: true })
|
||
} catch (error) {
|
||
wx.showToast({ title: '音频播放失败', icon: 'none' })
|
||
}
|
||
}
|
||
if (cachedLocal) {
|
||
playWithPath(cachedLocal)
|
||
} else {
|
||
apiManager.downloadFile(fileId).then((filePath) => {
|
||
const map = this.data.assessmentAudioLocalMap || {}
|
||
this.setData({ assessmentAudioLocalMap: { ...map, [fileId]: filePath } })
|
||
playWithPath(filePath)
|
||
}).catch((error) => {
|
||
logger.error('下载音频失败:', error)
|
||
wx.showToast({ title: '音频下载失败', icon: 'none' })
|
||
})
|
||
}
|
||
},
|
||
|
||
handleRecordStart() {
|
||
if (this.data.isRecording) return
|
||
try {
|
||
wx.getSetting({
|
||
success: (res) => {
|
||
const granted = !!(res.authSetting && res.authSetting['scope.record'])
|
||
if (granted) {
|
||
this.setData({ recordPermissionGranted: true })
|
||
this.startRecording()
|
||
try { this.onMicHighlight() } catch (e) {}
|
||
return
|
||
}
|
||
try { this.ensureRecordPermission() } catch (_) {}
|
||
}
|
||
})
|
||
} catch (e) {
|
||
try { this.ensureRecordPermission() } catch (_) {}
|
||
}
|
||
},
|
||
|
||
handleRecordEnd() {
|
||
this.stopRecording()
|
||
},
|
||
|
||
onScoreTap() {
|
||
if (!this.data.hasScoreInfo) return
|
||
this.setData({
|
||
isScoreModalOpen: true,
|
||
scoreModalVisible: false,
|
||
overlayVisible: false,
|
||
highlightShow: false,
|
||
highlightZoom: false,
|
||
analizing: false
|
||
})
|
||
setTimeout(() => {
|
||
this.setData({ scoreModalVisible: true })
|
||
setTimeout(() => {
|
||
try { this.updateCircleProgress() } catch (e) {}
|
||
}, 100)
|
||
}, 0)
|
||
},
|
||
|
||
onCloseScoreModal() {
|
||
if (this.circleAnimTimer) {
|
||
clearInterval(this.circleAnimTimer)
|
||
this.circleAnimTimer = undefined
|
||
}
|
||
this.setData({ scoreModalVisible: false })
|
||
this.setData({
|
||
circleProgressStyle: 'background: conic-gradient(transparent 0)',
|
||
accuracyCircleStyle: 'background: conic-gradient(transparent 0)',
|
||
completenessCircleStyle: 'background: conic-gradient(transparent 0)',
|
||
fluencyCircleStyle: 'background: conic-gradient(transparent 0)',
|
||
displayTotalScore: 0,
|
||
displayAccuracyScore: 0,
|
||
displayCompletenessScore: 0,
|
||
displayFluencyScore: 0,
|
||
isScoreModalOpen: false
|
||
})
|
||
},
|
||
|
||
async onWordTap(e: any) {
|
||
const dsWord = (e && e.currentTarget && e.currentTarget.dataset && e.currentTarget.dataset.word) || (e && e.target && e.target.dataset && e.target.dataset.word) || ''
|
||
const dtWord = (e && e.detail && e.detail.word) || ''
|
||
const rawWord = dtWord || dsWord || ''
|
||
if (!rawWord) return
|
||
const cleanedWord = String(rawWord).replace(/[.,?!*;:'"()]/g, '').trim()
|
||
if (!cleanedWord) return
|
||
const isFromWordSource = !!dtWord
|
||
const { isReturningFromPrevious } = this.data
|
||
let showBackIcon = false
|
||
let previousWord = ''
|
||
if (isFromWordSource && !isReturningFromPrevious) {
|
||
previousWord = String((e && e.detail && e.detail.previousWord) || '')
|
||
showBackIcon = !!previousWord
|
||
} else if (isReturningFromPrevious) {
|
||
showBackIcon = false
|
||
previousWord = ''
|
||
}
|
||
this.setData({ prototypeWord: '', isReturningFromPrevious: false, forceHidePrototype: (isFromWordSource && !isReturningFromPrevious) })
|
||
this.setData({ showBackIcon, previousWord })
|
||
this.setData({ showDictPopup: true })
|
||
const comp = this.selectComponent('#wordDict') as any
|
||
if (comp && typeof comp.queryWord === 'function') {
|
||
try {
|
||
wx.nextTick(() => {
|
||
comp.queryWord(cleanedWord)
|
||
})
|
||
} catch (e) {
|
||
setTimeout(() => comp.queryWord(cleanedWord), 0)
|
||
}
|
||
}
|
||
},
|
||
handleBackToPreviousWord() {
|
||
const w = String(this.data.previousWord || '')
|
||
if (!w) return
|
||
this.setData({ isReturningFromPrevious: true, forceHidePrototype: false })
|
||
const event = { currentTarget: { dataset: { word: w } } }
|
||
this.onWordTap(event as any)
|
||
},
|
||
updateCircleProgress() {
|
||
const { totalScore, accuracyScore, completenessScore, fluencyScore, hasScoreInfo } = this.data
|
||
const normalize = (v: number) => {
|
||
if (v < 0) return 0
|
||
if (v <= 1) return Math.round(v * 100)
|
||
if (v > 100) return 100
|
||
return Math.round(v)
|
||
}
|
||
const totalPct = normalize(totalScore)
|
||
const accPct = normalize(accuracyScore)
|
||
const compPct = normalize(completenessScore)
|
||
const fluPct = normalize(fluencyScore)
|
||
const pickColor = (pct: number) => {
|
||
if (pct >= 70) return 'rgb(39, 174, 96)'
|
||
if (pct >= 40) return 'rgb(241, 196, 15)'
|
||
return 'rgb(231, 76, 60)'
|
||
}
|
||
const totalColor = pickColor(totalPct)
|
||
const accColor = pickColor(accPct)
|
||
const compColor = pickColor(compPct)
|
||
const fluColor = pickColor(fluPct)
|
||
if (this.circleAnimTimer) {
|
||
clearInterval(this.circleAnimTimer)
|
||
this.circleAnimTimer = undefined
|
||
}
|
||
const duration = 800
|
||
const start = Date.now()
|
||
const compose = (pct: number, color: string) => {
|
||
if (!hasScoreInfo || pct <= 0) {
|
||
return 'background: conic-gradient(transparent 0)'
|
||
}
|
||
return `background: conic-gradient(${color} ${pct}%, transparent 0)`
|
||
}
|
||
const tick = () => {
|
||
const elapsed = Date.now() - start
|
||
const progressPct = Math.min(100, Math.round((elapsed / duration) * 100))
|
||
const curTotal = Math.min(progressPct, totalPct)
|
||
const curAcc = Math.min(progressPct, accPct)
|
||
const curComp = Math.min(progressPct, compPct)
|
||
const curFlu = Math.min(progressPct, fluPct)
|
||
const ratio = (target: number, current: number) => (target === 0 ? 0 : (current / target))
|
||
const round2 = (v: number) => Math.round(v * 100) / 100
|
||
this.setData({
|
||
circleProgressStyle: compose(curTotal, totalColor),
|
||
accuracyCircleStyle: compose(curAcc, accColor),
|
||
completenessCircleStyle: compose(curComp, compColor),
|
||
fluencyCircleStyle: compose(curFlu, fluColor),
|
||
displayTotalScore: round2(totalScore * ratio(totalPct, curTotal)),
|
||
displayAccuracyScore: round2(accuracyScore * ratio(accPct, curAcc)),
|
||
displayCompletenessScore: round2(completenessScore * ratio(compPct, curComp)),
|
||
displayFluencyScore: round2(fluencyScore * ratio(fluPct, curFlu)),
|
||
})
|
||
if (elapsed >= duration) {
|
||
if (this.circleAnimTimer) {
|
||
clearInterval(this.circleAnimTimer)
|
||
this.circleAnimTimer = undefined
|
||
}
|
||
}
|
||
}
|
||
this.setData({
|
||
circleProgressStyle: compose(0, totalColor),
|
||
accuracyCircleStyle: compose(0, accColor),
|
||
completenessCircleStyle: compose(0, compColor),
|
||
fluencyCircleStyle: compose(0, fluColor),
|
||
displayTotalScore: 0,
|
||
displayAccuracyScore: 0,
|
||
displayCompletenessScore: 0,
|
||
displayFluencyScore: 0,
|
||
})
|
||
this.circleAnimTimer = setInterval(tick, 16) as any
|
||
},
|
||
|
||
handleDictClose() {
|
||
this.setData({ showDictPopup: false, showDictExtended: false })
|
||
try { this.wordAudioContext && this.wordAudioContext.stop() } catch (e) {}
|
||
try {
|
||
if (this.wordAudioContext) {
|
||
this.wordAudioContext.offPlay()
|
||
this.wordAudioContext.offEnded()
|
||
this.wordAudioContext.offError()
|
||
this.wordAudioContext.destroy()
|
||
this.wordAudioContext = undefined
|
||
}
|
||
} catch (e) {}
|
||
if (this.wordAudioIconTimer) {
|
||
clearInterval(this.wordAudioIconTimer)
|
||
this.wordAudioIconTimer = undefined
|
||
}
|
||
this.setData({ wordAudioPlaying: false, wordAudioIconName: 'sound', activeWordAudioType: '' })
|
||
},
|
||
|
||
handleDictMore() {
|
||
this.setData({ showDictExtended: !this.data.showDictExtended })
|
||
},
|
||
|
||
processCollinsData(collinsData: any) {
|
||
if (!collinsData || !collinsData.collins_entries) return collinsData
|
||
const processedData = JSON.parse(JSON.stringify(collinsData))
|
||
processedData.collins_entries.forEach((entry: any) => {
|
||
if (entry.entries && entry.entries.entry) {
|
||
entry.entries.entry.forEach((entryItem: any) => {
|
||
if (entryItem.tran_entry) {
|
||
entryItem.tran_entry.forEach((tranEntry: any) => {
|
||
if (tranEntry.tran) {
|
||
const parts = tranEntry.tran.split(/(<b>.*?<\/b>)/g).filter(Boolean)
|
||
const processedParts: Array<{ text: string; bold: boolean }> = []
|
||
parts.forEach((part: string) => {
|
||
if (part.startsWith('<b>') && part.endsWith('</b>')) {
|
||
const text = part.substring(3, part.length - 4)
|
||
processedParts.push({ text, bold: true })
|
||
} else {
|
||
processedParts.push({ text: part, bold: false })
|
||
}
|
||
})
|
||
tranEntry.tranParts = processedParts
|
||
tranEntry.originalTran = tranEntry.tran
|
||
}
|
||
})
|
||
}
|
||
})
|
||
}
|
||
})
|
||
return processedData
|
||
},
|
||
startRecording() {
|
||
const options: WechatMiniprogram.RecorderManagerStartOption = {
|
||
duration: 30000,
|
||
sampleRate: 16000,
|
||
numberOfChannels: 1,
|
||
encodeBitRate: 48000,
|
||
format: 'mp3' as 'mp3'
|
||
}
|
||
this.recordTimer && clearInterval(this.recordTimer)
|
||
this.setData({ recordDuration: 0, remainingTime: 30 })
|
||
recorderManager.start(options)
|
||
this.setData({ isRecording: true, recordStartTime: Date.now() })
|
||
this.recordTimer = setInterval(() => {
|
||
const duration = Date.now() - (this.data.recordStartTime || 0)
|
||
const remaining = Math.max(0, 30 - Math.floor(duration / 1000))
|
||
this.setData({ recordDuration: duration, remainingTime: remaining })
|
||
if (remaining === 0) {
|
||
this.stopRecording()
|
||
}
|
||
}, 100) as any
|
||
},
|
||
stopRecording() {
|
||
if (!this.data.isRecording) return
|
||
const duration = Date.now() - (this.data.recordStartTime || 0)
|
||
if (this.recordTimer) { clearInterval(this.recordTimer) }
|
||
if (duration < 3000) {
|
||
try { recorderManager.stop() } catch (e) {}
|
||
this.setData({
|
||
isRecording: false,
|
||
remainingTime: 30,
|
||
overlayVisible: false,
|
||
highlightShow: false,
|
||
highlightZoom: false,
|
||
highlightWords: [],
|
||
cachedHighlightWords: [],
|
||
cachedSentenceIndex: -1
|
||
})
|
||
return
|
||
}
|
||
try { recorderManager.stop() } catch (e) {}
|
||
},
|
||
onMicHighlight() {
|
||
this.computeHighlightLayout()
|
||
const startAnim = () => {
|
||
const words = this.data.cachedHighlightWords || []
|
||
if (!words || words.length === 0) return
|
||
this.setData({
|
||
overlayVisible: true,
|
||
highlightWords: words.map((w: any) => ({ ...w, transform: 'translate(0px, 0px) scale(1)' })),
|
||
highlightShow: false,
|
||
highlightZoom: false
|
||
})
|
||
setTimeout(() => {
|
||
this.setData({ highlightShow: true })
|
||
setTimeout(() => {
|
||
this.setData({ highlightZoom: true })
|
||
try {
|
||
wx.nextTick(() => {
|
||
const updated = (this.data.highlightWords || []).map((w: any) => ({ ...w, transform: w.targetTransform }))
|
||
this.setData({ highlightWords: updated })
|
||
})
|
||
} catch (e) {
|
||
setTimeout(() => {
|
||
const updated = (this.data.highlightWords || []).map((w: any) => ({ ...w, transform: w.targetTransform }))
|
||
this.setData({ highlightWords: updated })
|
||
}, 0)
|
||
}
|
||
}, 500)
|
||
}, 50)
|
||
}
|
||
try {
|
||
wx.nextTick(() => {
|
||
setTimeout(() => startAnim(), 80)
|
||
})
|
||
} catch (e) {
|
||
setTimeout(() => {
|
||
setTimeout(() => startAnim(), 80)
|
||
}, 0)
|
||
}
|
||
},
|
||
|
||
ensureRecordPermission() {
|
||
try {
|
||
wx.getSetting({
|
||
success: (res) => {
|
||
const granted = !!(res.authSetting && res.authSetting['scope.record'])
|
||
if (granted) {
|
||
this.setData({ recordPermissionGranted: true })
|
||
return
|
||
}
|
||
wx.authorize({
|
||
scope: 'scope.record',
|
||
success: () => {
|
||
this.setData({ recordPermissionGranted: true })
|
||
},
|
||
fail: () => {
|
||
wx.showModal({
|
||
title: '需要麦克风权限',
|
||
content: '录音功能需要麦克风权限,请在设置中开启',
|
||
confirmText: '去设置',
|
||
cancelText: '取消',
|
||
success: (r) => {
|
||
if (r.confirm) {
|
||
wx.openSetting({
|
||
success: (s) => {
|
||
const ok = !!(s.authSetting && s.authSetting['scope.record'])
|
||
this.setData({ recordPermissionGranted: ok })
|
||
}
|
||
})
|
||
}
|
||
}
|
||
})
|
||
}
|
||
})
|
||
}
|
||
})
|
||
} catch (e) {}
|
||
},
|
||
|
||
computeHighlightLayout() {
|
||
const list = this.data.scene?.list || []
|
||
const cur = list[this.data.currentIndex]
|
||
const words = (cur && cur.sentenceEn ? cur.sentenceEn.split(' ') : [])
|
||
if (!words || words.length === 0) return
|
||
const sys = wx.getSystemInfoSync()
|
||
const windowWidth = sys.windowWidth || 375
|
||
const windowHeight = sys.windowHeight || 667
|
||
const bottomOffset = 120
|
||
const scale = 1.6
|
||
const centerX = windowWidth / 2
|
||
const centerY = (windowHeight - bottomOffset) / 2
|
||
const query = wx.createSelectorQuery().in(this as any)
|
||
query.selectAll('.sentence-en .sentence-word').boundingClientRect()
|
||
query.exec((res: any) => {
|
||
const rects = (res && res[0]) || []
|
||
if (!rects || rects.length === 0) return
|
||
const sidePadding = 24
|
||
const wordSpacing = 12
|
||
const rowSpacing = 16
|
||
const availWidth = Math.max(windowWidth - sidePadding * 2, 100)
|
||
const scaledHeights = rects.map((r: any) => r.height * scale)
|
||
const rowHeight = Math.max(...scaledHeights)
|
||
const scaledWidths = rects.map((r: any) => Math.max(r.width * scale, 10))
|
||
const rows: { idxs: number[], width: number }[] = []
|
||
let current: { idxs: number[], width: number } = { idxs: [], width: 0 }
|
||
scaledWidths.forEach((w: number, i: number) => {
|
||
const extra = current.idxs.length > 0 ? wordSpacing : 0
|
||
if (current.width + extra + w <= availWidth) {
|
||
current.idxs.push(i)
|
||
current.width += extra + w
|
||
} else {
|
||
if (current.idxs.length > 0) rows.push(current)
|
||
current = { idxs: [i], width: w }
|
||
}
|
||
})
|
||
if (current.idxs.length > 0) rows.push(current)
|
||
const totalHeight = rows.length * rowHeight + Math.max(rows.length - 1, 0) * rowSpacing
|
||
const firstRowCenterY = centerY - totalHeight / 2 + rowHeight / 2
|
||
const targetWords = rects.map((r: any, idx: number) => {
|
||
const rcx = r.left + r.width / 2
|
||
const rcy = r.top + r.height / 2
|
||
let rowIndex = 0
|
||
let y = firstRowCenterY
|
||
for (let ri = 0; ri < rows.length; ri++) {
|
||
if (rows[ri].idxs.includes(idx)) { rowIndex = ri; break }
|
||
y += rowHeight + rowSpacing
|
||
}
|
||
const row = rows[rowIndex]
|
||
const rowStartX = centerX - row.width / 2
|
||
let cumX = 0
|
||
for (const j of row.idxs) {
|
||
if (j === idx) break
|
||
cumX += scaledWidths[j] + wordSpacing
|
||
}
|
||
const targetCx = rowStartX + cumX + scaledWidths[idx] / 2
|
||
const targetCy = firstRowCenterY + rowIndex * (rowHeight + rowSpacing)
|
||
const dx = targetCx - rcx
|
||
const dy = targetCy - rcy
|
||
const transform = `translate(${dx}px, ${dy}px) scale(${scale})`
|
||
return {
|
||
text: words[idx] || '',
|
||
left: r.left,
|
||
top: r.top,
|
||
width: r.width,
|
||
height: r.height,
|
||
targetTransform: transform,
|
||
transform: 'translate(0px, 0px) scale(1)'
|
||
}
|
||
})
|
||
this.setData({ cachedHighlightWords: targetWords, cachedSentenceIndex: this.data.currentIndex })
|
||
})
|
||
},
|
||
|
||
async getStandardVoice(sentenceId: string) {
|
||
try {
|
||
const map = this.data.standardAudioMap || {}
|
||
if (map[sentenceId]) {
|
||
if (this.audioCtx && !this.audioCtx.src) {
|
||
this.audioCtx.src = map[sentenceId]
|
||
}
|
||
return
|
||
}
|
||
const { audio_id } = await apiManager.getStandardVoice(sentenceId)
|
||
if (audio_id) {
|
||
const fileUrl = String(audio_id)
|
||
this.setData({ standardAudioMap: { ...map, [sentenceId]: fileUrl } })
|
||
if (this.audioCtx && !this.audioCtx.src) {
|
||
this.audioCtx.src = fileUrl
|
||
}
|
||
}
|
||
} catch (err) {
|
||
logger.error('获取标准语音失败:', err)
|
||
wx.showToast({ title: '获取语音失败', icon: 'none' })
|
||
}
|
||
},
|
||
onUnload() {
|
||
if (this.pollTimer) {
|
||
clearInterval(this.pollTimer)
|
||
this.pollTimer = undefined
|
||
}
|
||
try { (recorderManager as any).offStop && (recorderManager as any).offStop() } catch (e) {}
|
||
try { (recorderManager as any).offError && (recorderManager as any).offError() } catch (e) {}
|
||
recorderHandlersBound = false
|
||
if (this.audioCtx) {
|
||
try {
|
||
this.audioCtx.destroy()
|
||
} catch {}
|
||
this.audioCtx = undefined
|
||
}
|
||
}
|
||
})
|