1387 lines
45 KiB
TypeScript
1387 lines
45 KiB
TypeScript
import {
|
||
applyFeedbackSummaryRun,
|
||
copyApiRequestId,
|
||
createFeedbackRecord,
|
||
createFeedbackSummaryRun,
|
||
createVoiceLesson,
|
||
discardEmptyVoiceLesson,
|
||
discardVoiceSegment,
|
||
ensureFeedbackSession,
|
||
finishVoiceLesson,
|
||
getActiveFeedbackSession,
|
||
getApiErrorMessage,
|
||
getApiRequestId,
|
||
getFeedbackSummaryRun,
|
||
listProfiles,
|
||
markVoiceLessonApplied,
|
||
retryVoiceSegment,
|
||
updateFeedbackSession,
|
||
uploadVoiceSegment
|
||
} from '../../utils/api'
|
||
import type {
|
||
FeedbackDraft,
|
||
FeedbackSession,
|
||
FeedbackSummaryRun,
|
||
StudentProfile,
|
||
VoiceAudioSegment,
|
||
VoiceLesson,
|
||
VoiceLessonFinished
|
||
} from '../../utils/types'
|
||
import { runWithSessionRefresh } from './voice-actions'
|
||
import type { VoiceLessonRecoveryAction } from './voice-state'
|
||
import {
|
||
getFailedVoiceSegments,
|
||
getVoiceFailureHint,
|
||
getVoiceLessonRecoveryAction,
|
||
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 } } }
|
||
type ErrorRetryAction = 'load' | 'generateSummary'
|
||
|
||
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
|
||
failedSegmentCount: number
|
||
failureRecoveryAction: VoiceLessonRecoveryAction
|
||
failureRecoveryText: string
|
||
failureDeleteText: string
|
||
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 pendingSummaryIdempotencyKey: string | null = null
|
||
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(0, 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)
|
||
const failedSegmentCount = lesson.segments.filter((segment) => segment.status === 'failed').length
|
||
const failureRecoveryAction = getVoiceLessonRecoveryAction(lesson)
|
||
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),
|
||
failedSegmentCount,
|
||
failureRecoveryAction,
|
||
failureRecoveryText: failureRecoveryAction === 'retry' ? '重试转录' : '重新录制',
|
||
failureDeleteText: failureRecoveryAction === 'retry' ? '删除录音' : '删除记录',
|
||
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,
|
||
errorRetrying: false,
|
||
errorRetryAction: 'load' as ErrorRetryAction,
|
||
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(preserveError = false): Promise<boolean> {
|
||
this.setData(preserveError
|
||
? { loading: true }
|
||
: { loading: true, errorMessage: '', errorRequestId: '' })
|
||
let success = false
|
||
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
|
||
})
|
||
success = await this.loadActiveSession(profileId)
|
||
} catch (error) {
|
||
this.setData({
|
||
errorMessage: getApiErrorMessage(error),
|
||
errorRequestId: getApiRequestId(error),
|
||
errorRetryAction: 'load'
|
||
})
|
||
} finally {
|
||
this.setData({ loading: false })
|
||
}
|
||
return success
|
||
},
|
||
|
||
async loadActiveSession(profileId?: string | null): Promise<boolean> {
|
||
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 false
|
||
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)
|
||
return true
|
||
} catch (error) {
|
||
this.setData({
|
||
errorMessage: getApiErrorMessage(error),
|
||
errorRequestId: getApiRequestId(error),
|
||
errorRetryAction: 'load'
|
||
})
|
||
return false
|
||
}
|
||
},
|
||
|
||
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)}`
|
||
const emptyFailedLessonCount = session.lessons.filter((lesson) =>
|
||
lesson.status === 'failed' && lesson.segments.length === 0
|
||
).length
|
||
let suffix = '已就绪'
|
||
if (session.failedSegmentCount > 0) suffix = `${session.failedSegmentCount}段转录失败`
|
||
else if (emptyFailedLessonCount > 0) suffix = `${emptyFailedLessonCount}节未保留录音`
|
||
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,
|
||
retryAction: ErrorRetryAction = 'load'
|
||
) {
|
||
const errorMessage = getApiErrorMessage(error)
|
||
const errorRequestId = getApiRequestId(error)
|
||
this.setData({ errorMessage, errorRequestId, errorRetryAction: retryAction })
|
||
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)
|
||
}
|
||
},
|
||
|
||
async retryError() {
|
||
if (this.data.loading || this.data.errorRetrying) return
|
||
this.setData({ errorRetrying: true })
|
||
try {
|
||
const success = this.data.errorRetryAction === 'generateSummary'
|
||
? await this.submitFeedbackSummary(true)
|
||
: await this.loadProfiles(true)
|
||
if (success) this.dismissError()
|
||
} finally {
|
||
this.setData({ errorRetrying: false })
|
||
}
|
||
},
|
||
|
||
copyRequestId() {
|
||
copyApiRequestId(this.data.errorRequestId)
|
||
},
|
||
|
||
dismissError() {
|
||
this.setData({ errorMessage: '', errorRequestId: '', errorRetryAction: 'load' })
|
||
},
|
||
|
||
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
|
||
pendingSummaryIdempotencyKey = 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
|
||
}
|
||
|
||
await this.startVoiceRecording()
|
||
},
|
||
|
||
async startVoiceRecording(permissionGranted = false) {
|
||
this.setData({ voiceBusy: true, voiceStatus: '正在开启麦克风' })
|
||
let authorized = permissionGranted
|
||
if (!authorized) 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 () => {
|
||
await 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)
|
||
},
|
||
|
||
generateFeedbackSummary(): Promise<boolean> {
|
||
pendingSummaryIdempotencyKey = null
|
||
return this.submitFeedbackSummary(false)
|
||
},
|
||
|
||
async submitFeedbackSummary(preserveError: boolean): Promise<boolean> {
|
||
const session = this.data.session
|
||
if (!session || this.data.summaryBusy || this.data.recording || this.data.voiceBusy) return false
|
||
if (this.data.summaryRun?.status === 'queued' || this.data.summaryRun?.status === 'processing') {
|
||
wx.showToast({ title: '当前总结仍在生成', icon: 'none' })
|
||
return false
|
||
}
|
||
const lessonIds = this.data.voiceLessonViews
|
||
.filter((lesson) => lesson.summarySelected)
|
||
.map((lesson) => lesson.id)
|
||
if (lessonIds.length === 0) {
|
||
wx.showToast({ title: '请至少选择一节已就绪录音', icon: 'none' })
|
||
return false
|
||
}
|
||
|
||
const idempotencyKey = pendingSummaryIdempotencyKey || createSummaryIdempotencyKey(session.id)
|
||
pendingSummaryIdempotencyKey = idempotencyKey
|
||
this.setData(preserveError
|
||
? { summaryBusy: true }
|
||
: { summaryBusy: true, errorMessage: '', errorRequestId: '' })
|
||
try {
|
||
const run = await createFeedbackSummaryRun(
|
||
session.id,
|
||
lessonIds,
|
||
this.data.draft.content,
|
||
idempotencyKey
|
||
)
|
||
logVoiceOperation('feedback_summary_queued', {
|
||
feedbackSessionId: session.id,
|
||
summaryRunId: run.id,
|
||
lessonIds
|
||
})
|
||
this.syncSummaryRun(run)
|
||
pendingSummaryIdempotencyKey = null
|
||
wx.showToast({ title: '已开始生成总结', icon: 'none' })
|
||
return true
|
||
} catch (error) {
|
||
this.showApiError(error, true, 'generateSummary')
|
||
return false
|
||
} 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
|
||
await this.retryVoiceSegments(failedSegments)
|
||
},
|
||
|
||
async recoverVoiceLesson(event: LessonActionEvent) {
|
||
const session = this.data.session
|
||
const lesson = session?.lessons.find((item) => item.id === event.currentTarget.dataset.lessonId)
|
||
if (!lesson) return
|
||
const failedSegments = lesson.segments.filter((segment) => segment.status === 'failed')
|
||
if (failedSegments.length > 0) {
|
||
await this.retryVoiceSegments(failedSegments, lesson.sequence)
|
||
return
|
||
}
|
||
if (lesson.status === 'failed') await this.rerecordVoiceLesson(lesson)
|
||
},
|
||
|
||
confirmRerecordVoiceLesson(lessonSequence: number): Promise<boolean> {
|
||
return new Promise((resolve) => {
|
||
wx.showModal({
|
||
title: `重新录制第${lessonSequence}节`,
|
||
content: '该节没有保留可重试的音频。重新录制将删除这条失败记录并开启麦克风。',
|
||
confirmText: '重新录制',
|
||
success: (result) => resolve(result.confirm),
|
||
fail: () => resolve(false)
|
||
})
|
||
})
|
||
},
|
||
|
||
async rerecordVoiceLesson(lesson: VoiceLesson) {
|
||
if (
|
||
this.data.recording
|
||
|| this.data.voiceBusy
|
||
|| !(await this.confirmRerecordVoiceLesson(lesson.sequence))
|
||
) return
|
||
this.setData({ voiceBusy: true, voiceStatus: `正在准备重新录制第${lesson.sequence}节` })
|
||
const authorized = await this.ensureRecordPermission()
|
||
if (!authorized) {
|
||
this.setData({ voiceBusy: false })
|
||
this.syncVoicePresentation(this.data.session)
|
||
return
|
||
}
|
||
try {
|
||
await discardEmptyVoiceLesson(lesson.id)
|
||
logVoiceOperation('empty_failed_lesson_rerecord_started', {
|
||
feedbackSessionId: this.data.session?.id || null,
|
||
lessonId: lesson.id,
|
||
lessonSequence: lesson.sequence
|
||
})
|
||
await this.startVoiceRecording(true)
|
||
} catch (error) {
|
||
this.setData({ voiceBusy: false })
|
||
await this.loadActiveSession()
|
||
this.syncVoicePresentation(this.data.session)
|
||
this.showApiError(error)
|
||
}
|
||
},
|
||
|
||
async retryVoiceSegments(
|
||
failedSegments: VoiceAudioSegment[],
|
||
lessonSequence?: number
|
||
) {
|
||
const session = this.data.session
|
||
if (!session || this.data.recording || this.data.voiceBusy) return
|
||
logVoiceOperation('transcription_retry_clicked', {
|
||
feedbackSessionId: session.id,
|
||
lessonSequence: lessonSequence || null,
|
||
failedSegmentCount: failedSegments.length
|
||
})
|
||
this.setData({
|
||
voiceBusy: true,
|
||
voiceStatus: lessonSequence
|
||
? `正在重试第${lessonSequence}节转录`
|
||
: `正在重试${failedSegments.length}段转录`,
|
||
primaryActionText: '正在重试转录',
|
||
errorMessage: '',
|
||
errorRequestId: ''
|
||
})
|
||
try {
|
||
await runWithSessionRefresh(
|
||
async () => {
|
||
for (const segment of failedSegments) await retryVoiceSegment(segment.id)
|
||
},
|
||
async () => {
|
||
await this.loadActiveSession()
|
||
}
|
||
)
|
||
logVoiceOperation('transcription_retry_submitted', {
|
||
feedbackSessionId: session.id,
|
||
lessonSequence: lessonSequence || null,
|
||
segmentIds: failedSegments.map((segment) => segment.id)
|
||
})
|
||
wx.showToast({
|
||
title: lessonSequence ? `第${lessonSequence}节已重新提交` : '已重新提交,正在转录',
|
||
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)
|
||
}
|
||
},
|
||
|
||
confirmDiscardVoiceSegments(
|
||
failedSegmentCount: number,
|
||
lessonSequence?: number
|
||
): Promise<boolean> {
|
||
return new Promise((resolve) => {
|
||
wx.showModal({
|
||
title: lessonSequence ? `删除第${lessonSequence}节录音` : '删除失败录音',
|
||
content: lessonSequence
|
||
? `将删除第${lessonSequence}节中${failedSegmentCount}段转录失败的录音,其余语音记录不受影响。此操作无法撤销。`
|
||
: `将删除${failedSegmentCount}段失败录音,其余录音和反馈内容不受影响。此操作无法撤销。`,
|
||
confirmText: '确认删除',
|
||
confirmColor: '#d13f3a',
|
||
success: (result) => resolve(result.confirm),
|
||
fail: () => resolve(false)
|
||
})
|
||
})
|
||
},
|
||
|
||
confirmDiscardEmptyVoiceLesson(lessonSequence: number): Promise<boolean> {
|
||
return new Promise((resolve) => {
|
||
wx.showModal({
|
||
title: `删除第${lessonSequence}节记录`,
|
||
content: '该节没有保留可用录音,删除后不会影响其他语音记录。此操作无法撤销。',
|
||
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.confirmDiscardVoiceSegments(failedSegments.length))
|
||
) return
|
||
await this.discardVoiceSegments(failedSegments)
|
||
},
|
||
|
||
async discardVoiceLesson(event: LessonActionEvent) {
|
||
if (this.data.recording || this.data.voiceBusy) return
|
||
const session = this.data.session
|
||
const lesson = session?.lessons.find((item) => item.id === event.currentTarget.dataset.lessonId)
|
||
if (!session || !lesson) return
|
||
const failedSegments = lesson.segments.filter((segment) => segment.status === 'failed')
|
||
if (failedSegments.length === 0) {
|
||
if (
|
||
lesson.status === 'failed'
|
||
&& await this.confirmDiscardEmptyVoiceLesson(lesson.sequence)
|
||
) await this.discardEmptyFailedVoiceLesson(lesson)
|
||
return
|
||
}
|
||
if (
|
||
!(await this.confirmDiscardVoiceSegments(failedSegments.length, lesson.sequence))
|
||
) return
|
||
await this.discardVoiceSegments(failedSegments, lesson.sequence)
|
||
},
|
||
|
||
async discardEmptyFailedVoiceLesson(lesson: VoiceLesson) {
|
||
const session = this.data.session
|
||
if (!session) return
|
||
this.setData({
|
||
voiceBusy: true,
|
||
voiceStatus: `正在删除第${lesson.sequence}节失败记录`,
|
||
primaryActionText: '正在处理',
|
||
errorMessage: '',
|
||
errorRequestId: ''
|
||
})
|
||
try {
|
||
await runWithSessionRefresh(
|
||
async () => discardEmptyVoiceLesson(lesson.id),
|
||
async () => {
|
||
await this.loadActiveSession()
|
||
}
|
||
)
|
||
logVoiceOperation('empty_failed_lesson_discarded', {
|
||
feedbackSessionId: session.id,
|
||
lessonId: lesson.id,
|
||
lessonSequence: lesson.sequence
|
||
})
|
||
wx.showToast({ title: `第${lesson.sequence}节失败记录已删除`, icon: 'none' })
|
||
} catch (error) {
|
||
this.showApiError(error)
|
||
} finally {
|
||
this.setData({ voiceBusy: false })
|
||
this.syncVoicePresentation(this.data.session)
|
||
}
|
||
},
|
||
|
||
async discardVoiceSegments(
|
||
failedSegments: VoiceAudioSegment[],
|
||
lessonSequence?: number
|
||
) {
|
||
const session = this.data.session
|
||
if (!session) return
|
||
|
||
this.setData({
|
||
voiceBusy: true,
|
||
voiceStatus: lessonSequence
|
||
? `正在删除第${lessonSequence}节失败录音`
|
||
: `正在删除${failedSegments.length}段失败录音`,
|
||
primaryActionText: '正在处理',
|
||
errorMessage: '',
|
||
errorRequestId: ''
|
||
})
|
||
try {
|
||
await runWithSessionRefresh(
|
||
async () => {
|
||
for (const segment of failedSegments) await discardVoiceSegment(segment.id)
|
||
},
|
||
async () => {
|
||
await this.loadActiveSession()
|
||
}
|
||
)
|
||
logVoiceOperation('failed_transcription_discarded', {
|
||
feedbackSessionId: session.id,
|
||
lessonSequence: lessonSequence || null,
|
||
segmentIds: failedSegments.map((segment) => segment.id)
|
||
})
|
||
wx.showToast({
|
||
title: lessonSequence ? `第${lessonSequence}节失败录音已删除` : '失败录音已删除',
|
||
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' })
|
||
}
|
||
})
|