import { createFeedbackRecord, createVoiceLesson, ensureFeedbackSession, finishVoiceLesson, generateFeedbackFromVoice, getActiveFeedbackSession, getApiErrorMessage, listProfiles, markVoiceLessonApplied, retryVoiceSegment, updateFeedbackSession, uploadVoiceSegment } from '../../utils/api' import type { FeedbackDraft, FeedbackSession, StudentProfile, VoiceLessonFinished } from '../../utils/types' type PickerEvent = { detail: { value: string | number } } type InputEvent = { detail: { value: string } } interface PendingVoiceUpload { lessonId: string sequence: number durationMs: number filePath: 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' const recorderManager = wx.getRecorderManager() let recordingTimer: number | null = null let draftSaveTimer: number | null = null let voicePollTimer: number | null = null let voicePollBusy = 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)) } Page({ data: { profiles: [] as StudentProfile[], profileOptions: ['不关联学生'], selectedProfileIndex: 0, draft: buildDraft(), session: null as FeedbackSession | null, contentLength: 0, loading: false, saving: false, generating: false, voiceBusy: false, recording: false, recordingPaused: false, recordingTime: '00:00', voiceStatus: '语音录入', voiceActionable: false, primaryActionText: '保存反馈', errorMessage: '' }, 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() if (this.data.recording) this.stopRecording() }, onUnload() { pageVisible = false this.clearRecordingTimer() this.clearDraftSaveTimer() this.clearVoicePollTimer() 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: '' }) 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) }) } finally { this.setData({ loading: false }) } }, async loadActiveSession(profileId?: string | null) { try { const targetProfileId = profileId === undefined ? this.data.draft.profileId : profileId const session = await getActiveFeedbackSession(targetProfileId) if (targetProfileId !== this.data.draft.profileId) return const nextData: Record = { session, 'draft.feedbackSessionId': session?.id || null } if (session) { nextData['draft.feedbackDate'] = session.feedbackDate nextData['draft.content'] = session.content nextData.contentLength = session.content.length } this.setData(nextData) this.syncVoicePresentation(session) this.scheduleVoicePolling(session) } catch (error) { this.setData({ errorMessage: getApiErrorMessage(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: '语音录入', voiceActionable: false, primaryActionText: '保存反馈' }) return } const prefix = `${session.lessonCount}节 · ${formatVoiceDuration(session.totalDurationMs)}` let suffix = '已就绪' if (session.failedSegmentCount > 0) suffix = '转录失败,点击重试' else if (session.processingSegmentCount > 0) suffix = '正在处理' let primaryActionText = session.needsGeneration ? '生成反馈' : '保存反馈' if (session.processingSegmentCount > 0) primaryActionText = '转录中' this.setData({ voiceStatus: `${prefix} · ${suffix}`, voiceActionable: true, primaryActionText }) }, 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 }, 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 }) 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) } finally { voicePollBusy = false this.scheduleVoicePolling(this.data.session) } }, retryLoad() { void this.loadProfiles() }, onProfileChange(event: PickerEvent) { this.clearDraftSaveTimer() 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, 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 }) this.scheduleDraftSave() }, 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 }, async persistSessionDraft() { const session = this.data.session if (!session) return const sessionId = session.id try { const updated = await updateFeedbackSession( sessionId, this.data.draft.feedbackDate, this.data.draft.content ) if (this.data.session?.id !== sessionId) return this.setData({ session: updated }) this.syncVoicePresentation(updated) } catch (error) { console.warn('Failed to auto-save feedback session', error) } }, 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) wx.showToast({ title: getApiErrorMessage(error), icon: 'none' }) } }, 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) 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[] = [] for (const segment of failedLocalUploads) { try { await this.uploadWithRetry(segment) this.removeSavedFile(segment.filePath) } catch { remaining.push(segment) } } failedLocalUploads = remaining wx.setStorageSync(PENDING_UPLOADS_KEY, failedLocalUploads) if (remaining.length > 0) { this.setData({ voiceBusy: false, voiceStatus: '上传失败,点击重试', voiceActionable: true }) wx.showToast({ title: '录音上传失败,请检查网络', icon: 'none' }) 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) { wx.showToast({ title: getApiErrorMessage(error), icon: 'none' }) } 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 } 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' }) } }, 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 openVoiceDetails() { 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 const lines = session.lessons.map((lesson) => { const status = lesson.status === 'ready' ? '已就绪' : lesson.status === 'failed' ? '转录失败' : '处理中' return `第${lesson.sequence}节 · ${formatVoiceDuration(lesson.durationMs)} · ${status}` }) 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() } }) }, async retryFailedSegments() { const session = this.data.session if (!session) return const failedSegments = session.lessons.flatMap((lesson) => lesson.segments.filter((segment) => segment.status === 'failed') ) if (failedSegments.length === 0) return this.setData({ voiceBusy: true, voiceStatus: '正在重试转录' }) 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() } catch (error) { wx.showToast({ title: getApiErrorMessage(error), icon: 'none' }) } finally { this.setData({ voiceBusy: false }) this.syncVoicePresentation(this.data.session) } }, async saveFeedback() { this.clearDraftSaveTimer() const session = this.data.session if (session && session.processingSegmentCount > 0) { wx.showToast({ title: '录音仍在转写,请稍候', icon: 'none' }) this.scheduleVoicePolling(session) return } if (session?.needsGeneration) { await this.generateFeedback() 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.setData({ draft: buildDraft(), session: null, selectedProfileIndex: 0, contentLength: 0, voiceStatus: '语音录入', voiceActionable: false, primaryActionText: '保存反馈' }) wx.showToast({ title: '反馈已保存', icon: 'success' }) setTimeout(() => wx.switchTab({ url: '/pages/records/index' }), 450) } catch (error) { wx.showToast({ title: getApiErrorMessage(error), icon: 'none' }) } finally { this.setData({ saving: false }) } }, async generateFeedback() { const session = this.data.session if (!session) return this.setData({ generating: true, voiceBusy: true, voiceStatus: '正在生成反馈' }) try { const result = await generateFeedbackFromVoice(session.id, this.data.draft.content) 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' }) } finally { this.setData({ generating: false, voiceBusy: false }) this.syncVoicePresentation(this.data.session) } }, goStudents() { wx.switchTab({ url: '/pages/students/index' }) } })