Files
teaching-feedback-assistant/pages/feedback/index.ts

744 lines
23 KiB
TypeScript

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<void>[] = []
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<void> {
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<string, unknown> = {
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<boolean> {
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<void> {
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<void> {
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<PendingVoiceUpload> {
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' })
}
})