feat(voice): add async selectable summaries

This commit is contained in:
zhangheng
2026-07-22 19:13:55 +08:00
parent 108e280b3f
commit 0a2509d923
17 changed files with 1530 additions and 159 deletions

View File

@@ -1,14 +1,16 @@
import {
applyFeedbackSummaryRun,
copyApiRequestId,
createFeedbackRecord,
createFeedbackSummaryRun,
createVoiceLesson,
discardVoiceSegment,
ensureFeedbackSession,
finishVoiceLesson,
generateFeedbackFromVoice,
getActiveFeedbackSession,
getApiErrorMessage,
getApiRequestId,
getFeedbackSummaryRun,
listProfiles,
markVoiceLessonApplied,
retryVoiceSegment,
@@ -18,6 +20,7 @@ import {
import type {
FeedbackDraft,
FeedbackSession,
FeedbackSummaryRun,
StudentProfile,
VoiceLesson,
VoiceLessonFinished
@@ -33,6 +36,7 @@ import {
type PickerEvent = { detail: { value: string | number } }
type InputEvent = { detail: { value: string } }
type CheckboxGroupEvent = { detail: { value: string[] } }
type LessonActionEvent = { currentTarget: { dataset: { lessonId: string } } }
interface PendingVoiceUpload {
@@ -51,6 +55,8 @@ interface VoiceLessonView {
transcriptLength: number
transcriptExpandable: boolean
canAdd: boolean
summaryEligible: boolean
summarySelected: boolean
addActionText: string
emptyText: string
}
@@ -58,13 +64,17 @@ interface VoiceLessonView {
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
@@ -121,11 +131,17 @@ function wait(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}
function buildVoiceLessonViews(session: FeedbackSession | null, content: string): VoiceLessonView[] {
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
@@ -142,6 +158,8 @@ function buildVoiceLessonViews(session: FeedbackSession | null, content: string)
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
@@ -156,6 +174,19 @@ 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[],
@@ -166,7 +197,14 @@ Page({
contentLength: 0,
loading: false,
saving: false,
generating: 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,
@@ -200,6 +238,7 @@ Page({
onHide() {
pageVisible = false
this.clearVoicePollTimer()
this.clearSummaryPollTimer()
if (this.data.recording) this.stopRecording()
},
@@ -208,6 +247,7 @@ Page({
this.clearRecordingTimer()
this.clearDraftSaveTimer()
this.clearVoicePollTimer()
this.clearSummaryPollTimer()
if (this.data.recording) recorderManager.stop()
},
@@ -277,11 +317,16 @@ Page({
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
'draft.feedbackSessionId': session?.id || null,
summaryRun: session?.latestSummaryRun || null
}
if ((session?.id || null) !== previousSessionId) {
nextData.excludedSummaryLessonIds = []
}
if (session) {
nextData['draft.feedbackDate'] = session.feedbackDate
@@ -290,6 +335,7 @@ Page({
}
this.setData(nextData)
this.syncVoicePresentation(session)
this.syncSummaryRun(session?.latestSummaryRun || null)
this.scheduleVoicePolling(session)
} catch (error) {
this.setData({ errorMessage: getApiErrorMessage(error), errorRequestId: getApiRequestId(error) })
@@ -310,6 +356,9 @@ Page({
voiceDetailsExpanded: false,
expandedVoiceLessonId: '',
voiceLessonViews: [],
selectedSummaryLessonCount: 0,
eligibleSummaryLessonCount: 0,
allSummaryLessonsSelected: false,
primaryActionText: '保存反馈'
})
return
@@ -319,17 +368,39 @@ Page({
let suffix = '已就绪'
if (session.failedSegmentCount > 0) suffix = `${session.failedSegmentCount}段转录失败`
else if (session.processingSegmentCount > 0) suffix = '正在处理'
else if (session.needsGeneration) 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: buildVoiceLessonViews(session, this.data.draft.content),
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)
@@ -352,6 +423,42 @@ Page({
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) {
@@ -363,8 +470,13 @@ Page({
try {
const session = await getActiveFeedbackSession(profileId)
if (!pageVisible || profileId !== this.data.draft.profileId) return
this.setData({ session, 'draft.feedbackSessionId': session?.id || null })
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)
@@ -384,6 +496,7 @@ Page({
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
@@ -398,6 +511,13 @@ Page({
voiceDetailsExpanded: false,
expandedVoiceLessonId: '',
voiceLessonViews: [],
excludedSummaryLessonIds: [],
selectedSummaryLessonCount: 0,
eligibleSummaryLessonCount: 0,
allSummaryLessonsSelected: false,
summaryRun: null,
summaryStatusText: '',
summaryActionText: '生成总结',
primaryActionText: '保存反馈'
})
void this.loadActiveSession(profileId)
@@ -413,7 +533,11 @@ Page({
this.setData({
'draft.content': content,
contentLength: content.length,
voiceLessonViews: buildVoiceLessonViews(this.data.session, content)
voiceLessonViews: buildVoiceLessonViews(
this.data.session,
content,
this.data.excludedSummaryLessonIds
)
})
this.scheduleDraftSave()
},
@@ -437,7 +561,11 @@ Page({
this.setData({
'draft.content': '',
contentLength: 0,
voiceLessonViews: buildVoiceLessonViews(this.data.session, '')
voiceLessonViews: buildVoiceLessonViews(
this.data.session,
'',
this.data.excludedSummaryLessonIds
)
})
await this.persistSessionDraft()
},
@@ -456,23 +584,26 @@ Page({
draftSaveTimer = null
},
async persistSessionDraft() {
persistSessionDraft(): Promise<void> {
const session = this.data.session
if (!session) return
if (!session) return Promise.resolve()
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)
this.showApiError(error, false)
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> {
@@ -777,6 +908,99 @@ Page({
}
},
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
@@ -878,8 +1102,9 @@ Page({
this.scheduleVoicePolling(session)
return
}
if (session?.needsGeneration) {
await this.generateFeedback()
if (this.data.summaryRun?.status === 'queued' || this.data.summaryRun?.status === 'processing') {
wx.showToast({ title: '总结仍在生成,请稍候', icon: 'none' })
this.scheduleSummaryPolling(this.data.summaryRun)
return
}
@@ -896,6 +1121,7 @@ Page({
content,
feedbackSessionId: session?.id || null
})
this.clearSummaryPollTimer()
this.setData({
draft: buildDraft(),
session: null,
@@ -903,6 +1129,15 @@ Page({
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' })
@@ -914,34 +1149,6 @@ Page({
}
},
async generateFeedback() {
const session = this.data.session
if (!session) return
this.setData({
generating: true,
voiceBusy: true,
voiceStatus: '正在生成反馈',
errorMessage: '',
errorRequestId: ''
})
try {
const result = await runWithSessionRefresh(
async () => generateFeedbackFromVoice(session.id, this.data.draft.content),
async () => this.loadActiveSession()
)
this.setData({
'draft.content': result.content,
contentLength: result.content.length
})
wx.showToast({ title: '反馈已生成,可继续修改', icon: 'none' })
} catch (error) {
this.showApiError(error)
} finally {
this.setData({ generating: false, voiceBusy: false })
this.syncVoicePresentation(this.data.session)
}
},
goStudents() {
wx.switchTab({ url: '/pages/students/index' })
}