Files
miniprogram-1/miniprogram/pages/assessment/assessment.ts
2025-12-12 20:23:28 +08:00

1978 lines
68 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.
// assessment.ts
import apiManager from '../../utils/api'
import logger from '../../utils/logger'
const recorderManager = wx.getRecorderManager()
let recorderHandlersBound = false
interface IPageData {
imagePath: string
imageSmall: boolean
imageMode: 'widthFix' | 'aspectFit'
currentSentence: any
sentences: any[]
currentIndex: number
selectedSentenceIndex: number
totalScore: number
accuracyScore: number
completenessScore: number
fluencyScore: number
circleProgressStyle: string
accuracyCircleStyle: string
completenessCircleStyle: string
fluencyCircleStyle: string
isRecording: boolean
recordStartTime: number
recordDuration: number
remainingTime: number // 剩余录音时间
hasScoreInfo: boolean // 是否有评分信息
words: string[] // 句子内容的单词数组
ipas: string[] // 音标的单词数组
isScoreExpanded: boolean // 评分区域是否展开
wordScores: Array<{ // 单词评分数组
word: string
pronAccuracy: number
pronFluency: number
matchTag?: number // 0匹配单词、1新增单词、2缺少单词、3错读的词、4未录入单词
phoneInfos?: Array<{ // 音标信息数组
phone: string // 国际音标
pronAccuracy: number // 音标评分
matchTag: number // 音标匹配度
}>
}>
matchTagLegend: Array<{ // MatchTag 说明
tag: number
description: string
color: string
}>
standardAudioMap: { [key: string]: string } // 标准语音文件ID映射
isPlaying: boolean // 是否正在播放音频
audioDuration: number // 音频总时长
currentTime: number // 当前播放时间
sliderValue: number // 进度条值0-100
playIconName: string // 播放图标名称
showDictPopup: boolean // 控制弹窗是否显示
showDictExtended: boolean // 控制扩展内容是否显示
wordAudioPlaying: boolean // 单词音频是否播放中
wordAudioIconName: string // sound/sound-low/sound-mute
activeWordAudioType: string // 'uk' | 'us' 当前播放的音频类型
wordDict: {
ee: any
ec: any
expandEc: any
etym: any
syno: {
word: string
synos: Array<{
syno: {
ws: Array<{ w: string }>
pos: string
tran: string
}
}>
}
simple: any
phrs: any
individual: any
collins: any
relWord: {
rels: Array<{
rel: {
pos: string
words: Array<{
tran: string
word: string
}>
}
}>
}
discriminate: {
data: Array<{
source: string
usages: Array<{
usage: string
headword: string
}>
}>
}
}
prototypeWord: string
// 添加处理后的单词和音标数组
processedSentences: Array<{
words: string[]
ipas: string[]
zh: string
}>
// 添加翻译显示模式状态
transDisplayMode: 'en' | 'en_ipa' | 'en_zh' // 翻译显示模式
isScoreModalOpen: boolean // 评分结果弹窗是否打开
scoreModalVisible: boolean
justSelectedByWord: boolean
isMoreMenuOpen: boolean // 更多菜单是否打开
isMoreMenuClosing: boolean // 更多菜单是否打开
displayTotalScore: number
displayAccuracyScore: number
displayCompletenessScore: number
displayFluencyScore: number
dictDefaultTabValue: string
dictLoading: boolean
// 添加返回功能相关字段
showBackIcon: boolean // 是否显示返回图标
previousWord: string // 上一个单词
isReturningFromPrevious: boolean // 是否正在从上一个单词返回
overlayVisible: boolean,
highlightWords: string[],
highlightShow: boolean,
analizing: boolean,
highlightZoom: boolean,
focusWordIndex: number,
focusTransform: string,
cachedHighlightWords: string[],
cachedSentenceIndex: number,
isWordEmptyResult: boolean,
recordPermissionGranted: boolean
loadingMaskVisible: boolean,
imageSectionHeight: number,
sentenceMinHeight: number,
sentenceMarginTop: number,
standardAudioLocalMap: { [key: string]: string }, // 标准语音文件ID映射
assessmentAudioLocalMap: { [key: string]: string } // 评估语音文件ID映射
}
type IPageMethods = {
updateCircleProgress: () => void
startRecording: () => void
stopRecording: () => void
handleRecordStart: () => void
handleRecordEnd: () => void
updateWordsAndIpas: (content: string, ipa: string) => void
toggleScoreSection: () => void
handleImagePreview: () => void
getStandardVoice: (sentenceId: string) => Promise<void>
playStandardVoice: () => void
playAssessmentVoice: () => void
resetAudioState: () => void
handleWordClick: (e: any) => void
handleSentenceSelect: (e: any) => void
handleDictMore: () => void
handleDictClose: () => void
handleBackToPreviousWord: () => void // 添加返回上一个单词的方法
playWordAudio: (e: any) => void
onTabsChange: (event: any) => void
onTabsClick: (event: any) => void
onScoreTap: () => void
onTransTap: () => void
onCloseScoreModal: () => void
processSentences: (sentences: any[]) => Array<{ words: string[], ipas: string[], zh: string }>
getTransIconName: () => string
onPageScroll: (e: any) => void
processCollinsData: (collinsData: any) => any // Add this line
computeHighlightLayout:() => void
onMicHighlight: () => void
ensureRecordPermission: () => void
onMoreTap: () => void
noop: () => void
}
interface IPageInstance extends IPageMethods {
recordTimer?: number
data: IPageData
audioContext?: WechatMiniprogram.InnerAudioContext
playIconTimer?: number
// 新增:单词音频上下文与图标轮播定时器
wordAudioContext?: WechatMiniprogram.InnerAudioContext
wordAudioIconTimer?: number
// 新增:录音时长(毫秒),用于避免 setData 异步导致 onStop 判断失败
lastRecordDurationMs?: number
circleAnimTimer?: number
sentenceTouchStartY?: number
sentencePullTriggered?: boolean
sentenceBaseLayouts?: { imageSectionHeight: number; sentenceMinHeight: number; sentenceMarginTop: number }
}
Page<IPageData, IPageInstance>({
data: {
imagePath: '', // 图片路径
imageSmall: false, // 图片是否缩小
imageMode: 'aspectFill', // 初始完整展示模式
imageScale: 1,
imageTranslateY: 0,
imageSectionHeight: 0,
sentenceMinHeight: 0,
sentenceMarginTop: 0,
dragSentenceTranslateY: 0,
introStarted: false,
buttonsVisible: false,
currentSentence: '', // 当前显示的例句
sentences: [], // 例句列表
currentIndex: 0, // 当前例句索引
selectedSentenceIndex: 0, // 当前选中的例句索引
totalScore: 0, // 总分
accuracyScore: 0, // 准确性评分
completenessScore: 0, // 完整性评分
fluencyScore: 0, // 流利度评分
circleProgressStyle: '', // 圆形进度条样式
accuracyCircleStyle: '',
completenessCircleStyle: '',
fluencyCircleStyle: '',
isRecording: false, // 是否正在录音
recordStartTime: 0, // 录音开始时间
recordDuration: 0, // 录音持续时间
remainingTime: 30, // 剩余录音时间默认30秒
hasScoreInfo: false, // 是否有评分信息
words: [], // 句子内容的单词数组
ipas: [], // 音标的单词数组
isScoreExpanded: false, // 评分区域是否展开
wordScores: [], // 单词评分数组
prototypeWord: '',
wordDict: {
ee: {},
ec: {},
expandEc: {},
etym: {},
syno: { word: '', synos: [] },
simple: {},
phrs: {},
individual: {},
collins: {},
relWord: { rels: [] },
discriminate: { data: [] }
},
matchTagLegend: [
{ tag: 0, description: '匹配', color: '#ffffff' },
{ tag: 1, description: '新增', color: '#ffebee' },
{ tag: 2, description: '缺少', color: '#e3f2fd' },
{ tag: 3, description: '错读', color: '#fff3e0' },
{ tag: 4, description: '未录入', color: '#f5f5f5' }
],
standardAudioMap: {}, // 标准语音文件ID映射
// 页面级音频本地缓存:避免重复网络获取
standardAudioLocalMap: {},
assessmentAudioLocalMap: {},
isPlaying: false, // 是否正在播放音频
audioDuration: 0, // 音频总时长
currentTime: 0, // 当前播放时间
sliderValue: 0, // 进度条值0-100
playIconName: 'sound-low', // 播放图标名称
showDictPopup: false, // 控制弹窗是否显示
showDictExtended: false, // 控制扩展内容是否显示
// 新增:单词音频播放与图标轮播状态
wordAudioPlaying: false,
wordAudioIconName: 'sound',
activeWordAudioType: '',
processedSentences: [], // 处理后的句子数据
transDisplayMode: 'en', // 默认显示模式为英文 only
isScoreModalOpen: false, // 评分结果弹窗是否打开
scoreModalVisible: false,
justSelectedByWord: false,
isMoreMenuOpen: false, // 更多菜单是否打开
displayTotalScore: 0,
displayAccuracyScore: 0,
displayCompletenessScore: 0,
displayFluencyScore: 0,
dictDefaultTabValue: '2',
dictLoading: false,
// 返回功能相关字段
showBackIcon: false, // 是否显示返回图标
previousWord: '', // 上一个单词
isReturningFromPrevious: false, // 是否正在从上一个单词返回
isWordEmptyResult: false,
overlayVisible: false,
highlightWords: [],
highlightShow: false,
analizing: false,
highlightZoom: false,
focusWordIndex: 0,
focusTransform: '',
cachedHighlightWords: [],
cachedSentenceIndex: -1,
isMoreMenuClosing: false,
recordPermissionGranted: false,
loadingMaskVisible: false,
},
onImageLoaded() {
this.setData({ introStarted: true })
setTimeout(() => { this.setData({ buttonsVisible: true }) }, 600)
},
onBgTouchMove(e: any) {
const y = (e && e.touches && e.touches[0] && typeof e.touches[0].clientY === 'number') ? e.touches[0].clientY : 0
const delta = Math.max(0, 200 - y)
const scale = Math.min(1.1, 1 + delta / 2000)
this.setData({ imageScale: scale })
},
onMicHighlight() {
// const pre = this.data.cachedHighlightWords || []
// const cachedIdx = typeof this.data.cachedSentenceIndex === 'number' ? this.data.cachedSentenceIndex : -1
// const currentIdx = typeof this.data.selectedSentenceIndex === 'number' ? this.data.selectedSentenceIndex : -1
// const needRecompute = (!pre || pre.length === 0 || cachedIdx !== currentIdx)
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)
}
// if (needRecompute) {
try {
wx.nextTick(() => {
setTimeout(() => startAnim(), 80)
})
} catch (e) {
setTimeout(() => {
setTimeout(() => startAnim(), 80)
}, 0)
}
// } else {
// startAnim()
// }
},
// 切换评分区域展开状态
toggleScoreSection() {
this.setData({
isScoreExpanded: !this.data.isScoreExpanded
})
},
// 更新单词和音标数组
updateWordsAndIpas(content: string, ipa: string) {
const words = content ? content.split(' ') : []
const ipas = ipa ? ipa.split(' ') : []
this.setData({ words, ipas })
},
// 更新圆形进度条样式
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
},
// 重置音频播放状态
resetAudioState() {
// 隐藏可能存在的加载提示
wx.hideLoading()
if (this.audioContext) {
// iOS 真机上,直接 stop 可能无法立即中断播放;需先 pause再 seek(0),并关闭自动播放
try { (this.audioContext as any).autoplay = false } catch (e) {}
try { this.audioContext.pause() } catch (e) {}
try { this.audioContext.seek(0) } catch (e) {}
try { this.audioContext.stop() } catch (e) {}
// 解绑所有事件,避免旧回调在切换句子后再次触发导致误播上一个音频
try {
this.audioContext.offPlay()
this.audioContext.offTimeUpdate()
this.audioContext.offEnded()
this.audioContext.offError()
this.audioContext.offCanplay && this.audioContext.offCanplay()
} catch (e) {}
// 清空音源并销毁上下文,确保下次播放重新创建,避免残留状态
try { this.audioContext.src = '' } catch (e) {}
try { this.audioContext.destroy() } catch (e) {}
this.audioContext = undefined
}
// 重置界面状态
this.setData({
isPlaying: false,
currentTime: 0,
sliderValue: 0,
audioDuration: 0,
playIconName: 'sound-low'
})
},
// 开始录音
startRecording() {
const options: WechatMiniprogram.RecorderManagerStartOption = {
duration: 30000, // 最长录音时长,单位 ms
sampleRate: 16000, // 采样率
numberOfChannels: 1, // 录音通道数
encodeBitRate: 48000, // 编码码率
format: 'mp3' as 'mp3', // 音频格式
}
// 重置上次录音时长(避免 onStop 读取旧值)
this.lastRecordDurationMs = 0
recorderManager.start(options)
this.setData({
isRecording: true,
recordStartTime: Date.now(),
recordDuration: 0
})
// 设置定时器更新录音时长和倒计时
this.recordTimer = setInterval(() => {
const duration = Date.now() - this.data.recordStartTime
const remaining = Math.max(0, 30 - Math.floor(duration / 1000))
this.setData({
recordDuration: duration,
remainingTime: remaining
})
// 时间到自动停止录音
if (remaining === 0) {
this.stopRecording()
}
}, 100)
},
// 停止录音
stopRecording() {
// 若未处于录音中,直接返回,避免调用 stop 后不触发 onStop
if (!this.data.isRecording) {
return
}
const duration = Date.now() - this.data.recordStartTime
if (this.recordTimer) {
clearInterval(this.recordTimer)
}
// 写入最终录音时长,确保 onStop 回调中可准确判断
this.lastRecordDurationMs = duration
// 写入 data 仅用于 UI 展示
this.setData({ recordDuration: duration })
if (duration < 3000) { // 小于3秒
recorderManager.stop()
this.setData({
isRecording: false,
remainingTime: 30,
overlayVisible: false,
highlightShow: false,
highlightZoom: false,
focusTransform: '',
highlightWords: [],
cachedHighlightWords: [],
cachedSentenceIndex: -1
})
return
}
recorderManager.stop()
// this.setData({ isRecording: false })
},
// 长按开始录音
handleRecordStart() {
if (this.data.isRecording) return
this.startRecording()
try { this.onMicHighlight() } catch (e) {}
},
// 松开结束录音
handleRecordEnd() {
logger.info('---lisa-handleRecordEnd')
this.stopRecording()
// 淡出高亮层
// this.setData({ overlayVisible: false })
// const timer = setTimeout(() => {
// this.setData({ highlightShow: false, highlightZoom: false, focusTransform: '', highlightWords: [] })
// clearTimeout(timer)
// }, 320)
},
// 点击图片预览
handleImagePreview() {
wx.previewImage({
urls: [this.data.imagePath],
current: this.data.imagePath
})
},
// 获取标准语音
async getStandardVoice(sentenceId: string) {
try {
// 如果已经获取过该例句的标准语音,直接返回
if (this.data.standardAudioMap[sentenceId]) {
// 预加载音频
if (this.audioContext && !this.audioContext.src) {
this.audioContext.src = this.data.standardAudioMap[sentenceId]
}
return
}
const {audio_id} = await apiManager.getStandardVoice(sentenceId)
if (audio_id) {
// const fileUrl = `${FILE_BASE_URL}/${audio_id}`
const fileUrl = audio_id
this.setData({
standardAudioMap: {
...this.data.standardAudioMap,
[sentenceId]: fileUrl.toString() // Convert to string to ensure type compatibility
}
})
// 预加载音频
if (this.audioContext) {
this.audioContext.src = fileUrl.toString()
}
}
} catch (err) {
logger.error('获取标准语音失败:', err)
wx.showToast({
title: '获取语音失败',
icon: 'none'
})
}
},
// 播放标准语音
playStandardVoice() {
if (this.data.isRecording) return
const { currentSentence, standardAudioMap, isPlaying } = this.data
const audioUrl = standardAudioMap[currentSentence.id]
if (!audioUrl) {
wx.showLoading({ title: '正在获取...' })
this.getStandardVoice(currentSentence.id)
.then(() => {
wx.hideLoading()
// 重新尝试播放
this.playStandardVoice()
})
.catch(() => {
wx.hideLoading()
})
return
}
// 优先使用页面本地缓存
const cachedLocal = this.data.standardAudioLocalMap[currentSentence.id]
const playWithPath = (filePath: string) => {
if (isPlaying && this.audioContext?.src === filePath) {
try { this.audioContext.pause() } catch (e) {}
try { this.audioContext.seek(0) } catch (e) {}
if (this.playIconTimer) {
clearInterval(this.playIconTimer)
this.playIconTimer = undefined
}
this.setData({ isPlaying: false, playIconName: 'sound-low', currentTime: 0, sliderValue: 0 })
return
}
// 如果还没有音频上下文,则创建并绑定事件
if (!this.audioContext) {
this.audioContext = wx.createInnerAudioContext()
try { (this.audioContext as any).obeyMuteSwitch = false } catch (e) {}
try { (this.audioContext as any).autoplay = false } catch (e) {}
try { (this.audioContext as any).obeyMuteSwitch = false } catch (e) {}
// this.audioContext.onCanplay(() => {
// setTimeout(() => {
// const duration = this.audioContext!.duration
// if (duration && duration > 0) {
// this.setData({
// audioDuration: duration,
// currentTime: this.audioContext!.currentTime
// })
// }
// }, 100)
// })
this.audioContext.onPlay(() => {
// const duration = this.audioContext!.duration
// 停止可能存在的动画计时器
if (this.playIconTimer) {
clearInterval(this.playIconTimer)
this.playIconTimer = undefined
}
// 切换为播放中的图标并启动动画
this.setData({
isPlaying: true,
// currentTime: this.audioContext!.currentTime,
// audioDuration: duration && duration > 0 ? duration : 0,
playIconName: 'sound-low'
})
// 每500ms在 sound-low、sound、sound-high 三个图标之间循环切换
const iconOrder = ['sound-low', 'sound', 'sound-high']
let i = 0
this.playIconTimer = setInterval(() => {
i = (i + 1) % iconOrder.length
this.setData({ playIconName: iconOrder[i] })
}, 500) as any
})
this.audioContext.onTimeUpdate(() => {
// const currentTime = this.audioContext!.currentTime
// const duration = this.audioContext!.duration
// if (duration && duration > 0) {
// this.setData({
// currentTime,
// audioDuration: duration,
// sliderValue: (currentTime / duration) * 100
// })
// } else {
// this.setData({
// currentTime: 0,
// audioDuration: 0,
// sliderValue: 0
// })
// }
})
this.audioContext.onEnded(() => {
// 结束时清除动画并复位图标
if (this.playIconTimer) {
clearInterval(this.playIconTimer)
this.playIconTimer = undefined
}
this.setData({
isPlaying: false,
// currentTime: 0,
// sliderValue: 0,
playIconName: 'sound-low'
})
})
this.audioContext.onError((err: any) => {
// 出错时清除动画并复位图标
if (this.playIconTimer) {
clearInterval(this.playIconTimer)
this.playIconTimer = undefined
}
this.setData({
isPlaying: false,
// currentTime: 0,
// sliderValue: 0,
playIconName: 'sound-low'
})
console.error('标准语音播放错误:', err)
wx.showToast({ title: '播放失败', icon: 'none' })
})
this.audioContext.onTimeUpdate(() => {
// const currentTime = this.audioContext!.currentTime
// const duration = this.audioContext!.duration
// const sliderValue = (currentTime / duration) * 100
// this.setData({
// currentTime,
// audioDuration: duration,
// sliderValue
// })
})
}
// 切换音源并播放
if (this.audioContext.src !== filePath) {
try { this.audioContext.pause() } catch (e) {}
try { this.audioContext.seek(0) } catch (e) {}
this.setData({ currentTime: 0, sliderValue: 0, audioDuration: 0 })
this.audioContext.src = filePath
} else {
try { this.audioContext.seek(0) } catch (e) {}
this.setData({ currentTime: 0, sliderValue: 0 })
}
try {
this.audioContext.play()
this.setData({ isPlaying: true })
} catch (error) {
wx.showToast({ title: '音频播放失败', icon: 'none' })
}
}
if (cachedLocal) {
playWithPath(cachedLocal)
} else {
apiManager.downloadFile(audioUrl).then((filePath) => {
this.setData({
standardAudioLocalMap: {
...this.data.standardAudioLocalMap,
[currentSentence.id]: filePath
}
})
playWithPath(filePath)
}).catch((error) => {
console.error('下载音频文件失败:', error)
wx.showToast({ title: '音频下载失败', icon: 'none' })
})
}
},
// 新增:播放评分结果音频(使用当前句子的 file_id
playAssessmentVoice() {
if (this.data.isRecording) return
const { currentSentence, isPlaying } = this.data
const fileId = currentSentence?.file_id
if (!fileId) {
wx.showToast({ title: '暂无评分音频', icon: 'none' })
return
}
const cachedLocal = this.data.assessmentAudioLocalMap[fileId]
const playWithPath = (filePath: string) => {
if (isPlaying && this.audioContext?.src === filePath) {
try { this.audioContext.pause() } catch (e) {}
try { this.audioContext.seek(0) } catch (e) {}
if (this.playIconTimer) {
clearInterval(this.playIconTimer)
this.playIconTimer = undefined
}
this.setData({ isPlaying: false, playIconName: 'sound-low', currentTime: 0, sliderValue: 0 })
return
}
// 如果还没有音频上下文,则创建并绑定事件
if (!this.audioContext) {
this.audioContext = wx.createInnerAudioContext()
try { (this.audioContext as any).obeyMuteSwitch = false } catch (e) {}
try { (this.audioContext as any).autoplay = false } catch (e) {}
try { (this.audioContext as any).obeyMuteSwitch = false } catch (e) {}
// this.audioContext.onCanplay(() => {
// setTimeout(() => {
// const duration = this.audioContext!.duration
// if (duration && duration > 0) {
// this.setData({
// audioDuration: duration,
// currentTime: this.audioContext!.currentTime
// })
// }
// }, 100)
// })
this.audioContext.onPlay(() => {
const duration = this.audioContext!.duration
// 停止可能存在的动画计时器
if (this.playIconTimer) {
clearInterval(this.playIconTimer)
this.playIconTimer = undefined
}
// 切换为播放中的图标并启动动画
this.setData({
isPlaying: true,
currentTime: this.audioContext!.currentTime,
audioDuration: duration && duration > 0 ? duration : 0,
playIconName: 'sound-low'
})
// 每500ms在 play、play-2、play-1 三个图标之间循环切换顺序play -> play-2 -> play-1
const iconNames = ['sound-low', 'sound', 'sound-high']
let i = 0
this.playIconTimer = setInterval(() => {
i = (i + 1) % iconNames.length
this.setData({ playIconName: iconNames[i] })
}, 500) as any
})
this.audioContext.onTimeUpdate(() => {
const currentTime = this.audioContext!.currentTime
const duration = this.audioContext!.duration
if (duration && duration > 0) {
this.setData({
currentTime,
audioDuration: duration,
sliderValue: (currentTime / duration) * 100
})
} else {
this.setData({
currentTime: 0,
audioDuration: 0,
sliderValue: 0
})
}
})
this.audioContext.onEnded(() => {
// 结束时清除动画并复位图标
if (this.playIconTimer) {
clearInterval(this.playIconTimer)
this.playIconTimer = undefined
}
this.setData({
isPlaying: false,
currentTime: 0,
sliderValue: 0,
playIconName: 'sound-low'
})
})
this.audioContext.onError((err: any) => {
// 出错时清除动画并复位图标
if (this.playIconTimer) {
clearInterval(this.playIconTimer)
this.playIconTimer = undefined
}
this.setData({
isPlaying: false,
currentTime: 0,
sliderValue: 0,
playIconName: 'sound-low'
})
console.error('评分语音播放错误:', err)
wx.showToast({ title: '播放失败', icon: 'none' })
})
}
if (this.audioContext.src !== filePath) {
try { this.audioContext.pause() } catch (e) {}
try { this.audioContext.seek(0) } catch (e) {}
this.setData({ currentTime: 0, sliderValue: 0, audioDuration: 0 })
this.audioContext.src = filePath
} else {
try { this.audioContext.seek(0) } catch (e) {}
this.setData({ currentTime: 0, sliderValue: 0 })
}
try {
this.audioContext.play()
this.setData({ isPlaying: true })
} catch (error) {
wx.showToast({ title: '音频播放失败', icon: 'none' })
}
}
if (cachedLocal) {
playWithPath(cachedLocal)
} else {
apiManager.downloadFile(fileId).then((filePath) => {
this.setData({
assessmentAudioLocalMap: {
...this.data.assessmentAudioLocalMap,
[fileId]: filePath
}
})
playWithPath(filePath)
}).catch((error) => {
console.error('下载音频文件失败:', error)
wx.showToast({ title: '音频下载失败', icon: 'none' })
})
}
},
async handleWordClick(e: any) {
const { word, index } = e.currentTarget.dataset
// 若例句未被选中,优先选中例句并阻止单词选择
const { selectedSentenceIndex, justSelectedByWord } = this.data
if (typeof index === 'number' && index !== selectedSentenceIndex) {
// 选中目标例句,并更新相关状态(由点击单词触发)
const sentences = this.data.sentences
if (!sentences || !sentences[index]) return
const currentSentence = sentences[index]
const assessmentResult = currentSentence?.details?.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 < 0 && pronAccuracy < 0 && pronCompletion < 0 && pronFluency < 0
this.resetAudioState()
this.setData({
selectedSentenceIndex: index,
currentSentence,
hasScoreInfo: !allNegative && !!currentSentence.details,
totalScore: suggestedScore > 0 ? suggestedScore : 0,
accuracyScore: pronAccuracy > 0 ? pronAccuracy : 0,
completenessScore: pronCompletion > 0 ? pronCompletion : 0,
fluencyScore: pronFluency > 0 ? pronFluency : 0,
justSelectedByWord: true,
prototypeWord: ''
})
// this.updateCircleProgress()
return
}
// 若是刚通过点击单词选中句子,则本次点击不弹词典,重置标记位
if (justSelectedByWord) {
this.setData({ justSelectedByWord: false })
// 第二次点击,允许继续执行查询词典逻辑
}
// 清理单词,移除标点符号
const cleanedWord = word.replace(/[.,?!*;:'"()]/g, '').trim()
if (!cleanedWord) return
// 先清空 prototypeWord避免新词没有原型时保留旧值
// 判断是否通过 word-source 标签点击(通过是否有 index 判断)
const isFromWordSource = typeof index === 'undefined';
const { isReturningFromPrevious } = this.data;
// 只有通过 word-source 标签点击且不是从返回功能调用时才保存当前单词作为上一个单词,并显示返回图标
let showBackIcon = false;
let previousWord = '';
if (isFromWordSource && !isReturningFromPrevious) {
// 保存当前单词作为上一个单词,并显示返回图标
previousWord = this.data.wordDict?.simple?.query || '';
showBackIcon = !!previousWord;
}
// 注意:这里不立即设置 showBackIcon 和 previousWord而是在获取单词详情后再设置
// 重置返回标记位
this.setData({
prototypeWord: '',
isReturningFromPrevious: false
});
try {
// 调用API获取单词详情
this.setData({ dictLoading: true, showDictPopup: true })
const wordDetail: any = await apiManager.getWordDetail(cleanedWord);
logger.info('获取到单词详情:', wordDetail);
// 处理 Collins 数据中的 HTML 标签
const processedCollins = wordDetail['collins'] ? this.processCollinsData(wordDetail['collins']) : wordDetail['collins'];
const rawRelWord = wordDetail['rel_word']
let sanitizedRelWord = rawRelWord
if (rawRelWord && rawRelWord.rels && Array.isArray(rawRelWord.rels)) {
sanitizedRelWord = {
...rawRelWord,
rels: rawRelWord.rels.map((entry: any) => {
const rel = entry.rel || {}
const pos = rel.pos
const cleanPos = typeof pos === 'string' ? pos.replace(/\[|\]/g, '') : pos
return { ...entry, rel: { ...rel, pos: cleanPos } }
})
}
}
// 这里可以添加处理单词详情的逻辑,比如显示在页面上
const hasEE = !!wordDetail['ee']
const hasEC = !!(wordDetail['ec'] && wordDetail['ec'].word && wordDetail['ec'].word.length > 0)
const hasCollins = !!(processedCollins && processedCollins.collins_entries && processedCollins.collins_entries.length > 0)
const isWordEmptyResult = !(hasEE || hasEC || hasCollins)
const hasPhrs = !!(wordDetail['phrs'] && wordDetail['phrs'].phrs && wordDetail['phrs'].phrs.length > 0)
const hasPastExam = !!(wordDetail['individual'] && wordDetail['individual'].pastExamSents && wordDetail['individual'].pastExamSents.length > 0)
const hasRelated = !!((wordDetail['syno'] && wordDetail['syno'].synos && wordDetail['syno'].synos.length > 0) || (sanitizedRelWord && sanitizedRelWord.rels && sanitizedRelWord.rels.length > 0) || (wordDetail['discriminate'] && wordDetail['discriminate'].data && wordDetail['discriminate'].data.length > 0))
const dictDefaultTabValue = hasCollins ? '0' : (hasPhrs ? '1' : (hasPastExam ? '2' : '3'))
this.setData({
showDictPopup: true,
wordDict: {
ee:wordDetail['ee'], ec:wordDetail['ec'], expandEc:wordDetail['expand_ec'],
simple:wordDetail['simple'], phrs:wordDetail['phrs'], etym:wordDetail['etym'],
individual:wordDetail['individual'], collins: processedCollins,
relWord: sanitizedRelWord, syno: wordDetail['syno'],
discriminate: wordDetail['discriminate']
},
prototypeWord: (wordDetail?.ec?.word?.[0]?.prototype) || '',
showBackIcon: showBackIcon, // 只有通过 word-source 点击且不是从返回功能调用时才显示返回图标
previousWord: previousWord, // 只有通过 word-source 点击且不是从返回功能调用时才保存上一个单词
isWordEmptyResult,
dictDefaultTabValue
});
this.setData({ dictLoading: false })
} catch (error) {
logger.error('获取单词详情失败:', error)
wx.showToast({
title: '获取单词详情失败',
icon: 'none'
})
this.setData({ dictLoading: false })
}
},
// 选中例句
handleSentenceSelect(e: any) {
const { index } = e.currentTarget.dataset
if (typeof index !== 'number') return
const { sentences } = this.data
if (!sentences || !sentences[index]) return
// 切换选中时,重置音频播放状态
this.resetAudioState()
const currentSentence = sentences[index]
const assessmentResult = currentSentence?.details?.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((score: number) => score < 0)
const wordScores = assessmentResult?.Words?.map((word: any) => ({
word: word.Word,
pronAccuracy: Number(word.PronAccuracy.toFixed(2)),
pronFluency: Number(word.PronFluency.toFixed(2)),
matchTag: word.MatchTag || 0,
phoneInfos: word.PhoneInfos?.map((phone: any) => ({
phone: phone.Phone,
pronAccuracy: Number(phone.PronAccuracy.toFixed(2)),
matchTag: phone.MatchTag || 0
})) || []
})) || []
this.setData({
selectedSentenceIndex: index,
currentSentence,
currentIndex: index,
hasScoreInfo: !allNegative && !!currentSentence.details,
totalScore: suggestedScore >= 0 ? Number(suggestedScore.toFixed(2)) : 0,
accuracyScore: pronAccuracy >= 0 ? Number(pronAccuracy.toFixed(2)) : 0,
completenessScore: pronCompletion >= 0 ? Number((pronCompletion * 100).toFixed(2)) : 0,
fluencyScore: pronFluency >= 0 ? Number((pronFluency * 100).toFixed(2)) : 0,
wordScores,
justSelectedByWord: false,
prototypeWord: '',
overlayVisible: false,
highlightShow: false,
highlightZoom: false,
focusTransform: '',
highlightWords: [],
cachedHighlightWords: [],
cachedSentenceIndex: -1
})
// 预加载并绑定新的标准语音
if (currentSentence.id) {
this.getStandardVoice(currentSentence.id)
}
// try {
// wx.nextTick(() => {
// this.computeHighlightLayout()
// })
// } catch (e) {
// setTimeout(() => this.computeHighlightLayout(), 0)
// }
},
onLoad(options: Record<string, string>) {
this.ensureRecordPermission()
try { this.computeDynamicLayout() } catch (e) {}
// 如果图片ID调用接口获取文本和评分信息
if (options.index) {
this.setData({ currentIndex: Number(options.index) })
}
if (options.imageId) {
// wx.showLoading({ title: '加载中...' })
this.setData({ loadingMaskVisible: true })
apiManager.getImageTextInit(options.imageId)
.then(res => {
// 更新图片路径(如果有)
if (res.image_file_id) {
// 使用安全的文件下载方式获取图片
apiManager.getFileDisplayUrl(res.image_file_id)
.then(imagePath => {
this.setData({ imagePath })
})
.catch(error => {
logger.error('获取图片失败:', error)
})
.finally(() => {
this.setData({ loadingMaskVisible: false })
// try { wx.nextTick(() => { this.computeHighlightLayout() }) } catch (e) { setTimeout(() => this.computeHighlightLayout(), 0) }
})
}
// 更新例句和评分信息
if (res.assessments && res.assessments.length > 0) {
const sentences = res.assessments
// 处理句子数据
const processedSentences = this.processSentences(sentences)
const index = this.data.currentIndex || 0
const currentSentence = sentences[index]
const assessmentResult = currentSentence?.details?.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((score: number) => score < 0)
// 处理单词评分数据
const wordScores = assessmentResult?.Words?.map((word: any) => ({
word: word.Word,
pronAccuracy: Number(word.PronAccuracy.toFixed(2)),
pronFluency: Number(word.PronFluency.toFixed(2)),
matchTag: word.MatchTag || 0,
phoneInfos: word.PhoneInfos?.map((phone: any) => ({
phone: phone.Phone,
pronAccuracy: Number(phone.PronAccuracy.toFixed(2)),
matchTag: phone.MatchTag || 0
})) || []
})) || []
this.setData({
sentences,
processedSentences,
currentSentence,
currentIndex: index,
selectedSentenceIndex: index,
hasScoreInfo: !allNegative && !!currentSentence.details,
totalScore: suggestedScore >= 0 ? Number(suggestedScore.toFixed(2)) : 0,
accuracyScore: pronAccuracy >= 0 ? Number(pronAccuracy.toFixed(2)) : 0,
completenessScore: pronCompletion >= 0 ? Number((pronCompletion * 100).toFixed(2)) : 0,
fluencyScore: pronFluency >= 0 ? Number((pronFluency * 100).toFixed(2)) : 0,
wordScores
})
// this.updateWordsAndIpas(currentSentence.content, currentSentence.ipa)
// 获取当前例句的标准语音
if (currentSentence.id) {
this.getStandardVoice(currentSentence.id)
}
// try {
// wx.nextTick(() => {
// this.computeHighlightLayout()
// })
// } catch (e) {
// setTimeout(() => this.computeHighlightLayout(), 0)
// }
if (!res.image_file_id) {
this.setData({ loadingMaskVisible: false })
}
}
// 初始化圆形进度条
// this.updateCircleProgress()
})
.catch(err => {
logger.error('获取图片文本和评分信息失败:', err)
wx.showToast({
title: '加载失败',
icon: 'none'
})
this.setData({ loadingMaskVisible: false })
// try { wx.nextTick(() => { this.computeHighlightLayout() }) } catch (e) { setTimeout(() => this.computeHighlightLayout(), 0) }
})
.finally(() => {
wx.hideLoading()
})
} else {
// 初始化圆形进度条
// this.updateCircleProgress()
}
// 录音事件监听(提前绑定,避免事件丢失)
if (!recorderHandlersBound) {
recorderManager.onStop((res) => {
const ms = Date.now() - this.data.recordStartTime
if (ms >= 3000) {
wx.showModal({
title: '提示',
content: '录音完成,是否确认提交?',
success: (result) => {
if (result.confirm) {
// wx.showLoading({ title: '正在解析...' })
logger.info('录音文件路径:', res.tempFilePath)
this.setData({ analizing: true })
apiManager.uploadFile(res.tempFilePath).then((fileId) => {
apiManager.getAssessmentResult(fileId, this.data.currentSentence.id).then((result) => {
const assessmentResult = result.assessment_result.assessment.result
const suggestedScore = assessmentResult.SuggestedScore
const pronAccuracy = assessmentResult.PronAccuracy
const pronCompletion = assessmentResult.PronCompletion
const pronFluency = assessmentResult.PronFluency
const allNegative = [suggestedScore, pronAccuracy, pronCompletion, pronFluency].every(score => score < 0)
const { sentences, currentIndex } = this.data
sentences[currentIndex].details = {
assessment: { result: assessmentResult }
}
sentences[currentIndex].file_id = fileId
const wordScores = assessmentResult.Words?.map((word: any) => ({
word: word.Word,
pronAccuracy: Number(word.PronAccuracy.toFixed(2)),
pronFluency: Number(word.PronFluency.toFixed(2)),
matchTag: word.MatchTag || 0,
phoneInfos: word.PhoneInfos?.map((phone: any) => ({
phone: phone.Phone,
pronAccuracy: Number(phone.PronAccuracy.toFixed(2)),
matchTag: phone.MatchTag || 0
}))
})) || []
this.setData({
sentences,
currentSentence: sentences[currentIndex],
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,
isRecording: false,
overlayVisible: false,
highlightShow: false,
highlightZoom: false,
focusTransform: '',
highlightWords: [],
analizing: false
})
this.onScoreTap()
// this.updateCircleProgress()
// wx.hideLoading()
}).catch(err => {
logger.error('获取评估结果失败:', err)
this.setData({
isRecording: false,
overlayVisible: false,
highlightShow: false,
highlightZoom: false,
focusTransform: '',
highlightWords: [],
analizing: false
})
wx.showToast({ title: '评估失败', icon: 'none' })
// wx.hideLoading()
}).finally(() => {
this.setData({
isRecording: false,
overlayVisible: false,
highlightShow: false,
highlightZoom: false,
focusTransform: '',
highlightWords: [],
analizing: false
})
})
}).catch(err => {
logger.error('上传录音失败:', err)
this.setData({
isRecording: false,
overlayVisible: false,
highlightShow: false,
highlightZoom: false,
focusTransform: '',
highlightWords: [],
analizing: false
})
wx.showToast({ title: '上传失败', icon: 'none' })
// wx.hideLoading()
}).finally(() => {
this.setData({
isRecording: false,
overlayVisible: false,
highlightShow: false,
highlightZoom: false,
focusTransform: '',
highlightWords: [],
analizing: false
})
})
}
},
fail: () => {
this.setData({
isRecording: false,
overlayVisible: false,
highlightShow: false,
highlightZoom: false,
focusTransform: '',
highlightWords: [],
analizing: false
})
}
})
} else {
this.setData({
isRecording: false,
overlayVisible: false,
highlightShow: false,
highlightZoom: false,
focusTransform: '',
highlightWords: [],
analizing: false
})
wx.showToast({
title: '说话时间太短',
icon: 'none'
})
}
})
recorderManager.onError((res) => {
wx.showToast({ title: '录音失败', icon: 'none' })
// 录音出错时淡出高亮层
this.setData({ overlayVisible: false, isRecording: false })
const timer = setTimeout(() => {
this.setData({ highlightShow: false, highlightZoom: false, focusTransform: '', highlightWords: [], analizing: false })
clearTimeout(timer)
}, 320)
})
recorderHandlersBound = true
}
},
noop() {},
onUnload() {
// 销毁音频实例
if (this.audioContext) {
this.audioContext.destroy()
}
if (this.circleAnimTimer) {
clearInterval(this.circleAnimTimer)
this.circleAnimTimer = undefined
}
// 销毁单词音频实例与清理定时器
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: '',
// 清空页面级音频缓存映射
standardAudioLocalMap: {},
assessmentAudioLocalMap: {}
})
},
onReady() {
// 录音事件监听已在 onLoad 提前绑定,避免未绑定时触发 stop 事件导致丢失
},
// 点击“more”按钮展开/收起扩展内容
handleDictMore() {
this.setData({
showDictExtended: !this.data.showDictExtended
})
},
// 点击“x”按钮关闭弹窗
handleDictClose() {
// 关闭弹窗
this.setData({
showDictExtended: false,
showDictPopup: false,
showBackIcon: false, // 清除返回图标标记位
previousWord: '', // 清除上一个单词
isReturningFromPrevious: 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: ''
})
},
onTabsChange(event: any) {
logger.info(`Change tab, tab-panel value is ${event.detail.value}.`);
},
onTabsClick(event: any) {
logger.info(`Click tab, tab-panel value is ${event.detail.value}.`);
},
// 播放单词音频(带图标轮播)
playWordAudio(e: any) {
const { audio, type } = e.currentTarget.dataset
if (!audio) return
// 如果点击同一类型且正在播放,则切换为停止
if (this.data.wordAudioPlaying && this.data.activeWordAudioType === type) {
try { this.wordAudioContext && this.wordAudioContext.stop() } catch (err) {}
try {
if (this.wordAudioContext) {
this.wordAudioContext.offPlay()
this.wordAudioContext.offEnded()
this.wordAudioContext.offError()
this.wordAudioContext.destroy()
this.wordAudioContext = undefined
}
} catch (err) {}
if (this.wordAudioIconTimer) {
clearInterval(this.wordAudioIconTimer)
this.wordAudioIconTimer = undefined
}
this.setData({
wordAudioPlaying: false,
wordAudioIconName: 'sound',
activeWordAudioType: ''
})
return
}
// 先清理上一次的上下文与定时器
try { this.wordAudioContext && this.wordAudioContext.stop() } catch (err) {}
try {
if (this.wordAudioContext) {
this.wordAudioContext.offPlay()
this.wordAudioContext.offEnded()
this.wordAudioContext.offError()
this.wordAudioContext.destroy()
this.wordAudioContext = undefined
}
} catch (err) {}
if (this.wordAudioIconTimer) {
clearInterval(this.wordAudioIconTimer)
this.wordAudioIconTimer = undefined
}
// 构造音频URL
const audioUrl = `https://dict.youdao.com/dictvoice?audio=${audio}`
// 创建音频上下文
this.wordAudioContext = wx.createInnerAudioContext()
try { (this.wordAudioContext as any).autoplay = false } catch (err) {}
// 绑定事件
this.wordAudioContext.onPlay(() => {
// 开始轮播图标
const seq = ['sound', 'sound-low']
let i = 0
this.setData({ wordAudioPlaying: true, activeWordAudioType: type, wordAudioIconName: seq[0] })
this.wordAudioIconTimer = setInterval(() => {
i = (i + 1) % seq.length
this.setData({ wordAudioIconName: seq[i] })
}, 400) as any
})
const finalize = () => {
if (this.wordAudioIconTimer) {
clearInterval(this.wordAudioIconTimer)
this.wordAudioIconTimer = undefined
}
this.setData({
wordAudioPlaying: false,
wordAudioIconName: 'sound',
activeWordAudioType: ''
})
try {
if (this.wordAudioContext) {
this.wordAudioContext.offPlay()
this.wordAudioContext.offEnded()
this.wordAudioContext.offError()
this.wordAudioContext.destroy()
this.wordAudioContext = undefined
}
} catch (err) {}
}
this.wordAudioContext.onEnded(() => {
finalize()
})
this.wordAudioContext.onError((res) => {
logger.error('播放单词音频失败:', res)
wx.showToast({ title: '播放失败', icon: 'none' })
finalize()
})
// 设置音频源并播放
this.wordAudioContext.src = audioUrl
this.wordAudioContext.play()
},
onScoreTap() {
if (this.data.isRecording) return
logger.info('Score button tapped')
// 当当前选中例句无评分信息时,不响应点击
if (!this.data.hasScoreInfo) {
return
}
// 有评分信息时,打开评分结果弹窗
this.setData({
isScoreModalOpen: true,
scoreModalVisible: false
})
setTimeout(() => {
this.setData({ scoreModalVisible: true })
setTimeout(() => {
this.updateCircleProgress()
}, 100)
}, 0)
},
onCloseScoreModal() {
// 关闭评分弹窗时重置音频播放状态,避免残留播放
this.resetAudioState()
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
})
},
onTransTap() {
if (this.data.isRecording) return
logger.info('User button tapped')
// Cycle through translation display modes
const currentMode = this.data.transDisplayMode
let nextMode: 'en' | 'en_ipa' | 'en_zh' = 'en'
switch (currentMode) {
case 'en':
nextMode = 'en_ipa'
break
case 'en_ipa':
nextMode = 'en_zh'
break
case 'en_zh':
nextMode = 'en'
break
default:
nextMode = 'en'
}
this.setData({
transDisplayMode: nextMode
})
},
// 获取翻译按钮图标名称
getTransIconName() {
switch (this.data.transDisplayMode) {
case 'en':
return 'file-teams'
case 'en_ipa':
return 'file-search'
case 'en_zh':
return 'translate'
default:
return 'file-teams'
}
},
onPageScroll(e: any) {
if (!e || typeof e.scrollTop !== 'number') return
const t = e.scrollTop
const scale = Math.min(1.18, 1 + t / 900)
const translate = Math.min(60, t / 15)
this.setData({ imageScale: scale, imageTranslateY: translate })
},
computeDynamicLayout() {
try {
const windowInfo = wx.getWindowInfo();
const wh = windowInfo.windowHeight
const ww = windowInfo.windowWidth
const rpx = ww / 750
const isTall = (wh / ww) > 1.8
const ratio = isTall ? 0.6 : 0.52
const imageH = Math.floor(wh * ratio)
const bottomMinRpx = 100
const bottomPadRpx = 40
const bottomPx = Math.floor((bottomMinRpx + bottomPadRpx) * rpx)
const overlapRpx = 40
const overlapPx = Math.floor(overlapRpx * rpx)
const sentenceMin = Math.max(80, Math.floor(wh - imageH - bottomPx))
const sentenceTop = Math.max(0, imageH - overlapPx)
this.setData({ imageSectionHeight: imageH, sentenceMinHeight: sentenceMin, sentenceMarginTop: sentenceTop })
} catch (e) {}
},
onSentenceTouchStart(e: any) {
const y = (e && e.touches && e.touches[0] && typeof e.touches[0].clientY === 'number') ? e.touches[0].clientY : 0
this.sentenceTouchStartY = y
this.sentencePullTriggered = false
this.sentenceBaseLayouts = {
imageSectionHeight: this.data.imageSectionHeight || 0,
sentenceMinHeight: this.data.sentenceMinHeight || 0,
sentenceMarginTop: this.data.sentenceMarginTop || 0
}
},
onSentenceTouchMove(e: any) {
const startY = this.sentenceTouchStartY || 0
const y = (e && e.touches && e.touches[0] && typeof e.touches[0].clientY === 'number') ? e.touches[0].clientY : 0
const delta = y - startY
// this.sentenceDragDelta = delta
const base = this.sentenceBaseLayouts || {
imageSectionHeight: this.data.imageSectionHeight || 0,
sentenceMinHeight: this.data.sentenceMinHeight || 0,
sentenceMarginTop: this.data.sentenceMarginTop || 0
}
const minSentence = 80
const expandCap = Math.max(0, (base.sentenceMinHeight || 0) - minSentence)
const contractMinImage = Math.floor((base.imageSectionHeight || 0) * 0.4)
const contractCap = Math.max(0, (base.imageSectionHeight || 0) - contractMinImage)
const down = Math.min(Math.max(delta, 0), expandCap)
const up = Math.min(Math.max(-delta, 0), contractCap)
if (down > 0 && up === 0) {
const translateSentence = down
this.setData({ dragSentenceTranslateY: translateSentence })
}
if (up > 0 && down === 0) {
const translateSentence = -up
this.setData({ dragSentenceTranslateY: translateSentence })
}
const previewThreshold = expandCap + 40
if (down > previewThreshold && !this.sentencePullTriggered && this.data.imagePath) {
this.sentencePullTriggered = true
try { this.handleImagePreview() } catch (err) {}
}
},
onSentenceTouchEnd() {
this.sentenceTouchStartY = 0
this.sentencePullTriggered = false
// 保持当前的 transform不重置高度或缩放
},
// 处理句子数据,分割单词和音标
processSentences(sentences: any[]) {
return sentences.map(sentence => {
// 分割内容和音标
const words = sentence.content ? sentence.content.split(' ') : []
const ipas = sentence.ipa ? sentence.ipa.split(' ') : []
// 处理标点符号,确保单词末尾的标点符号保留在正确位置
const processedWords = words.map((word: string, index: number) => {
// 如果是最后一个单词,保留可能的句号或逗号
if (index === words.length - 1) {
// 保留句末标点符号
return word
}
// 对于其他单词,移除常见的标点符号
return word.replace(/[.,;:!?]/g, '')
})
return {
words: processedWords,
ipas,
zh: sentence.zh || ''
}
})
},
// 处理 Collins 数据中的 HTML 标签
processCollinsData(collinsData: any) {
if (!collinsData || !collinsData.collins_entries) {
return collinsData;
}
// 深拷贝数据以避免修改原始数据
const processedData = JSON.parse(JSON.stringify(collinsData));
// 遍历 collins_entries
processedData.collins_entries.forEach((entry: any) => {
if (entry.entries && entry.entries.entry) {
// 遍历 entry
entry.entries.entry.forEach((entryItem: any) => {
if (entryItem.tran_entry) {
// 遍历 tran_entry
entryItem.tran_entry.forEach((tranEntry: any) => {
// 处理 tran 字段中的 HTML 标签
if (tranEntry.tran) {
// 将包含 <b> 标签的文本转换为数组格式便于 WXML 渲染
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>')) {
// 提取 bold 文本内容
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;
},
onMoreTap() {
if (this.data.isRecording) return
const { isMoreMenuOpen } = this.data
if (!isMoreMenuOpen) {
this.setData({ isMoreMenuOpen: true, isMoreMenuClosing: false })
return
}
this.setData({ isMoreMenuOpen: false, isMoreMenuClosing: true })
const timer = setTimeout(() => {
this.setData({ isMoreMenuClosing: false })
clearTimeout(timer)
}, 360)
},
computeHighlightLayout() {
const { processedSentences, selectedSentenceIndex } = this.data
if (!processedSentences || processedSentences.length === 0) return
const sentence = processedSentences[selectedSentenceIndex]
const words = sentence?.words || []
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 = 2.5
const centerX = windowWidth / 2
const centerY = (windowHeight - bottomOffset) / 2
const query = wx.createSelectorQuery().in(this as any)
query.selectAll('.sentence-wrapper.selected .sentence-text').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, i) => {
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: selectedSentenceIndex })
})
},
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) {}
},
// 返回上一个单词的查询结果
handleBackToPreviousWord() {
const { previousWord } = this.data;
if (previousWord === '') return;
// 设置返回标记位
this.setData({
isReturningFromPrevious: true
});
// 调用 handleWordClick 方法查询上一个单词
const event = {
currentTarget: {
dataset: {
word: previousWord
}
}
};
// 查询上一个单词
this.handleWordClick(event);
}
})