This commit is contained in:
Felix
2025-11-29 13:29:29 +08:00
parent a3d28e99e3
commit a949eef388
3 changed files with 182 additions and 53 deletions

View File

@@ -125,7 +125,10 @@ interface IPageData {
focusWordIndex: number,
focusTransform: string,
cachedHighlightWords: string[],
isWordEmptyResult: boolean
cachedSentenceIndex: number,
isWordEmptyResult: boolean,
recordPermissionGranted: boolean
loadingMaskVisible: boolean
}
type IPageMethods = {
@@ -159,7 +162,9 @@ type IPageMethods = {
processCollinsData: (collinsData: any) => any // Add this line
computeHighlightLayout:() => void
onMicHighlight: () => void
ensureRecordPermission: () => void
onMoreTap: () => void
noop: () => void
}
interface IPageInstance extends IPageMethods {
@@ -258,47 +263,67 @@ Page<IPageData, IPageInstance>({
focusWordIndex: 0,
focusTransform: '',
cachedHighlightWords: [],
isMoreMenuClosing: false
cachedSentenceIndex: -1,
isMoreMenuClosing: false,
recordPermissionGranted: false,
loadingMaskVisible: false
},
onMicHighlight() {
const pre = this.data.cachedHighlightWords || []
if (!pre || pre.length === 0) {
this.computeHighlightLayout()
}
const words = this.data.cachedHighlightWords || []
if (!words || words.length === 0) return
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.setData({
overlayVisible: true,
highlightWords: words.map((w: any) => ({ ...w, transform: 'translate(0px, 0px) scale(1)' })),
highlightShow: false,
highlightZoom: false
})
setTimeout(() => {
this.setData({ highlightShow: true })
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({ 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)
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(() => {
this.computeHighlightLayout()
setTimeout(() => startAnim(), 80)
})
} catch (e) {
setTimeout(() => {
this.computeHighlightLayout()
setTimeout(() => startAnim(), 80)
}, 0)
}
} else {
startAnim()
}
},
// 切换评分区域展开状态
toggleScoreSection() {
@@ -494,14 +519,17 @@ Page<IPageData, IPageInstance>({
this.setData({ recordDuration: duration })
if (duration < 3000) { // 小于3秒
wx.showToast({
title: '说话时间太短',
icon: 'none'
})
recorderManager.stop()
this.setData({
isRecording: false,
remainingTime: 30 // 重置倒计时
remainingTime: 30,
overlayVisible: false,
highlightShow: false,
highlightZoom: false,
focusTransform: '',
highlightWords: [],
cachedHighlightWords: [],
cachedSentenceIndex: -1
})
return
}
@@ -574,6 +602,7 @@ Page<IPageData, IPageInstance>({
// 播放标准语音
playStandardVoice() {
if (this.data.isRecording) return
const { currentSentence, standardAudioMap, isPlaying } = this.data
const audioUrl = standardAudioMap[currentSentence.id]
@@ -736,6 +765,7 @@ Page<IPageData, IPageInstance>({
// 新增:播放评分结果音频(使用当前句子的 file_id
playAssessmentVoice() {
if (this.data.isRecording) return
const { currentSentence, isPlaying } = this.data
const fileId = currentSentence?.file_id
@@ -1056,7 +1086,14 @@ Page<IPageData, IPageInstance>({
fluencyScore: pronFluency >= 0 ? Number((pronFluency * 100).toFixed(2)) : 0,
wordScores,
justSelectedByWord: false,
prototypeWord: ''
prototypeWord: '',
overlayVisible: false,
highlightShow: false,
highlightZoom: false,
focusTransform: '',
highlightWords: [],
cachedHighlightWords: [],
cachedSentenceIndex: -1
})
// 预加载并绑定新的标准语音
@@ -1074,12 +1111,14 @@ Page<IPageData, IPageInstance>({
},
onLoad(options: Record<string, string>) {
this.ensureRecordPermission()
// 如果图片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 => {
// 更新图片路径(如果有)
@@ -1092,6 +1131,10 @@ Page<IPageData, IPageInstance>({
.catch(error => {
console.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) {
@@ -1140,6 +1183,16 @@ Page<IPageData, IPageInstance>({
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()
@@ -1150,6 +1203,8 @@ Page<IPageData, IPageInstance>({
title: '加载失败',
icon: 'none'
})
this.setData({ loadingMaskVisible: false })
try { wx.nextTick(() => { this.computeHighlightLayout() }) } catch (e) { setTimeout(() => this.computeHighlightLayout(), 0) }
})
.finally(() => {
wx.hideLoading()
@@ -1162,9 +1217,8 @@ Page<IPageData, IPageInstance>({
// 录音事件监听(提前绑定,避免事件丢失)
if (!recorderHandlersBound) {
recorderManager.onStop((res) => {
// 使用实例属性的时长判断,避免 setData 异步导致值为 0
const ms = this.lastRecordDurationMs || 0
if (ms >= 3000) { // 只有录音时长大于3秒才提示确认
const ms = Date.now() - this.data.recordStartTime
if (ms >= 3000) {
wx.showModal({
title: '提示',
content: '录音完成,是否确认提交?',
@@ -1225,6 +1279,11 @@ Page<IPageData, IPageInstance>({
}
}
})
} else {
wx.showToast({
title: '说话时间太短',
icon: 'none'
})
}
})
@@ -1242,6 +1301,8 @@ Page<IPageData, IPageInstance>({
}
},
noop() {},
onUnload() {
// 销毁音频实例
if (this.audioContext) {
@@ -1425,6 +1486,7 @@ Page<IPageData, IPageInstance>({
},
onScoreTap() {
if (this.data.isRecording) return
console.log('Score button tapped')
// 当当前选中例句无评分信息时,不响应点击
if (!this.data.hasScoreInfo) {
@@ -1463,6 +1525,7 @@ Page<IPageData, IPageInstance>({
})
},
onTransTap() {
if (this.data.isRecording) return
console.log('User button tapped')
// Cycle through translation display modes
const currentMode = this.data.transDisplayMode
@@ -1515,6 +1578,11 @@ Page<IPageData, IPageInstance>({
imageMode: 'aspectFit'
})
}
try { if ((this as any)._layoutDebounceTimer) clearTimeout((this as any)._layoutDebounceTimer) } catch (e) {}
;(this as any)._layoutDebounceTimer = setTimeout(() => {
this.computeHighlightLayout()
}, 100)
},
// 处理句子数据,分割单词和音标
@@ -1592,6 +1660,7 @@ Page<IPageData, IPageInstance>({
},
onMoreTap() {
if (this.data.isRecording) return
const { isMoreMenuOpen } = this.data
if (!isMoreMenuOpen) {
this.setData({ isMoreMenuOpen: true, isMoreMenuClosing: false })
@@ -1683,10 +1752,48 @@ Page<IPageData, IPageInstance>({
}
})
this.setData({ cachedHighlightWords: targetWords })
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;
@@ -1709,4 +1816,4 @@ Page<IPageData, IPageInstance>({
// 查询上一个单词
this.handleWordClick(event);
}
})
})

View File

@@ -36,24 +36,26 @@
<text class="overlay-text">{{item.text}}</text>
</view>
</view>
<view wx:if="{{isRecording}}" class="recording-mask" catchtouchstart="noop" catchtouchmove="noop" catchtouchend="noop"></view>
<view wx:if="{{loadingMaskVisible}}" class="page-loading-mask" catchtap="noop" catchtouchstart="noop" catchtouchmove="noop" catchtouchend="noop"></view>
<!-- 底部按钮区域 -->
<view class="bottom-button-area">
<view wx:if="{{isMoreMenuOpen}}" class="more-menu-modal"></view>
<view class="button-row">
<t-icon name="{{isPlaying ? 'pause' : 'play'}}" class="bottom-button" size="48rpx" bind:tap="playStandardVoice" />
<view class="bottom-button-img-wrap bottom-button" bind:tap="onTransTap">
<t-icon name="{{isPlaying ? 'pause' : 'play'}}" class="bottom-button {{isRecording ? 'disabled' : ''}}" size="48rpx" bind:tap="playStandardVoice" />
<view class="bottom-button-img-wrap bottom-button {{isRecording ? 'disabled' : ''}}" bind:tap="onTransTap">
<t-icon name="translate" class="trans-button left-half {{transDisplayMode === 'en_ipa' ? 'trans-active' : 'trans-deactive'}}" size="48rpx" />
<t-icon name="translate" class="trans-button right-half {{transDisplayMode === 'en_zh' ? 'trans-active' : 'trans-deactive'}}" size="48rpx" />
</view>
<view class="bottom-button mic-wrap" bindtap="onMicHighlight">
<t-icon name="microphone-1" color="{{isRecording ? '#FFFFFF' : '#333333'}}" class="microphone {{isRecording ? 'recording' : 'bottom-button'}}" size="48rpx" bind:longpress="handleRecordStart" bind:touchend="handleRecordEnd" bind:touchcancel="handleRecordEnd" />
<view class="bottom-button mic-wrap" bindtap="onMicHighlight" catchtouchstart="noop" catchtouchmove="noop" catchtouchend="noop">
<t-icon name="microphone-1" color="{{isRecording ? '#FFFFFF' : '#333333'}}" class="microphone {{isRecording ? 'recording' : 'bottom-button'}} {{recordPermissionGranted ? '' : 'disabled'}}" size="48rpx" bind:longpress="handleRecordStart" bind:touchend="handleRecordEnd" bind:touchcancel="handleRecordEnd" />
<view wx:if="{{isRecording}}" class="mic">
<view class="mic-shadow"></view>
</view>
</view>
<t-icon name="fact-check" class="bottom-button {{hasScoreInfo ? '' : 'disabled'}}" size="48rpx" bind:tap="onScoreTap" />
<t-icon name="fact-check" class="bottom-button {{(hasScoreInfo && !isRecording) ? '' : 'disabled'}}" size="48rpx" bind:tap="onScoreTap" />
<!-- <t-icon name="ellipsis" class="bottom-button {{isMoreMenuOpen ? 'more-open' : ''}}" size="48rpx" bind:tap="onMoreTap" /> -->
<view class="bottom-button" bindtap="onMoreTap">
<view class="bottom-button {{isRecording ? 'disabled' : ''}}" bindtap="onMoreTap">
<view class="ul {{isMoreMenuOpen ? 'active' : ''}}">
<view class="dot1"></view>
<view class="dot2"></view>

View File

@@ -573,6 +573,26 @@
.overlay-word.show .overlay-text { font-weight: 600; }
.overlay-word.zoom { transform-origin: center center; transition: transform 500ms ease; }
.page-loading-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: transparent;
z-index: 1000;
}
.recording-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: transparent;
z-index: 99;
}
.bottom-button {
padding: 20rpx;
border-radius: 50%;