diff --git a/docs/incidents/2026-07-22-miniprogram-voice-retry.md b/docs/incidents/2026-07-22-miniprogram-voice-retry.md index bf66d65..e128a83 100644 --- a/docs/incidents/2026-07-22-miniprogram-voice-retry.md +++ b/docs/incidents/2026-07-22-miniprogram-voice-retry.md @@ -104,6 +104,8 @@ API 当前只有通用 HTTP 请求日志。重试请求未到达服务端时, - 生成请求结束后始终刷新活动会话;API 错误消息和 `X-Request-Id` 保存在页面错误区域,可复制查询。 - 页面增加失败原因说明和“放弃失败录音”;放弃操作不会自动开始录音,后端删除失败片段时会重算课堂状态,空课堂会一并删除。 - 语音记录区分“已加入输入框”“待生成反馈”和“已生成反馈”,避免把待汇总的长录音笼统显示为“已就绪”。 +- 语音记录进一步调整为可复用素材列表:每节可查看完整转写、展开或收起、按需加入或重新加入输入框;短录音转写完成后不再自动修改正文。 +- 反馈输入框支持确认后清空,语音记录仍保留;单节内容加入后若超过 2000 字会整体拒绝并提示,不再截断后写入。 - 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 dc939aa..7f159fb 100644 --- a/pages/feedback/index.ts +++ b/pages/feedback/index.ts @@ -19,6 +19,7 @@ import type { FeedbackDraft, FeedbackSession, StudentProfile, + VoiceLesson, VoiceLessonFinished } from '../../utils/types' import { runWithSessionRefresh } from './voice-actions' @@ -32,6 +33,7 @@ import { type PickerEvent = { detail: { value: string | number } } type InputEvent = { detail: { value: string } } +type LessonActionEvent = { currentTarget: { dataset: { lessonId: string } } } interface PendingVoiceUpload { lessonId: string @@ -40,9 +42,21 @@ interface PendingVoiceUpload { filePath: string } +interface VoiceLessonView { + id: string + sequence: number + durationText: string + statusText: string + transcript: string + transcriptLength: number + transcriptExpandable: boolean + canAdd: boolean + addActionText: string + emptyText: string +} + const MAX_CONTENT_LENGTH = 2000 const RECORDING_SEGMENT_DURATION_MS = 8 * 60 * 1000 -const SHORT_RECORDING_DURATION_MS = 60 * 1000 const VOICE_POLL_INTERVAL_MS = 3000 const PENDING_UPLOADS_KEY = 'teaching-feedback-pending-voice-uploads-v1' @@ -107,6 +121,37 @@ function wait(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)) } +function buildVoiceLessonViews(session: FeedbackSession | null, content: string): VoiceLessonView[] { + if (!session) return [] + return session.lessons.map((lesson: VoiceLesson) => { + const transcript = lesson.transcript.trim() + const isInDraft = Boolean(transcript) && content.includes(transcript) + let emptyText = '转录完成后可查看内容' + if (lesson.status === 'failed') { + emptyText = lesson.segments.find((segment) => segment.status === 'failed')?.errorMessage + || '该节转录失败' + } else if (lesson.status === 'ready') { + emptyText = '该节没有可用的转写内容' + } + return { + id: lesson.id, + sequence: lesson.sequence, + durationText: formatVoiceDuration(lesson.durationMs), + statusText: getVoiceLessonStatusText(lesson, isInDraft), + transcript, + transcriptLength: transcript.length, + transcriptExpandable: transcript.length > 120, + canAdd: lesson.status === 'ready' && Boolean(transcript) && !isInDraft, + addActionText: isInDraft + ? '已在输入框' + : lesson.appliedDirectly + ? '重新加入' + : '加入输入框', + emptyText + } + }) +} + function logVoiceOperation(event: string, fields: Record): void { console.info('voice_operation', { event, ...fields }) } @@ -129,6 +174,9 @@ Page({ voiceStatus: '语音录入', voiceFailureHint: '', voiceActionable: false, + voiceDetailsExpanded: false, + expandedVoiceLessonId: '', + voiceLessonViews: [] as VoiceLessonView[], primaryActionText: '保存反馈', errorMessage: '', errorRequestId: '' @@ -259,6 +307,9 @@ Page({ voiceStatus: '语音录入', voiceFailureHint: '', voiceActionable: false, + voiceDetailsExpanded: false, + expandedVoiceLessonId: '', + voiceLessonViews: [], primaryActionText: '保存反馈' }) return @@ -274,6 +325,7 @@ Page({ voiceStatus: `${prefix} · ${suffix}`, voiceFailureHint: session.failedSegmentCount > 0 ? getVoiceFailureHint(session) : '', voiceActionable: true, + voiceLessonViews: buildVoiceLessonViews(session, this.data.draft.content), primaryActionText: getVoicePrimaryActionText(primaryAction, session.failedSegmentCount) }) }, @@ -313,20 +365,6 @@ Page({ if (!pageVisible || profileId !== this.data.draft.profileId) return this.setData({ session, 'draft.feedbackSessionId': session?.id || null }) this.syncVoicePresentation(session) - if (!session) return - - const readyShortLessons = session.lessons.filter( - (lesson) => - lesson.status === 'ready' && - lesson.durationMs <= SHORT_RECORDING_DURATION_MS && - !lesson.appliedDirectly && - !lesson.includedInGeneration - ) - for (const lesson of readyShortLessons) { - const finished = await finishVoiceLesson(lesson.id) - await this.applyFinishedLesson(finished) - } - if (readyShortLessons.length > 0) await this.loadActiveSession(profileId) } catch (error) { console.warn('Failed to refresh voice transcription status', error) this.showApiError(error, false) @@ -357,6 +395,9 @@ Page({ 'draft.content': '', contentLength: 0, voiceActionable: false, + voiceDetailsExpanded: false, + expandedVoiceLessonId: '', + voiceLessonViews: [], primaryActionText: '保存反馈' }) void this.loadActiveSession(profileId) @@ -369,10 +410,38 @@ Page({ onContentInput(event: InputEvent) { const content = event.detail.value - this.setData({ 'draft.content': content, contentLength: content.length }) + this.setData({ + 'draft.content': content, + contentLength: content.length, + voiceLessonViews: buildVoiceLessonViews(this.data.session, content) + }) this.scheduleDraftSave() }, + confirmClearFeedbackContent(): Promise { + return new Promise((resolve) => { + wx.showModal({ + title: '清空反馈内容', + content: '手动填写和已加入的语音文字都会从输入框移除,语音记录仍会保留。', + confirmText: '确认清空', + confirmColor: '#d13f3a', + success: (result) => resolve(result.confirm), + fail: () => resolve(false) + }) + }) + }, + + async clearFeedbackContent() { + if (!this.data.draft.content || !(await this.confirmClearFeedbackContent())) return + this.clearDraftSaveTimer() + this.setData({ + 'draft.content': '', + contentLength: 0, + voiceLessonViews: buildVoiceLessonViews(this.data.session, '') + }) + await this.persistSessionDraft() + }, + scheduleDraftSave() { this.clearDraftSaveTimer() if (!this.data.session) return @@ -633,19 +702,7 @@ Page({ wx.showToast({ title: '部分录音转录失败,点击状态可重试', icon: 'none' }) return } - if (!finished.canApplyDirectly) { - wx.showToast({ title: '录音已就绪,可生成反馈', icon: 'none' }) - return - } - - const merged = appendTranscript(this.data.draft.content, finished.transcript) - this.setData({ 'draft.content': merged.content, contentLength: merged.content.length }) - if (!merged.truncated) { - await markVoiceLessonApplied(finished.id, merged.content) - wx.showToast({ title: '语音已转为文字', icon: 'none' }) - } else { - wx.showToast({ title: '内容已达上限,请生成汇总反馈', icon: 'none' }) - } + wx.showToast({ title: '转录已就绪,可在语音记录中查看', icon: 'none' }) }, startRecordingTimer() { @@ -661,7 +718,7 @@ Page({ recordingTimer = null }, - async openVoiceDetails() { + async toggleVoiceDetails() { if (this.data.recording || this.data.voiceBusy) return if (failedLocalUploads.length > 0) { await this.retryPendingUploads() @@ -669,22 +726,57 @@ Page({ } const session = this.data.session if (!session || session.lessonCount === 0) return - const lines = session.lessons.map((lesson) => { - return `第${lesson.sequence}节 · ${formatVoiceDuration(lesson.durationMs)} · ${getVoiceLessonStatusText(lesson)}` + this.setData({ + voiceDetailsExpanded: !this.data.voiceDetailsExpanded, + expandedVoiceLessonId: '' }) - const hasFailures = session.failedSegmentCount > 0 - wx.showModal({ - title: '语音记录', - content: lines.join('\n'), - showCancel: hasFailures, - cancelText: '关闭', - confirmText: hasFailures ? '重试转录' : '知道了', - success: (result) => { - if (hasFailures && result.confirm) void this.retryFailedSegments() - } + }, + + toggleVoiceLessonTranscript(event: LessonActionEvent) { + const lessonId = event.currentTarget.dataset.lessonId + this.setData({ + expandedVoiceLessonId: this.data.expandedVoiceLessonId === lessonId ? '' : lessonId }) }, + async addVoiceLessonToDraft(event: LessonActionEvent) { + if (this.data.recording || this.data.voiceBusy) return + const session = this.data.session + const lessonId = event.currentTarget.dataset.lessonId + const lesson = session?.lessons.find((item) => item.id === lessonId) + const transcript = lesson?.transcript.trim() || '' + if (!session || !lesson || lesson.status !== 'ready' || !transcript) return + if (this.data.draft.content.includes(transcript)) { + wx.showToast({ title: '该节内容已在输入框中', icon: 'none' }) + return + } + + const merged = appendTranscript(this.data.draft.content, transcript) + if (merged.truncated) { + wx.showModal({ + title: '内容超出上限', + content: `加入第${lesson.sequence}节后将超过2000字,请先清空或删减输入框内容。`, + showCancel: false, + confirmText: '知道了' + }) + return + } + + this.setData({ voiceBusy: true, errorMessage: '', errorRequestId: '' }) + try { + await runWithSessionRefresh( + async () => markVoiceLessonApplied(lesson.id, merged.content), + async () => this.loadActiveSession() + ) + wx.showToast({ title: `第${lesson.sequence}节已加入`, icon: 'none' }) + } catch (error) { + this.showApiError(error) + } finally { + this.setData({ voiceBusy: false }) + this.syncVoicePresentation(this.data.session) + } + }, + async retryFailedSegments() { const session = this.data.session if (!session) return diff --git a/pages/feedback/index.wxml b/pages/feedback/index.wxml index 23d712c..0c74d96 100644 --- a/pages/feedback/index.wxml +++ b/pages/feedback/index.wxml @@ -30,7 +30,15 @@