diff --git a/docs/incidents/2026-07-22-miniprogram-voice-retry.md b/docs/incidents/2026-07-22-miniprogram-voice-retry.md index a19b3c1..bf66d65 100644 --- a/docs/incidents/2026-07-22-miniprogram-voice-retry.md +++ b/docs/incidents/2026-07-22-miniprogram-voice-retry.md @@ -102,7 +102,8 @@ API 当前只有通用 HTTP 请求日志。重试请求未到达服务端时, - 原生弹窗确认文案缩短为“重试转录”,符合最多 4 个字符的约束。 - 重试开始后立即锁定操作并显示处理中状态;请求结束后无论成功或失败都刷新活动会话并恢复轮询。 - 生成请求结束后始终刷新活动会话;API 错误消息和 `X-Request-Id` 保存在页面错误区域,可复制查询。 -- 页面增加失败原因说明和“放弃并重新录制”;后端删除失败片段时会重算课堂状态,空课堂会一并删除。 +- 页面增加失败原因说明和“放弃失败录音”;放弃操作不会自动开始录音,后端删除失败片段时会重算课堂状态,空课堂会一并删除。 +- 语音记录区分“已加入输入框”“待生成反馈”和“已生成反馈”,避免把待汇总的长录音笼统显示为“已就绪”。 - API 增加 `transcription_retry_requested`、`feedback_generation_rejected` 和 `failed_transcription_discarded` 业务日志;转录 worker 将无清晰语音分类为 `no_clear_speech`。 - 前端自动化测试覆盖失败优先级、重试状态流转、无清晰语音提示和 API 失败后的会话刷新;后端测试覆盖错误分类和 OpenAPI 路由完整性。 diff --git a/pages/feedback/index.ts b/pages/feedback/index.ts index 2e04930..dc939aa 100644 --- a/pages/feedback/index.ts +++ b/pages/feedback/index.ts @@ -25,6 +25,7 @@ import { runWithSessionRefresh } from './voice-actions' import { getFailedVoiceSegments, getVoiceFailureHint, + getVoiceLessonStatusText, getVoicePrimaryAction, getVoicePrimaryActionText } from './voice-state' @@ -267,6 +268,7 @@ Page({ let suffix = '已就绪' if (session.failedSegmentCount > 0) suffix = `${session.failedSegmentCount}段转录失败` else if (session.processingSegmentCount > 0) suffix = '正在处理' + else if (session.needsGeneration) suffix = '待生成反馈' const primaryAction = getVoicePrimaryAction(session) this.setData({ voiceStatus: `${prefix} · ${suffix}`, @@ -668,8 +670,7 @@ Page({ const session = this.data.session if (!session || session.lessonCount === 0) return const lines = session.lessons.map((lesson) => { - const status = lesson.status === 'ready' ? '已就绪' : lesson.status === 'failed' ? '转录失败' : '处理中' - return `第${lesson.sequence}节 · ${formatVoiceDuration(lesson.durationMs)} · ${status}` + return `第${lesson.sequence}节 · ${formatVoiceDuration(lesson.durationMs)} · ${getVoiceLessonStatusText(lesson)}` }) const hasFailures = session.failedSegmentCount > 0 wx.showModal({ @@ -726,11 +727,12 @@ Page({ }, confirmDiscardFailedSegments(): Promise { + const failedSegmentCount = this.data.session?.failedSegmentCount || 0 return new Promise((resolve) => { wx.showModal({ title: '放弃失败录音', - content: '失败片段将被删除,并立即开始新的录音。此操作无法撤销。', - confirmText: '放弃重录', + content: `将删除${failedSegmentCount}段失败录音,其余录音和反馈内容不受影响。此操作无法撤销。`, + confirmText: '确认放弃', confirmColor: '#d13f3a', success: (result) => resolve(result.confirm), fail: () => resolve(false) @@ -738,14 +740,13 @@ Page({ }) }, - async discardFailedAndRecord() { + async discardFailedSegments() { 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}段失败录音`, @@ -760,18 +761,17 @@ Page({ }, async () => this.loadActiveSession() ) - discarded = true logVoiceOperation('failed_transcription_discarded', { feedbackSessionId: session.id, segmentIds: failedSegments.map((segment) => segment.id) }) + wx.showToast({ title: '已放弃失败录音', icon: 'none' }) } catch (error) { this.showApiError(error) } finally { this.setData({ voiceBusy: false }) this.syncVoicePresentation(this.data.session) } - if (discarded) await this.toggleRecording() }, async saveFeedback() { diff --git a/pages/feedback/index.wxml b/pages/feedback/index.wxml index 83907b3..23d712c 100644 --- a/pages/feedback/index.wxml +++ b/pages/feedback/index.wxml @@ -61,8 +61,8 @@ + bindtap="discardFailedSegments" + >放弃失败录音 diff --git a/pages/feedback/voice-state.ts b/pages/feedback/voice-state.ts index 642e447..947a219 100644 --- a/pages/feedback/voice-state.ts +++ b/pages/feedback/voice-state.ts @@ -1,4 +1,4 @@ -import type { FeedbackSession, VoiceAudioSegment } from '../../utils/types' +import type { FeedbackSession, VoiceAudioSegment, VoiceLesson } from '../../utils/types' export type VoicePrimaryAction = 'save' | 'retry' | 'processing' | 'generate' @@ -25,6 +25,15 @@ export function getFailedVoiceSegments(session: FeedbackSession): VoiceAudioSegm ) } +export function getVoiceLessonStatusText(lesson: VoiceLesson): string { + if (lesson.status === 'failed') return '转录失败' + if (lesson.status === 'recording') return '录音中' + if (lesson.status === 'processing') return '处理中' + if (lesson.appliedDirectly) return '已加入输入框' + if (lesson.includedInGeneration) return '已生成反馈' + return '待生成反馈' +} + export function getVoiceFailureHint(session: FeedbackSession): string { const failedSegments = getFailedVoiceSegments(session) const hasNoClearSpeech = failedSegments.some((segment) => { @@ -33,7 +42,7 @@ export function getVoiceFailureHint(session: FeedbackSession): string { }) const count = session.failedSegmentCount if (hasNoClearSpeech) { - return `${count}段录音未识别到清晰语音,可能是录音过短、静音或环境噪声较大。可重试,或放弃后重新录制。` + return `${count}段录音未识别到清晰语音,可能是录音过短、静音或环境噪声较大。可重试,或放弃该失败录音。` } - return `${count}段录音转录失败,可重试,或放弃后重新录制。` + return `${count}段录音转录失败,可重试,或放弃该失败录音。` } diff --git a/tests/voice-state.test.js b/tests/voice-state.test.js index ea7e754..c2084eb 100644 --- a/tests/voice-state.test.js +++ b/tests/voice-state.test.js @@ -3,6 +3,7 @@ const test = require('node:test') const { getVoiceFailureHint, + getVoiceLessonStatusText, getVoicePrimaryAction, getVoicePrimaryActionText } = require('../.test-dist/pages/feedback/voice-state.js') @@ -65,7 +66,19 @@ test('no clear speech failures provide an actionable explanation', () => { const hint = getVoiceFailureHint(value) assert.match(hint, /录音过短、静音或环境噪声/) - assert.match(hint, /放弃后重新录制/) + assert.match(hint, /放弃该失败录音/) +}) + +test('ready lessons explain whether their transcript is visible or pending generation', () => { + const lesson = { + status: 'ready', + appliedDirectly: false, + includedInGeneration: false + } + + assert.equal(getVoiceLessonStatusText(lesson), '待生成反馈') + assert.equal(getVoiceLessonStatusText({ ...lesson, appliedDirectly: true }), '已加入输入框') + assert.equal(getVoiceLessonStatusText({ ...lesson, includedInGeneration: true }), '已生成反馈') }) test('session refresh runs after a failed API action', async () => {