import { applyFeedbackSummaryRun, copyApiRequestId, createFeedbackRecord, createFeedbackSummaryRun, createVoiceLesson, discardVoiceSegment, ensureFeedbackSession, finishVoiceLesson, getActiveFeedbackSession, getApiErrorMessage, getApiRequestId, getFeedbackSummaryRun, listProfiles, markVoiceLessonApplied, retryVoiceSegment, updateFeedbackSession, uploadVoiceSegment } from '../../utils/api' import type { FeedbackDraft, FeedbackSession, FeedbackSummaryRun, StudentProfile, VoiceLesson, VoiceLessonFinished } from '../../utils/types' import { runWithSessionRefresh } from './voice-actions' import { getFailedVoiceSegments, getVoiceFailureHint, getVoiceLessonStatusText, getVoicePrimaryAction, getVoicePrimaryActionText } from './voice-state' type PickerEvent = { detail: { value: string | number } } type InputEvent = { detail: { value: string } } type CheckboxGroupEvent = { detail: { value: string[] } } type LessonActionEvent = { currentTarget: { dataset: { lessonId: string } } } interface PendingVoiceUpload { lessonId: string sequence: number durationMs: number filePath: string } interface VoiceLessonView { id: string sequence: number durationText: string statusText: string transcript: string transcriptLength: number transcriptExpandable: boolean canAdd: boolean summaryEligible: boolean summarySelected: boolean addActionText: string emptyText: string } const MAX_CONTENT_LENGTH = 2000 const RECORDING_SEGMENT_DURATION_MS = 8 * 60 * 1000 const VOICE_POLL_INTERVAL_MS = 3000 const SUMMARY_POLL_INTERVAL_MS = 2000 const PENDING_UPLOADS_KEY = 'teaching-feedback-pending-voice-uploads-v1' const recorderManager = wx.getRecorderManager() let recordingTimer: number | null = null let draftSaveTimer: number | null = null let draftSaveQueue: Promise = Promise.resolve() let voicePollTimer: number | null = null let summaryPollTimer: number | null = null let voicePollBusy = false let summaryPollBusy = false let pageVisible = false let lessonStartedAt = 0 let segmentStartedAt = 0 let currentLessonId: string | null = null let currentSegmentSequence = 0 let finishRequested = false let pendingUploads: Promise[] = [] let failedLocalUploads: PendingVoiceUpload[] = [] function today(): string { const now = new Date() const year = now.getFullYear() const month = String(now.getMonth() + 1).padStart(2, '0') const day = String(now.getDate()).padStart(2, '0') return `${year}-${month}-${day}` } function buildDraft(): FeedbackDraft { return { profileId: null, feedbackDate: today(), content: '', feedbackSessionId: null } } function appendTranscript(content: string, transcript: string): { content: string; truncated: boolean } { const normalizedTranscript = transcript.trim() const separator = content && !/\s$/.test(content) ? '\n' : '' const combined = `${content}${separator}${normalizedTranscript}` return { content: combined.slice(0, MAX_CONTENT_LENGTH), truncated: combined.length > MAX_CONTENT_LENGTH } } function formatRecordingTime(milliseconds: number): string { const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000)) const hours = Math.floor(totalSeconds / 3600) const minutes = Math.floor((totalSeconds % 3600) / 60) const seconds = totalSeconds % 60 const minuteText = String(minutes).padStart(2, '0') const secondText = String(seconds).padStart(2, '0') return hours > 0 ? `${String(hours).padStart(2, '0')}:${minuteText}:${secondText}` : `${minuteText}:${secondText}` } function formatVoiceDuration(milliseconds: number): string { const totalMinutes = Math.floor(milliseconds / 60000) if (totalMinutes < 1) return `${Math.max(1, Math.round(milliseconds / 1000))}秒` return `${totalMinutes}分钟` } function wait(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)) } function buildVoiceLessonViews( session: FeedbackSession | null, content: string, excludedSummaryLessonIds: string[] = [] ): VoiceLessonView[] { if (!session) return [] const excluded = new Set(excludedSummaryLessonIds) return session.lessons.map((lesson: VoiceLesson) => { const transcript = lesson.transcript.trim() const isInDraft = Boolean(transcript) && content.includes(transcript) const summaryEligible = lesson.status === 'ready' && Boolean(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, summaryEligible, summarySelected: summaryEligible && !excluded.has(lesson.id), addActionText: isInDraft ? '已在输入框' : lesson.appliedDirectly ? '重新加入' : '加入输入框', emptyText } }) } function logVoiceOperation(event: string, fields: Record): void { console.info('voice_operation', { event, ...fields }) } function createSummaryIdempotencyKey(sessionId: string): string { return `${sessionId}-${Date.now()}-${Math.random().toString(16).slice(2)}` } function getSummaryStatusText(run: FeedbackSummaryRun | null): string { if (!run) return '' if (run.status === 'queued') return '已提交,等待生成' if (run.status === 'processing') return '正在整理所选录音' if (run.status === 'ready') return `总结已生成 · ${run.characterCount || 0}字` if (run.status === 'applied') return `已替换输入框 · ${run.characterCount || 0}字` return run.errorMessage || '总结生成失败,请重新生成' } Page({ data: { profiles: [] as StudentProfile[], profileOptions: ['不关联学生'], selectedProfileIndex: 0, draft: buildDraft(), session: null as FeedbackSession | null, contentLength: 0, loading: false, saving: false, summaryBusy: false, summaryRun: null as FeedbackSummaryRun | null, summaryStatusText: '', summaryActionText: '生成总结', excludedSummaryLessonIds: [] as string[], selectedSummaryLessonCount: 0, eligibleSummaryLessonCount: 0, allSummaryLessonsSelected: false, voiceBusy: false, recording: false, recordingPaused: false, recordingTime: '00:00', voiceStatus: '语音录入', voiceFailureHint: '', voiceActionable: false, voiceDetailsExpanded: false, expandedVoiceLessonId: '', voiceLessonViews: [] as VoiceLessonView[], primaryActionText: '保存反馈', errorMessage: '', errorRequestId: '' }, onLoad() { failedLocalUploads = (wx.getStorageSync(PENDING_UPLOADS_KEY) as PendingVoiceUpload[]) || [] this.initRecorder() if (failedLocalUploads.length > 0) { currentLessonId = failedLocalUploads[0].lessonId this.setData({ voiceStatus: '上传失败,点击重试', voiceActionable: true }) } }, onShow() { pageVisible = true this.getTabBar()?.setData({ selected: 0 }) void this.loadProfiles() }, onHide() { pageVisible = false this.clearVoicePollTimer() this.clearSummaryPollTimer() if (this.data.recording) this.stopRecording() }, onUnload() { pageVisible = false this.clearRecordingTimer() this.clearDraftSaveTimer() this.clearVoicePollTimer() this.clearSummaryPollTimer() if (this.data.recording) recorderManager.stop() }, initRecorder() { recorderManager.onStart(() => { segmentStartedAt = Date.now() this.setData({ voiceBusy: false, recording: true, recordingPaused: false, voiceStatus: '正在录音' }) this.startRecordingTimer() }) recorderManager.onStop((result) => { void this.handleRecorderStop(result) }) recorderManager.onPause(() => { this.setData({ recordingPaused: true, voiceStatus: '录音已暂停' }) }) recorderManager.onResume(() => { this.setData({ recordingPaused: false, voiceStatus: '正在录音' }) }) recorderManager.onInterruptionEnd(() => { if (this.data.recordingPaused) recorderManager.resume() }) recorderManager.onError((error) => { console.warn('RecorderManager error', error) this.clearRecordingTimer() finishRequested = true this.setData({ recording: false, recordingPaused: false, voiceBusy: true, voiceStatus: '正在保留已录内容' }) void this.finalizeCurrentLesson() }) }, async loadProfiles() { this.setData({ loading: true, errorMessage: '', errorRequestId: '' }) try { const profiles = await listProfiles() const matchedProfileIndex = profiles.findIndex((profile) => profile.id === this.data.draft.profileId) const selectedProfileIndex = matchedProfileIndex + 1 const profileId = matchedProfileIndex >= 0 ? profiles[matchedProfileIndex].id : null this.setData({ profiles, profileOptions: ['不关联学生', ...profiles.map((profile) => `${profile.name} · ${profile.subject}`)], selectedProfileIndex, 'draft.profileId': profileId }) await this.loadActiveSession(profileId) } catch (error) { this.setData({ errorMessage: getApiErrorMessage(error), errorRequestId: getApiRequestId(error) }) } finally { this.setData({ loading: false }) } }, async loadActiveSession(profileId?: string | null) { try { const targetProfileId = profileId === undefined ? this.data.draft.profileId : profileId const previousSessionId = this.data.session?.id || null const session = await getActiveFeedbackSession(targetProfileId) if (targetProfileId !== this.data.draft.profileId) return const nextData: Record = { session, 'draft.feedbackSessionId': session?.id || null, summaryRun: session?.latestSummaryRun || null } if ((session?.id || null) !== previousSessionId) { nextData.excludedSummaryLessonIds = [] } if (session) { nextData['draft.feedbackDate'] = session.feedbackDate nextData['draft.content'] = session.content nextData.contentLength = session.content.length } this.setData(nextData) this.syncVoicePresentation(session) this.syncSummaryRun(session?.latestSummaryRun || null) this.scheduleVoicePolling(session) } catch (error) { this.setData({ errorMessage: getApiErrorMessage(error), errorRequestId: getApiRequestId(error) }) } }, syncVoicePresentation(session: FeedbackSession | null) { if (this.data.recording || this.data.voiceBusy) return if (failedLocalUploads.length > 0) { this.setData({ voiceStatus: '上传失败,点击重试', voiceActionable: true }) return } if (!session || session.lessonCount === 0) { this.setData({ voiceStatus: '语音录入', voiceFailureHint: '', voiceActionable: false, voiceDetailsExpanded: false, expandedVoiceLessonId: '', voiceLessonViews: [], selectedSummaryLessonCount: 0, eligibleSummaryLessonCount: 0, allSummaryLessonsSelected: false, primaryActionText: '保存反馈' }) return } const prefix = `${session.lessonCount}节 · ${formatVoiceDuration(session.totalDurationMs)}` let suffix = '已就绪' if (session.failedSegmentCount > 0) suffix = `${session.failedSegmentCount}段转录失败` else if (session.processingSegmentCount > 0) suffix = '正在处理' else if (session.needsGeneration) suffix = '可生成总结' const primaryAction = getVoicePrimaryAction(session) const voiceLessonViews = buildVoiceLessonViews( session, this.data.draft.content, this.data.excludedSummaryLessonIds ) const eligibleSummaryLessonCount = voiceLessonViews.filter((lesson) => lesson.summaryEligible).length const selectedSummaryLessonCount = voiceLessonViews.filter((lesson) => lesson.summarySelected).length this.setData({ voiceStatus: `${prefix} · ${suffix}`, voiceFailureHint: session.failedSegmentCount > 0 ? getVoiceFailureHint(session) : '', voiceActionable: true, voiceLessonViews, eligibleSummaryLessonCount, selectedSummaryLessonCount, allSummaryLessonsSelected: eligibleSummaryLessonCount > 0 && selectedSummaryLessonCount === eligibleSummaryLessonCount, primaryActionText: getVoicePrimaryActionText(primaryAction, session.failedSegmentCount) }) }, syncSummaryRun(run: FeedbackSummaryRun | null) { this.setData({ summaryRun: run, summaryStatusText: getSummaryStatusText(run), summaryActionText: run && ['ready', 'failed', 'applied'].includes(run.status) ? '重新生成总结' : '生成总结' }) this.scheduleSummaryPolling(run) }, 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 voicePollTimer = setTimeout(() => { voicePollTimer = null void this.pollVoiceSession() }, VOICE_POLL_INTERVAL_MS) as unknown as number }, clearVoicePollTimer() { if (voicePollTimer === null) return clearTimeout(voicePollTimer) voicePollTimer = null }, scheduleSummaryPolling(run: FeedbackSummaryRun | null) { this.clearSummaryPollTimer() if (!pageVisible || !run || (run.status !== 'queued' && run.status !== 'processing')) return summaryPollTimer = setTimeout(() => { summaryPollTimer = null void this.pollFeedbackSummary() }, SUMMARY_POLL_INTERVAL_MS) as unknown as number }, clearSummaryPollTimer() { if (summaryPollTimer === null) return clearTimeout(summaryPollTimer) summaryPollTimer = null }, async pollFeedbackSummary() { const run = this.data.summaryRun if (!pageVisible || !run) return if (summaryPollBusy) { this.scheduleSummaryPolling(run) return } summaryPollBusy = true try { const updated = await getFeedbackSummaryRun(run.id) if (!pageVisible || this.data.summaryRun?.id !== run.id) return this.syncSummaryRun(updated) } catch (error) { console.warn('Failed to refresh feedback summary status', error) this.showApiError(error, false) this.scheduleSummaryPolling(this.data.summaryRun) } finally { summaryPollBusy = false } }, async pollVoiceSession() { if (!pageVisible) return if (voicePollBusy || this.data.recording || this.data.voiceBusy) { this.scheduleVoicePolling(this.data.session) return } voicePollBusy = true const profileId = this.data.draft.profileId try { const session = await getActiveFeedbackSession(profileId) if (!pageVisible || profileId !== this.data.draft.profileId) return this.setData({ session, 'draft.feedbackSessionId': session?.id || null, summaryRun: session?.latestSummaryRun || this.data.summaryRun }) this.syncVoicePresentation(session) if (session?.latestSummaryRun) this.syncSummaryRun(session.latestSummaryRun) } catch (error) { console.warn('Failed to refresh voice transcription status', error) this.showApiError(error, false) } finally { voicePollBusy = false this.scheduleVoicePolling(this.data.session) } }, retryLoad() { void this.loadProfiles() }, copyRequestId() { copyApiRequestId(this.data.errorRequestId) }, onProfileChange(event: PickerEvent) { this.clearDraftSaveTimer() this.clearSummaryPollTimer() const selectedProfileIndex = Number(event.detail.value) const profile = this.data.profiles[selectedProfileIndex - 1] const profileId = profile?.id || null this.setData({ selectedProfileIndex, 'draft.profileId': profileId, session: null, 'draft.feedbackSessionId': null, 'draft.content': '', contentLength: 0, voiceActionable: false, voiceDetailsExpanded: false, expandedVoiceLessonId: '', voiceLessonViews: [], excludedSummaryLessonIds: [], selectedSummaryLessonCount: 0, eligibleSummaryLessonCount: 0, allSummaryLessonsSelected: false, summaryRun: null, summaryStatusText: '', summaryActionText: '生成总结', primaryActionText: '保存反馈' }) void this.loadActiveSession(profileId) }, onDateChange(event: InputEvent) { this.setData({ 'draft.feedbackDate': event.detail.value }) this.scheduleDraftSave() }, onContentInput(event: InputEvent) { const content = event.detail.value this.setData({ 'draft.content': content, contentLength: content.length, voiceLessonViews: buildVoiceLessonViews( this.data.session, content, this.data.excludedSummaryLessonIds ) }) 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, '', this.data.excludedSummaryLessonIds ) }) await this.persistSessionDraft() }, scheduleDraftSave() { this.clearDraftSaveTimer() if (!this.data.session) return draftSaveTimer = setTimeout(() => { void this.persistSessionDraft() }, 800) as unknown as number }, clearDraftSaveTimer() { if (draftSaveTimer === null) return clearTimeout(draftSaveTimer) draftSaveTimer = null }, persistSessionDraft(): Promise { const session = this.data.session if (!session) return Promise.resolve() const sessionId = session.id const feedbackDate = this.data.draft.feedbackDate const content = this.data.draft.content const save = async () => { try { const updated = await updateFeedbackSession(sessionId, feedbackDate, content) if (this.data.session?.id !== sessionId) return this.setData({ session: updated, summaryRun: updated.latestSummaryRun || this.data.summaryRun }) this.syncVoicePresentation(updated) if (updated.latestSummaryRun) this.syncSummaryRun(updated.latestSummaryRun) } catch (error) { console.warn('Failed to auto-save feedback session', error) this.showApiError(error, false) } } draftSaveQueue = draftSaveQueue.then(save, save) return draftSaveQueue }, ensureRecordPermission(): Promise { return new Promise((resolve) => { wx.authorize({ scope: 'scope.record', success: () => resolve(true), fail: () => { wx.showModal({ title: '需要麦克风权限', content: '请在设置中允许使用麦克风,才能录音转录。', confirmText: '去设置', success: (modalResult) => { if (!modalResult.confirm) { resolve(false) return } wx.openSetting({ success: (settingResult) => resolve(settingResult.authSetting['scope.record'] === true), fail: () => resolve(false) }) }, fail: () => resolve(false) }) } }) }) }, async toggleRecording() { if (failedLocalUploads.length > 0) { await this.retryPendingUploads() return } if (this.data.recording) { this.stopRecording() return } this.setData({ voiceBusy: true, voiceStatus: '正在开启麦克风' }) const authorized = await this.ensureRecordPermission() if (!authorized) { this.setData({ voiceBusy: false }) this.syncVoicePresentation(this.data.session) return } try { const session = await ensureFeedbackSession( this.data.draft.profileId, this.data.draft.feedbackDate, this.data.draft.content ) const lesson = await createVoiceLesson(session.id) currentLessonId = lesson.id currentSegmentSequence = 0 finishRequested = false pendingUploads = [] lessonStartedAt = Date.now() this.setData({ session, 'draft.feedbackSessionId': session.id, recordingTime: '00:00' }) this.startNextRecordingSegment() } catch (error) { this.setData({ voiceBusy: false }) this.syncVoicePresentation(this.data.session) this.showApiError(error) } }, startNextRecordingSegment() { currentSegmentSequence += 1 recorderManager.start({ duration: RECORDING_SEGMENT_DURATION_MS, sampleRate: 16000, numberOfChannels: 1, encodeBitRate: 48000, format: 'mp3', audioSource: 'auto' }) }, stopRecording() { if (!this.data.recording) return finishRequested = true this.clearRecordingTimer() this.setData({ recording: false, recordingPaused: false, voiceBusy: true, voiceStatus: '正在整理录音' }) recorderManager.stop() }, async handleRecorderStop(result: WechatMiniprogram.OnStopListenerResult) { const lessonId = currentLessonId if (!lessonId) return const durationMs = Math.max(1, result.duration || Date.now() - segmentStartedAt) const upload = this.queueSegmentUpload({ lessonId, sequence: currentSegmentSequence, durationMs, filePath: result.tempFilePath }) pendingUploads.push(upload) if (!finishRequested) { this.startNextRecordingSegment() return } await this.finalizeCurrentLesson() }, async queueSegmentUpload(segment: PendingVoiceUpload): Promise { try { 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) throw error } }, async uploadWithRetry(segment: PendingVoiceUpload): Promise { let lastError: unknown for (let attempt = 0; attempt < 2; attempt += 1) { try { await uploadVoiceSegment( segment.lessonId, segment.filePath, segment.sequence, segment.durationMs ) return } catch (error) { lastError = error if (attempt === 0) await wait(800) } } throw lastError }, persistRecordingFile(segment: PendingVoiceUpload): Promise { return new Promise((resolve) => { wx.getFileSystemManager().saveFile({ tempFilePath: segment.filePath, success: (result) => resolve({ ...segment, filePath: result.savedFilePath }), fail: () => resolve(segment) }) }) }, async retryPendingUploads() { 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 (error) { lastError = error remaining.push(segment) } } failedLocalUploads = remaining wx.setStorageSync(PENDING_UPLOADS_KEY, failedLocalUploads) if (remaining.length > 0) { this.setData({ voiceBusy: false, voiceStatus: '上传失败,点击重试', voiceActionable: true }) this.showApiError(lastError || new Error('录音上传失败,请检查网络')) return } await this.finalizeCurrentLesson() }, removeSavedFile(filePath: string) { wx.getFileSystemManager().unlink({ filePath, fail: () => undefined }) }, async finalizeCurrentLesson() { const lessonId = currentLessonId if (!lessonId) { this.setData({ voiceBusy: false, recording: false }) return } const results = await Promise.allSettled(pendingUploads) pendingUploads = [] if (results.some((result) => result.status === 'rejected') || failedLocalUploads.length > 0) { this.setData({ recording: false, voiceBusy: false, voiceStatus: '上传失败,点击重试', voiceActionable: true }) return } try { const finished = await finishVoiceLesson(lessonId) await this.applyFinishedLesson(finished) currentLessonId = null currentSegmentSequence = 0 finishRequested = false await this.loadActiveSession() } catch (error) { this.showApiError(error) } finally { this.clearRecordingTimer() this.setData({ recording: false, recordingPaused: false, voiceBusy: false, recordingTime: '00:00' }) this.syncVoicePresentation(this.data.session) } }, async applyFinishedLesson(finished: VoiceLessonFinished) { if (finished.status === 'processing') { wx.showToast({ title: '录音已上传,正在后台转写', icon: 'none' }) return } if (finished.status === 'failed') { wx.showToast({ title: '部分录音转录失败,点击状态可重试', icon: 'none' }) return } wx.showToast({ title: '转录已就绪,可在语音记录中查看', icon: 'none' }) }, startRecordingTimer() { this.clearRecordingTimer() recordingTimer = setInterval(() => { this.setData({ recordingTime: formatRecordingTime(Date.now() - lessonStartedAt) }) }, 1000) as unknown as number }, clearRecordingTimer() { if (recordingTimer === null) return clearInterval(recordingTimer) recordingTimer = null }, async toggleVoiceDetails() { if (this.data.recording || this.data.voiceBusy) return if (failedLocalUploads.length > 0) { await this.retryPendingUploads() return } const session = this.data.session if (!session || session.lessonCount === 0) return this.setData({ voiceDetailsExpanded: !this.data.voiceDetailsExpanded, expandedVoiceLessonId: '' }) }, 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) } }, onSummarySelectionChange(event: CheckboxGroupEvent) { const selectedLessonIds = new Set(event.detail.value) const excludedSummaryLessonIds = this.data.voiceLessonViews .filter((lesson) => lesson.summaryEligible && !selectedLessonIds.has(lesson.id)) .map((lesson) => lesson.id) this.setData({ excludedSummaryLessonIds }) this.syncVoicePresentation(this.data.session) }, toggleAllSummaryLessons() { if (this.data.eligibleSummaryLessonCount === 0) return const excludedSummaryLessonIds = this.data.allSummaryLessonsSelected ? this.data.voiceLessonViews .filter((lesson) => lesson.summaryEligible) .map((lesson) => lesson.id) : [] this.setData({ excludedSummaryLessonIds }) this.syncVoicePresentation(this.data.session) }, async generateFeedbackSummary() { const session = this.data.session if (!session || this.data.summaryBusy || this.data.recording || this.data.voiceBusy) return if (this.data.summaryRun?.status === 'queued' || this.data.summaryRun?.status === 'processing') { wx.showToast({ title: '当前总结仍在生成', icon: 'none' }) return } const lessonIds = this.data.voiceLessonViews .filter((lesson) => lesson.summarySelected) .map((lesson) => lesson.id) if (lessonIds.length === 0) { wx.showToast({ title: '请至少选择一节已就绪录音', icon: 'none' }) return } this.setData({ summaryBusy: true, errorMessage: '', errorRequestId: '' }) try { const run = await createFeedbackSummaryRun( session.id, lessonIds, this.data.draft.content, createSummaryIdempotencyKey(session.id) ) logVoiceOperation('feedback_summary_queued', { feedbackSessionId: session.id, summaryRunId: run.id, lessonIds }) this.syncSummaryRun(run) wx.showToast({ title: '已开始生成总结', icon: 'none' }) } catch (error) { this.showApiError(error) } finally { this.setData({ summaryBusy: false }) } }, confirmApplySummary(): Promise { return new Promise((resolve) => { wx.showModal({ title: '替换反馈内容', content: '将使用预览总结替换当前输入框内容,原始语音记录仍会保留。', confirmText: '确认替换', success: (result) => resolve(result.confirm), fail: () => resolve(false) }) }) }, async applyGeneratedSummary() { const run = this.data.summaryRun if (!run || run.status !== 'ready' || !(await this.confirmApplySummary())) return this.clearDraftSaveTimer() this.setData({ summaryBusy: true, errorMessage: '', errorRequestId: '' }) try { await this.persistSessionDraft() const session = await applyFeedbackSummaryRun(run.id) this.setData({ session, summaryRun: session.latestSummaryRun, 'draft.content': session.content, contentLength: session.content.length }) this.syncVoicePresentation(session) this.syncSummaryRun(session.latestSummaryRun) wx.showToast({ title: '总结已替换输入框', icon: 'none' }) } catch (error) { this.showApiError(error) } finally { this.setData({ summaryBusy: false }) } }, async retryFailedSegments() { const session = this.data.session if (!session) return const failedSegments = getFailedVoiceSegments(session) if (failedSegments.length === 0) return logVoiceOperation('transcription_retry_clicked', { feedbackSessionId: session.id, failedSegmentCount: failedSegments.length }) this.setData({ voiceBusy: true, voiceStatus: `正在重试${failedSegments.length}段转录`, primaryActionText: '正在重试转录', errorMessage: '', errorRequestId: '' }) try { 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) { 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 { const failedSegmentCount = this.data.session?.failedSegmentCount || 0 return new Promise((resolve) => { wx.showModal({ title: '放弃失败录音', content: `将删除${failedSegmentCount}段失败录音,其余录音和反馈内容不受影响。此操作无法撤销。`, confirmText: '确认放弃', confirmColor: '#d13f3a', success: (result) => resolve(result.confirm), fail: () => resolve(false) }) }) }, 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 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() ) 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) } }, 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) return } if (this.data.summaryRun?.status === 'queued' || this.data.summaryRun?.status === 'processing') { wx.showToast({ title: '总结仍在生成,请稍候', icon: 'none' }) this.scheduleSummaryPolling(this.data.summaryRun) return } const content = this.data.draft.content.trim() if (!content) { wx.showToast({ title: '请填写反馈内容', icon: 'none' }) return } this.setData({ saving: true }) try { await createFeedbackRecord({ ...this.data.draft, content, feedbackSessionId: session?.id || null }) this.clearSummaryPollTimer() this.setData({ draft: buildDraft(), session: null, selectedProfileIndex: 0, contentLength: 0, voiceStatus: '语音录入', voiceActionable: false, voiceDetailsExpanded: false, voiceLessonViews: [], excludedSummaryLessonIds: [], selectedSummaryLessonCount: 0, eligibleSummaryLessonCount: 0, allSummaryLessonsSelected: false, summaryRun: null, summaryStatusText: '', summaryActionText: '生成总结', primaryActionText: '保存反馈' }) wx.showToast({ title: '反馈已保存', icon: 'success' }) setTimeout(() => wx.switchTab({ url: '/pages/records/index' }), 450) } catch (error) { this.showApiError(error) } finally { this.setData({ saving: false }) } }, goStudents() { wx.switchTab({ url: '/pages/students/index' }) } })