Files
miniprogram-1/miniprogram/pages/scene_sentence/scene_sentence.ts
2025-12-30 20:37:59 +08:00

1208 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}
})