feat(voice): add reusable transcript records
This commit is contained in:
@@ -19,6 +19,7 @@ import type {
|
||||
FeedbackDraft,
|
||||
FeedbackSession,
|
||||
StudentProfile,
|
||||
VoiceLesson,
|
||||
VoiceLessonFinished
|
||||
} from '../../utils/types'
|
||||
import { runWithSessionRefresh } from './voice-actions'
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
|
||||
type PickerEvent = { detail: { value: string | number } }
|
||||
type InputEvent = { detail: { value: string } }
|
||||
type LessonActionEvent = { currentTarget: { dataset: { lessonId: string } } }
|
||||
|
||||
interface PendingVoiceUpload {
|
||||
lessonId: string
|
||||
@@ -40,9 +42,21 @@ interface PendingVoiceUpload {
|
||||
filePath: string
|
||||
}
|
||||
|
||||
interface VoiceLessonView {
|
||||
id: string
|
||||
sequence: number
|
||||
durationText: string
|
||||
statusText: string
|
||||
transcript: string
|
||||
transcriptLength: number
|
||||
transcriptExpandable: boolean
|
||||
canAdd: boolean
|
||||
addActionText: string
|
||||
emptyText: 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'
|
||||
|
||||
@@ -107,6 +121,37 @@ function wait(milliseconds: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||
}
|
||||
|
||||
function buildVoiceLessonViews(session: FeedbackSession | null, content: string): VoiceLessonView[] {
|
||||
if (!session) return []
|
||||
return session.lessons.map((lesson: VoiceLesson) => {
|
||||
const transcript = lesson.transcript.trim()
|
||||
const isInDraft = Boolean(transcript) && content.includes(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,
|
||||
addActionText: isInDraft
|
||||
? '已在输入框'
|
||||
: lesson.appliedDirectly
|
||||
? '重新加入'
|
||||
: '加入输入框',
|
||||
emptyText
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function logVoiceOperation(event: string, fields: Record<string, unknown>): void {
|
||||
console.info('voice_operation', { event, ...fields })
|
||||
}
|
||||
@@ -129,6 +174,9 @@ Page({
|
||||
voiceStatus: '语音录入',
|
||||
voiceFailureHint: '',
|
||||
voiceActionable: false,
|
||||
voiceDetailsExpanded: false,
|
||||
expandedVoiceLessonId: '',
|
||||
voiceLessonViews: [] as VoiceLessonView[],
|
||||
primaryActionText: '保存反馈',
|
||||
errorMessage: '',
|
||||
errorRequestId: ''
|
||||
@@ -259,6 +307,9 @@ Page({
|
||||
voiceStatus: '语音录入',
|
||||
voiceFailureHint: '',
|
||||
voiceActionable: false,
|
||||
voiceDetailsExpanded: false,
|
||||
expandedVoiceLessonId: '',
|
||||
voiceLessonViews: [],
|
||||
primaryActionText: '保存反馈'
|
||||
})
|
||||
return
|
||||
@@ -274,6 +325,7 @@ Page({
|
||||
voiceStatus: `${prefix} · ${suffix}`,
|
||||
voiceFailureHint: session.failedSegmentCount > 0 ? getVoiceFailureHint(session) : '',
|
||||
voiceActionable: true,
|
||||
voiceLessonViews: buildVoiceLessonViews(session, this.data.draft.content),
|
||||
primaryActionText: getVoicePrimaryActionText(primaryAction, session.failedSegmentCount)
|
||||
})
|
||||
},
|
||||
@@ -313,20 +365,6 @@ Page({
|
||||
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)
|
||||
this.showApiError(error, false)
|
||||
@@ -357,6 +395,9 @@ Page({
|
||||
'draft.content': '',
|
||||
contentLength: 0,
|
||||
voiceActionable: false,
|
||||
voiceDetailsExpanded: false,
|
||||
expandedVoiceLessonId: '',
|
||||
voiceLessonViews: [],
|
||||
primaryActionText: '保存反馈'
|
||||
})
|
||||
void this.loadActiveSession(profileId)
|
||||
@@ -369,10 +410,38 @@ Page({
|
||||
|
||||
onContentInput(event: InputEvent) {
|
||||
const content = event.detail.value
|
||||
this.setData({ 'draft.content': content, contentLength: content.length })
|
||||
this.setData({
|
||||
'draft.content': content,
|
||||
contentLength: content.length,
|
||||
voiceLessonViews: buildVoiceLessonViews(this.data.session, content)
|
||||
})
|
||||
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, '')
|
||||
})
|
||||
await this.persistSessionDraft()
|
||||
},
|
||||
|
||||
scheduleDraftSave() {
|
||||
this.clearDraftSaveTimer()
|
||||
if (!this.data.session) return
|
||||
@@ -633,19 +702,7 @@ Page({
|
||||
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' })
|
||||
}
|
||||
wx.showToast({ title: '转录已就绪,可在语音记录中查看', icon: 'none' })
|
||||
},
|
||||
|
||||
startRecordingTimer() {
|
||||
@@ -661,7 +718,7 @@ Page({
|
||||
recordingTimer = null
|
||||
},
|
||||
|
||||
async openVoiceDetails() {
|
||||
async toggleVoiceDetails() {
|
||||
if (this.data.recording || this.data.voiceBusy) return
|
||||
if (failedLocalUploads.length > 0) {
|
||||
await this.retryPendingUploads()
|
||||
@@ -669,22 +726,57 @@ Page({
|
||||
}
|
||||
const session = this.data.session
|
||||
if (!session || session.lessonCount === 0) return
|
||||
const lines = session.lessons.map((lesson) => {
|
||||
return `第${lesson.sequence}节 · ${formatVoiceDuration(lesson.durationMs)} · ${getVoiceLessonStatusText(lesson)}`
|
||||
this.setData({
|
||||
voiceDetailsExpanded: !this.data.voiceDetailsExpanded,
|
||||
expandedVoiceLessonId: ''
|
||||
})
|
||||
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()
|
||||
}
|
||||
},
|
||||
|
||||
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)
|
||||
}
|
||||
},
|
||||
|
||||
async retryFailedSegments() {
|
||||
const session = this.data.session
|
||||
if (!session) return
|
||||
|
||||
Reference in New Issue
Block a user