Files
teaching-feedback-assistant/pages/feedback/index.ts
2026-07-22 19:13:55 +08:00

1156 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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