fix(voice): restore failed transcription recovery
This commit is contained in:
@@ -2,6 +2,7 @@ import {
|
||||
copyApiRequestId,
|
||||
createFeedbackRecord,
|
||||
createVoiceLesson,
|
||||
discardVoiceSegment,
|
||||
ensureFeedbackSession,
|
||||
finishVoiceLesson,
|
||||
generateFeedbackFromVoice,
|
||||
@@ -20,6 +21,13 @@ import type {
|
||||
StudentProfile,
|
||||
VoiceLessonFinished
|
||||
} from '../../utils/types'
|
||||
import { runWithSessionRefresh } from './voice-actions'
|
||||
import {
|
||||
getFailedVoiceSegments,
|
||||
getVoiceFailureHint,
|
||||
getVoicePrimaryAction,
|
||||
getVoicePrimaryActionText
|
||||
} from './voice-state'
|
||||
|
||||
type PickerEvent = { detail: { value: string | number } }
|
||||
type InputEvent = { detail: { value: string } }
|
||||
@@ -98,6 +106,10 @@ function wait(milliseconds: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||
}
|
||||
|
||||
function logVoiceOperation(event: string, fields: Record<string, unknown>): void {
|
||||
console.info('voice_operation', { event, ...fields })
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
profiles: [] as StudentProfile[],
|
||||
@@ -114,6 +126,7 @@ Page({
|
||||
recordingPaused: false,
|
||||
recordingTime: '00:00',
|
||||
voiceStatus: '语音录入',
|
||||
voiceFailureHint: '',
|
||||
voiceActionable: false,
|
||||
primaryActionText: '保存反馈',
|
||||
errorMessage: '',
|
||||
@@ -241,23 +254,35 @@ Page({
|
||||
return
|
||||
}
|
||||
if (!session || session.lessonCount === 0) {
|
||||
this.setData({ voiceStatus: '语音录入', voiceActionable: false, primaryActionText: '保存反馈' })
|
||||
this.setData({
|
||||
voiceStatus: '语音录入',
|
||||
voiceFailureHint: '',
|
||||
voiceActionable: false,
|
||||
primaryActionText: '保存反馈'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const prefix = `${session.lessonCount}节 · ${formatVoiceDuration(session.totalDurationMs)}`
|
||||
let suffix = '已就绪'
|
||||
if (session.failedSegmentCount > 0) suffix = '转录失败,点击重试'
|
||||
if (session.failedSegmentCount > 0) suffix = `${session.failedSegmentCount}段转录失败`
|
||||
else if (session.processingSegmentCount > 0) suffix = '正在处理'
|
||||
let primaryActionText = session.needsGeneration ? '生成反馈' : '保存反馈'
|
||||
if (session.processingSegmentCount > 0) primaryActionText = '转录中'
|
||||
const primaryAction = getVoicePrimaryAction(session)
|
||||
this.setData({
|
||||
voiceStatus: `${prefix} · ${suffix}`,
|
||||
voiceFailureHint: session.failedSegmentCount > 0 ? getVoiceFailureHint(session) : '',
|
||||
voiceActionable: true,
|
||||
primaryActionText
|
||||
primaryActionText: getVoicePrimaryActionText(primaryAction, session.failedSegmentCount)
|
||||
})
|
||||
},
|
||||
|
||||
showApiError(error: unknown, showToast = true) {
|
||||
const errorMessage = getApiErrorMessage(error)
|
||||
const errorRequestId = getApiRequestId(error)
|
||||
this.setData({ errorMessage, errorRequestId })
|
||||
if (showToast) wx.showToast({ title: errorMessage, icon: 'none' })
|
||||
},
|
||||
|
||||
scheduleVoicePolling(session: FeedbackSession | null) {
|
||||
this.clearVoicePollTimer()
|
||||
if (!pageVisible || !session || session.processingSegmentCount === 0) return
|
||||
@@ -302,6 +327,7 @@ Page({
|
||||
if (readyShortLessons.length > 0) await this.loadActiveSession(profileId)
|
||||
} catch (error) {
|
||||
console.warn('Failed to refresh voice transcription status', error)
|
||||
this.showApiError(error, false)
|
||||
} finally {
|
||||
voicePollBusy = false
|
||||
this.scheduleVoicePolling(this.data.session)
|
||||
@@ -374,6 +400,7 @@ Page({
|
||||
this.syncVoicePresentation(updated)
|
||||
} catch (error) {
|
||||
console.warn('Failed to auto-save feedback session', error)
|
||||
this.showApiError(error, false)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -443,7 +470,7 @@ Page({
|
||||
} catch (error) {
|
||||
this.setData({ voiceBusy: false })
|
||||
this.syncVoicePresentation(this.data.session)
|
||||
wx.showToast({ title: getApiErrorMessage(error), icon: 'none' })
|
||||
this.showApiError(error)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -496,6 +523,7 @@ Page({
|
||||
await this.uploadWithRetry(segment)
|
||||
} catch (error) {
|
||||
console.warn('Voice segment upload failed', error)
|
||||
this.showApiError(error, false)
|
||||
const saved = await this.persistRecordingFile(segment)
|
||||
failedLocalUploads.push(saved)
|
||||
wx.setStorageSync(PENDING_UPLOADS_KEY, failedLocalUploads)
|
||||
@@ -536,11 +564,13 @@ Page({
|
||||
if (failedLocalUploads.length === 0) return
|
||||
this.setData({ voiceBusy: true, voiceStatus: '正在重试上传' })
|
||||
const remaining: PendingVoiceUpload[] = []
|
||||
let lastError: unknown
|
||||
for (const segment of failedLocalUploads) {
|
||||
try {
|
||||
await this.uploadWithRetry(segment)
|
||||
this.removeSavedFile(segment.filePath)
|
||||
} catch {
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
remaining.push(segment)
|
||||
}
|
||||
}
|
||||
@@ -548,7 +578,7 @@ Page({
|
||||
wx.setStorageSync(PENDING_UPLOADS_KEY, failedLocalUploads)
|
||||
if (remaining.length > 0) {
|
||||
this.setData({ voiceBusy: false, voiceStatus: '上传失败,点击重试', voiceActionable: true })
|
||||
wx.showToast({ title: '录音上传失败,请检查网络', icon: 'none' })
|
||||
this.showApiError(lastError || new Error('录音上传失败,请检查网络'))
|
||||
return
|
||||
}
|
||||
await this.finalizeCurrentLesson()
|
||||
@@ -584,7 +614,7 @@ Page({
|
||||
finishRequested = false
|
||||
await this.loadActiveSession()
|
||||
} catch (error) {
|
||||
wx.showToast({ title: getApiErrorMessage(error), icon: 'none' })
|
||||
this.showApiError(error)
|
||||
} finally {
|
||||
this.clearRecordingTimer()
|
||||
this.setData({ recording: false, recordingPaused: false, voiceBusy: false, recordingTime: '00:00' })
|
||||
@@ -647,7 +677,7 @@ Page({
|
||||
content: lines.join('\n'),
|
||||
showCancel: hasFailures,
|
||||
cancelText: '关闭',
|
||||
confirmText: hasFailures ? '重试失败项' : '知道了',
|
||||
confirmText: hasFailures ? '重试转录' : '知道了',
|
||||
success: (result) => {
|
||||
if (hasFailures && result.confirm) void this.retryFailedSegments()
|
||||
}
|
||||
@@ -657,32 +687,100 @@ Page({
|
||||
async retryFailedSegments() {
|
||||
const session = this.data.session
|
||||
if (!session) return
|
||||
const failedSegments = session.lessons.flatMap((lesson) =>
|
||||
lesson.segments.filter((segment) => segment.status === 'failed')
|
||||
)
|
||||
const failedSegments = getFailedVoiceSegments(session)
|
||||
if (failedSegments.length === 0) return
|
||||
this.setData({ voiceBusy: true, voiceStatus: '正在重试转录' })
|
||||
logVoiceOperation('transcription_retry_clicked', {
|
||||
feedbackSessionId: session.id,
|
||||
failedSegmentCount: failedSegments.length
|
||||
})
|
||||
this.setData({
|
||||
voiceBusy: true,
|
||||
voiceStatus: `正在重试${failedSegments.length}段转录`,
|
||||
primaryActionText: '正在重试转录',
|
||||
errorMessage: '',
|
||||
errorRequestId: ''
|
||||
})
|
||||
try {
|
||||
for (const segment of failedSegments) await retryVoiceSegment(segment.id)
|
||||
const failedLessonIds = session.lessons
|
||||
.filter((lesson) => lesson.segments.some((segment) => segment.status === 'failed'))
|
||||
.map((lesson) => lesson.id)
|
||||
for (const lessonId of failedLessonIds) {
|
||||
const finished = await finishVoiceLesson(lessonId)
|
||||
await this.applyFinishedLesson(finished)
|
||||
}
|
||||
await this.loadActiveSession()
|
||||
await runWithSessionRefresh(
|
||||
async () => {
|
||||
for (const segment of failedSegments) await retryVoiceSegment(segment.id)
|
||||
},
|
||||
async () => this.loadActiveSession()
|
||||
)
|
||||
logVoiceOperation('transcription_retry_submitted', {
|
||||
feedbackSessionId: session.id,
|
||||
segmentIds: failedSegments.map((segment) => segment.id)
|
||||
})
|
||||
wx.showToast({ title: '已重新提交,正在转录', icon: 'none' })
|
||||
} catch (error) {
|
||||
wx.showToast({ title: getApiErrorMessage(error), icon: 'none' })
|
||||
logVoiceOperation('transcription_retry_failed', {
|
||||
feedbackSessionId: session.id,
|
||||
requestId: getApiRequestId(error)
|
||||
})
|
||||
this.showApiError(error)
|
||||
} finally {
|
||||
this.setData({ voiceBusy: false })
|
||||
this.syncVoicePresentation(this.data.session)
|
||||
this.scheduleVoicePolling(this.data.session)
|
||||
}
|
||||
},
|
||||
|
||||
confirmDiscardFailedSegments(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
wx.showModal({
|
||||
title: '放弃失败录音',
|
||||
content: '失败片段将被删除,并立即开始新的录音。此操作无法撤销。',
|
||||
confirmText: '放弃重录',
|
||||
confirmColor: '#d13f3a',
|
||||
success: (result) => resolve(result.confirm),
|
||||
fail: () => resolve(false)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
async discardFailedAndRecord() {
|
||||
if (this.data.recording || this.data.voiceBusy) return
|
||||
const session = this.data.session
|
||||
if (!session) return
|
||||
const failedSegments = getFailedVoiceSegments(session)
|
||||
if (failedSegments.length === 0 || !(await this.confirmDiscardFailedSegments())) return
|
||||
|
||||
let discarded = false
|
||||
this.setData({
|
||||
voiceBusy: true,
|
||||
voiceStatus: `正在放弃${failedSegments.length}段失败录音`,
|
||||
primaryActionText: '正在处理',
|
||||
errorMessage: '',
|
||||
errorRequestId: ''
|
||||
})
|
||||
try {
|
||||
await runWithSessionRefresh(
|
||||
async () => {
|
||||
for (const segment of failedSegments) await discardVoiceSegment(segment.id)
|
||||
},
|
||||
async () => this.loadActiveSession()
|
||||
)
|
||||
discarded = true
|
||||
logVoiceOperation('failed_transcription_discarded', {
|
||||
feedbackSessionId: session.id,
|
||||
segmentIds: failedSegments.map((segment) => segment.id)
|
||||
})
|
||||
} catch (error) {
|
||||
this.showApiError(error)
|
||||
} finally {
|
||||
this.setData({ voiceBusy: false })
|
||||
this.syncVoicePresentation(this.data.session)
|
||||
}
|
||||
if (discarded) await this.toggleRecording()
|
||||
},
|
||||
|
||||
async saveFeedback() {
|
||||
this.clearDraftSaveTimer()
|
||||
const session = this.data.session
|
||||
if (session && session.failedSegmentCount > 0) {
|
||||
await this.retryFailedSegments()
|
||||
return
|
||||
}
|
||||
if (session && session.processingSegmentCount > 0) {
|
||||
wx.showToast({ title: '录音仍在转写,请稍候', icon: 'none' })
|
||||
this.scheduleVoicePolling(session)
|
||||
@@ -718,7 +816,7 @@ Page({
|
||||
wx.showToast({ title: '反馈已保存', icon: 'success' })
|
||||
setTimeout(() => wx.switchTab({ url: '/pages/records/index' }), 450)
|
||||
} catch (error) {
|
||||
wx.showToast({ title: getApiErrorMessage(error), icon: 'none' })
|
||||
this.showApiError(error)
|
||||
} finally {
|
||||
this.setData({ saving: false })
|
||||
}
|
||||
@@ -727,17 +825,25 @@ Page({
|
||||
async generateFeedback() {
|
||||
const session = this.data.session
|
||||
if (!session) return
|
||||
this.setData({ generating: true, voiceBusy: true, voiceStatus: '正在生成反馈' })
|
||||
this.setData({
|
||||
generating: true,
|
||||
voiceBusy: true,
|
||||
voiceStatus: '正在生成反馈',
|
||||
errorMessage: '',
|
||||
errorRequestId: ''
|
||||
})
|
||||
try {
|
||||
const result = await generateFeedbackFromVoice(session.id, this.data.draft.content)
|
||||
const result = await runWithSessionRefresh(
|
||||
async () => generateFeedbackFromVoice(session.id, this.data.draft.content),
|
||||
async () => this.loadActiveSession()
|
||||
)
|
||||
this.setData({
|
||||
'draft.content': result.content,
|
||||
contentLength: result.content.length
|
||||
})
|
||||
await this.loadActiveSession()
|
||||
wx.showToast({ title: '反馈已生成,可继续修改', icon: 'none' })
|
||||
} catch (error) {
|
||||
wx.showToast({ title: getApiErrorMessage(error), icon: 'none' })
|
||||
this.showApiError(error)
|
||||
} finally {
|
||||
this.setData({ generating: false, voiceBusy: false })
|
||||
this.syncVoicePresentation(this.data.session)
|
||||
|
||||
@@ -56,10 +56,18 @@
|
||||
<text wx:if="{{recording}}" class="recording-time">{{recordingTime}}</text>
|
||||
<text wx:elif="{{voiceActionable}}" class="voice-state-arrow">›</text>
|
||||
</view>
|
||||
<view wx:if="{{session && session.failedSegmentCount > 0}}" class="voice-failure-recovery">
|
||||
<text class="voice-failure-hint">{{voiceFailureHint}}</text>
|
||||
<button
|
||||
class="voice-discard-button"
|
||||
disabled="{{saving || generating || recording || voiceBusy}}"
|
||||
bindtap="discardFailedAndRecord"
|
||||
>放弃并重新录制</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button class="save-feedback primary-button" loading="{{saving || generating}}" disabled="{{saving || generating || recording || voiceBusy || (session && session.processingSegmentCount > 0)}}" bindtap="saveFeedback">{{primaryActionText}}</button>
|
||||
<button class="save-feedback primary-button" loading="{{saving || generating}}" disabled="{{saving || generating || recording || voiceBusy || (session && session.processingSegmentCount > 0 && session.failedSegmentCount === 0)}}" bindtap="saveFeedback">{{primaryActionText}}</button>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{!loading && profiles.length === 0}}" class="profiles-hint">
|
||||
|
||||
@@ -165,6 +165,45 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.voice-failure-recovery {
|
||||
width: 100%;
|
||||
padding: 16rpx;
|
||||
border-left: 6rpx solid #d13f3a;
|
||||
border-radius: 8rpx;
|
||||
background: #fff7f6;
|
||||
}
|
||||
|
||||
.voice-failure-hint {
|
||||
display: block;
|
||||
color: #6e3f3c;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.voice-discard-button {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
height: 56rpx;
|
||||
margin: 12rpx 0 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #b8322d;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
line-height: 56rpx;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.voice-discard-button::after {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.voice-discard-button[disabled] {
|
||||
color: #aeaeb2;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.save-feedback {
|
||||
width: calc(100% - 48rpx);
|
||||
min-width: calc(100% - 48rpx);
|
||||
|
||||
18
pages/feedback/voice-actions.ts
Normal file
18
pages/feedback/voice-actions.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export async function runWithSessionRefresh<T>(
|
||||
action: () => Promise<T>,
|
||||
refreshSession: () => Promise<void>
|
||||
): Promise<T> {
|
||||
let result: T
|
||||
try {
|
||||
result = await action()
|
||||
} catch (error) {
|
||||
try {
|
||||
await refreshSession()
|
||||
} catch {
|
||||
// Preserve the actionable API error and its request ID.
|
||||
}
|
||||
throw error
|
||||
}
|
||||
await refreshSession()
|
||||
return result
|
||||
}
|
||||
39
pages/feedback/voice-state.ts
Normal file
39
pages/feedback/voice-state.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { FeedbackSession, VoiceAudioSegment } from '../../utils/types'
|
||||
|
||||
export type VoicePrimaryAction = 'save' | 'retry' | 'processing' | 'generate'
|
||||
|
||||
export function getVoicePrimaryAction(session: FeedbackSession | null): VoicePrimaryAction {
|
||||
if (!session || session.lessonCount === 0) return 'save'
|
||||
if (session.failedSegmentCount > 0) return 'retry'
|
||||
if (session.processingSegmentCount > 0) return 'processing'
|
||||
return session.needsGeneration ? 'generate' : 'save'
|
||||
}
|
||||
|
||||
export function getVoicePrimaryActionText(
|
||||
action: VoicePrimaryAction,
|
||||
failedSegmentCount = 0
|
||||
): string {
|
||||
if (action === 'retry') return `重试转录(${failedSegmentCount})`
|
||||
if (action === 'processing') return '转录中'
|
||||
if (action === 'generate') return '生成反馈'
|
||||
return '保存反馈'
|
||||
}
|
||||
|
||||
export function getFailedVoiceSegments(session: FeedbackSession): VoiceAudioSegment[] {
|
||||
return session.lessons.flatMap((lesson) =>
|
||||
lesson.segments.filter((segment) => segment.status === 'failed')
|
||||
)
|
||||
}
|
||||
|
||||
export function getVoiceFailureHint(session: FeedbackSession): string {
|
||||
const failedSegments = getFailedVoiceSegments(session)
|
||||
const hasNoClearSpeech = failedSegments.some((segment) => {
|
||||
const message = segment.errorMessage?.toLowerCase() || ''
|
||||
return message.includes('no clear speech') || message.includes('未识别到清晰语音')
|
||||
})
|
||||
const count = session.failedSegmentCount
|
||||
if (hasNoClearSpeech) {
|
||||
return `${count}段录音未识别到清晰语音,可能是录音过短、静音或环境噪声较大。可重试,或放弃后重新录制。`
|
||||
}
|
||||
return `${count}段录音转录失败,可重试,或放弃后重新录制。`
|
||||
}
|
||||
Reference in New Issue
Block a user