744 lines
23 KiB
TypeScript
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' })
|
|
}
|
|
})
|