feat(voice): add reusable transcript records

This commit is contained in:
zhangheng
2026-07-22 17:26:55 +08:00
parent f755ed2b08
commit 108e280b3f
12 changed files with 353 additions and 57 deletions

View File

@@ -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

View File

@@ -30,7 +30,15 @@
<view class="content-field">
<view class="content-label-row">
<text class="form-label">反馈内容</text>
<text class="content-count">{{contentLength}} / 2000</text>
<view class="content-actions">
<text class="content-count">{{contentLength}} / 2000</text>
<button
wx:if="{{contentLength > 0}}"
class="content-clear-button"
disabled="{{recording || voiceBusy}}"
bindtap="clearFeedbackContent"
>清空</button>
</view>
</view>
<textarea
class="feedback-textarea"
@@ -51,10 +59,50 @@
<text class="voice-button-icon">{{recording ? '■' : '●'}}</text>
<text>{{recording ? '结束录音' : '语音录入'}}</text>
</button>
<view class="voice-state {{voiceActionable ? 'voice-state-actionable' : ''}}" bindtap="openVoiceDetails">
<view class="voice-state {{voiceActionable ? 'voice-state-actionable' : ''}}" bindtap="toggleVoiceDetails">
<text class="voice-status">{{voiceStatus}}</text>
<text wx:if="{{recording}}" class="recording-time">{{recordingTime}}</text>
<text wx:elif="{{voiceActionable}}" class="voice-state-arrow"></text>
<text wx:elif="{{voiceActionable}}" class="voice-state-arrow">{{voiceDetailsExpanded ? '⌃' : '⌄'}}</text>
</view>
<view wx:if="{{voiceDetailsExpanded}}" class="voice-record-list">
<view class="voice-record-list-heading">
<text>语音记录</text>
<text>{{voiceLessonViews.length}}节</text>
</view>
<view
wx:for="{{voiceLessonViews}}"
wx:key="id"
wx:for-item="lesson"
class="voice-record-item"
>
<view class="voice-record-header">
<view>
<text class="voice-record-title">第{{lesson.sequence}}节</text>
<text class="voice-record-duration">{{lesson.durationText}} · {{lesson.transcriptLength}}字</text>
</view>
<text class="voice-record-status">{{lesson.statusText}}</text>
</view>
<text
wx:if="{{lesson.transcript}}"
selectable
class="voice-record-transcript {{expandedVoiceLessonId === lesson.id ? 'voice-record-transcript-expanded' : ''}}"
>{{lesson.transcript}}</text>
<text wx:else class="voice-record-empty">{{lesson.emptyText}}</text>
<view wx:if="{{lesson.transcript}}" class="voice-record-actions">
<button
wx:if="{{lesson.transcriptExpandable}}"
class="voice-record-text-button"
data-lesson-id="{{lesson.id}}"
bindtap="toggleVoiceLessonTranscript"
>{{expandedVoiceLessonId === lesson.id ? '收起' : '展开'}}</button>
<button
class="voice-record-add-button"
data-lesson-id="{{lesson.id}}"
disabled="{{!lesson.canAdd || voiceBusy}}"
bindtap="addVoiceLessonToDraft"
>{{lesson.addActionText}}</button>
</view>
</view>
</view>
<view wx:if="{{session && session.failedSegmentCount > 0}}" class="voice-failure-recovery">
<text class="voice-failure-hint">{{voiceFailureHint}}</text>

View File

@@ -44,6 +44,36 @@
font-size: 23rpx;
}
.content-actions {
display: flex;
align-items: center;
gap: 16rpx;
}
.content-clear-button,
.voice-record-text-button,
.voice-record-add-button {
width: auto;
min-width: 0;
height: 48rpx;
margin: 0;
padding: 0;
border: 0;
background: transparent;
font-size: 23rpx;
line-height: 48rpx;
}
.content-clear-button::after,
.voice-record-text-button::after,
.voice-record-add-button::after {
border: 0;
}
.content-clear-button {
color: #b8322d;
}
.feedback-textarea {
width: 100%;
min-height: 280rpx;
@@ -154,10 +184,107 @@
flex: none;
margin-left: 10rpx;
color: #8e8e93;
font-size: 38rpx;
font-size: 30rpx;
line-height: 1;
}
.voice-record-list {
width: 100%;
border-top: 1rpx solid #d8d8dc;
border-bottom: 1rpx solid #d8d8dc;
}
.voice-record-list-heading,
.voice-record-header,
.voice-record-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.voice-record-list-heading {
min-height: 64rpx;
color: #6e6e73;
font-size: 23rpx;
font-weight: 600;
}
.voice-record-item {
padding: 18rpx 0;
border-top: 1rpx solid #e5e5ea;
}
.voice-record-header > view {
min-width: 0;
}
.voice-record-title {
color: #1c1c1e;
font-size: 26rpx;
font-weight: 600;
}
.voice-record-duration {
margin-left: 12rpx;
color: #8e8e93;
font-size: 22rpx;
}
.voice-record-status {
flex: none;
color: #46715b;
font-size: 22rpx;
}
.voice-record-transcript,
.voice-record-empty {
margin-top: 12rpx;
color: #3a3a3c;
font-size: 24rpx;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-all;
}
.voice-record-transcript {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
}
.voice-record-transcript-expanded {
display: block;
overflow: visible;
}
.voice-record-empty {
display: block;
color: #8e8e93;
}
.voice-record-actions {
min-height: 48rpx;
margin-top: 10rpx;
}
.voice-record-text-button {
color: #6e6e73;
}
.voice-record-add-button {
margin-left: auto;
color: #007aff;
font-weight: 600;
}
.voice-record-add-button[disabled],
.content-clear-button[disabled] {
color: #aeaeb2;
opacity: 1;
}
.recording-time {
flex: none;
margin-left: 12rpx;

View File

@@ -25,13 +25,14 @@ export function getFailedVoiceSegments(session: FeedbackSession): VoiceAudioSegm
)
}
export function getVoiceLessonStatusText(lesson: VoiceLesson): string {
export function getVoiceLessonStatusText(lesson: VoiceLesson, isInDraft = false): string {
if (lesson.status === 'failed') return '转录失败'
if (lesson.status === 'recording') return '录音中'
if (lesson.status === 'processing') return '处理中'
if (lesson.appliedDirectly) return '已加入输入框'
if (isInDraft) return '已输入框'
if (lesson.includedInGeneration) return '已生成反馈'
return '待生成反馈'
if (lesson.appliedDirectly) return '可重新加入'
return '可加入输入框'
}
export function getVoiceFailureHint(session: FeedbackSession): string {