feat(voice): add reusable transcript records
This commit is contained in:
@@ -104,6 +104,8 @@ API 当前只有通用 HTTP 请求日志。重试请求未到达服务端时,
|
|||||||
- 生成请求结束后始终刷新活动会话;API 错误消息和 `X-Request-Id` 保存在页面错误区域,可复制查询。
|
- 生成请求结束后始终刷新活动会话;API 错误消息和 `X-Request-Id` 保存在页面错误区域,可复制查询。
|
||||||
- 页面增加失败原因说明和“放弃失败录音”;放弃操作不会自动开始录音,后端删除失败片段时会重算课堂状态,空课堂会一并删除。
|
- 页面增加失败原因说明和“放弃失败录音”;放弃操作不会自动开始录音,后端删除失败片段时会重算课堂状态,空课堂会一并删除。
|
||||||
- 语音记录区分“已加入输入框”“待生成反馈”和“已生成反馈”,避免把待汇总的长录音笼统显示为“已就绪”。
|
- 语音记录区分“已加入输入框”“待生成反馈”和“已生成反馈”,避免把待汇总的长录音笼统显示为“已就绪”。
|
||||||
|
- 语音记录进一步调整为可复用素材列表:每节可查看完整转写、展开或收起、按需加入或重新加入输入框;短录音转写完成后不再自动修改正文。
|
||||||
|
- 反馈输入框支持确认后清空,语音记录仍保留;单节内容加入后若超过 2000 字会整体拒绝并提示,不再截断后写入。
|
||||||
- API 增加 `transcription_retry_requested`、`feedback_generation_rejected` 和 `failed_transcription_discarded` 业务日志;转录 worker 将无清晰语音分类为 `no_clear_speech`。
|
- API 增加 `transcription_retry_requested`、`feedback_generation_rejected` 和 `failed_transcription_discarded` 业务日志;转录 worker 将无清晰语音分类为 `no_clear_speech`。
|
||||||
- 前端自动化测试覆盖失败优先级、重试状态流转、无清晰语音提示和 API 失败后的会话刷新;后端测试覆盖错误分类和 OpenAPI 路由完整性。
|
- 前端自动化测试覆盖失败优先级、重试状态流转、无清晰语音提示和 API 失败后的会话刷新;后端测试覆盖错误分类和 OpenAPI 路由完整性。
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type {
|
|||||||
FeedbackDraft,
|
FeedbackDraft,
|
||||||
FeedbackSession,
|
FeedbackSession,
|
||||||
StudentProfile,
|
StudentProfile,
|
||||||
|
VoiceLesson,
|
||||||
VoiceLessonFinished
|
VoiceLessonFinished
|
||||||
} from '../../utils/types'
|
} from '../../utils/types'
|
||||||
import { runWithSessionRefresh } from './voice-actions'
|
import { runWithSessionRefresh } from './voice-actions'
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
|
|
||||||
type PickerEvent = { detail: { value: string | number } }
|
type PickerEvent = { detail: { value: string | number } }
|
||||||
type InputEvent = { detail: { value: string } }
|
type InputEvent = { detail: { value: string } }
|
||||||
|
type LessonActionEvent = { currentTarget: { dataset: { lessonId: string } } }
|
||||||
|
|
||||||
interface PendingVoiceUpload {
|
interface PendingVoiceUpload {
|
||||||
lessonId: string
|
lessonId: string
|
||||||
@@ -40,9 +42,21 @@ interface PendingVoiceUpload {
|
|||||||
filePath: string
|
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 MAX_CONTENT_LENGTH = 2000
|
||||||
const RECORDING_SEGMENT_DURATION_MS = 8 * 60 * 1000
|
const RECORDING_SEGMENT_DURATION_MS = 8 * 60 * 1000
|
||||||
const SHORT_RECORDING_DURATION_MS = 60 * 1000
|
|
||||||
const VOICE_POLL_INTERVAL_MS = 3000
|
const VOICE_POLL_INTERVAL_MS = 3000
|
||||||
const PENDING_UPLOADS_KEY = 'teaching-feedback-pending-voice-uploads-v1'
|
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))
|
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 {
|
function logVoiceOperation(event: string, fields: Record<string, unknown>): void {
|
||||||
console.info('voice_operation', { event, ...fields })
|
console.info('voice_operation', { event, ...fields })
|
||||||
}
|
}
|
||||||
@@ -129,6 +174,9 @@ Page({
|
|||||||
voiceStatus: '语音录入',
|
voiceStatus: '语音录入',
|
||||||
voiceFailureHint: '',
|
voiceFailureHint: '',
|
||||||
voiceActionable: false,
|
voiceActionable: false,
|
||||||
|
voiceDetailsExpanded: false,
|
||||||
|
expandedVoiceLessonId: '',
|
||||||
|
voiceLessonViews: [] as VoiceLessonView[],
|
||||||
primaryActionText: '保存反馈',
|
primaryActionText: '保存反馈',
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
errorRequestId: ''
|
errorRequestId: ''
|
||||||
@@ -259,6 +307,9 @@ Page({
|
|||||||
voiceStatus: '语音录入',
|
voiceStatus: '语音录入',
|
||||||
voiceFailureHint: '',
|
voiceFailureHint: '',
|
||||||
voiceActionable: false,
|
voiceActionable: false,
|
||||||
|
voiceDetailsExpanded: false,
|
||||||
|
expandedVoiceLessonId: '',
|
||||||
|
voiceLessonViews: [],
|
||||||
primaryActionText: '保存反馈'
|
primaryActionText: '保存反馈'
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -274,6 +325,7 @@ Page({
|
|||||||
voiceStatus: `${prefix} · ${suffix}`,
|
voiceStatus: `${prefix} · ${suffix}`,
|
||||||
voiceFailureHint: session.failedSegmentCount > 0 ? getVoiceFailureHint(session) : '',
|
voiceFailureHint: session.failedSegmentCount > 0 ? getVoiceFailureHint(session) : '',
|
||||||
voiceActionable: true,
|
voiceActionable: true,
|
||||||
|
voiceLessonViews: buildVoiceLessonViews(session, this.data.draft.content),
|
||||||
primaryActionText: getVoicePrimaryActionText(primaryAction, session.failedSegmentCount)
|
primaryActionText: getVoicePrimaryActionText(primaryAction, session.failedSegmentCount)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -313,20 +365,6 @@ Page({
|
|||||||
if (!pageVisible || profileId !== this.data.draft.profileId) return
|
if (!pageVisible || profileId !== this.data.draft.profileId) return
|
||||||
this.setData({ session, 'draft.feedbackSessionId': session?.id || null })
|
this.setData({ session, 'draft.feedbackSessionId': session?.id || null })
|
||||||
this.syncVoicePresentation(session)
|
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) {
|
} catch (error) {
|
||||||
console.warn('Failed to refresh voice transcription status', error)
|
console.warn('Failed to refresh voice transcription status', error)
|
||||||
this.showApiError(error, false)
|
this.showApiError(error, false)
|
||||||
@@ -357,6 +395,9 @@ Page({
|
|||||||
'draft.content': '',
|
'draft.content': '',
|
||||||
contentLength: 0,
|
contentLength: 0,
|
||||||
voiceActionable: false,
|
voiceActionable: false,
|
||||||
|
voiceDetailsExpanded: false,
|
||||||
|
expandedVoiceLessonId: '',
|
||||||
|
voiceLessonViews: [],
|
||||||
primaryActionText: '保存反馈'
|
primaryActionText: '保存反馈'
|
||||||
})
|
})
|
||||||
void this.loadActiveSession(profileId)
|
void this.loadActiveSession(profileId)
|
||||||
@@ -369,10 +410,38 @@ Page({
|
|||||||
|
|
||||||
onContentInput(event: InputEvent) {
|
onContentInput(event: InputEvent) {
|
||||||
const content = event.detail.value
|
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()
|
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() {
|
scheduleDraftSave() {
|
||||||
this.clearDraftSaveTimer()
|
this.clearDraftSaveTimer()
|
||||||
if (!this.data.session) return
|
if (!this.data.session) return
|
||||||
@@ -633,19 +702,7 @@ Page({
|
|||||||
wx.showToast({ title: '部分录音转录失败,点击状态可重试', icon: 'none' })
|
wx.showToast({ title: '部分录音转录失败,点击状态可重试', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!finished.canApplyDirectly) {
|
wx.showToast({ title: '转录已就绪,可在语音记录中查看', icon: 'none' })
|
||||||
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() {
|
startRecordingTimer() {
|
||||||
@@ -661,7 +718,7 @@ Page({
|
|||||||
recordingTimer = null
|
recordingTimer = null
|
||||||
},
|
},
|
||||||
|
|
||||||
async openVoiceDetails() {
|
async toggleVoiceDetails() {
|
||||||
if (this.data.recording || this.data.voiceBusy) return
|
if (this.data.recording || this.data.voiceBusy) return
|
||||||
if (failedLocalUploads.length > 0) {
|
if (failedLocalUploads.length > 0) {
|
||||||
await this.retryPendingUploads()
|
await this.retryPendingUploads()
|
||||||
@@ -669,20 +726,55 @@ Page({
|
|||||||
}
|
}
|
||||||
const session = this.data.session
|
const session = this.data.session
|
||||||
if (!session || session.lessonCount === 0) return
|
if (!session || session.lessonCount === 0) return
|
||||||
const lines = session.lessons.map((lesson) => {
|
this.setData({
|
||||||
return `第${lesson.sequence}节 · ${formatVoiceDuration(lesson.durationMs)} · ${getVoiceLessonStatusText(lesson)}`
|
voiceDetailsExpanded: !this.data.voiceDetailsExpanded,
|
||||||
|
expandedVoiceLessonId: ''
|
||||||
})
|
})
|
||||||
const hasFailures = session.failedSegmentCount > 0
|
},
|
||||||
wx.showModal({
|
|
||||||
title: '语音记录',
|
toggleVoiceLessonTranscript(event: LessonActionEvent) {
|
||||||
content: lines.join('\n'),
|
const lessonId = event.currentTarget.dataset.lessonId
|
||||||
showCancel: hasFailures,
|
this.setData({
|
||||||
cancelText: '关闭',
|
expandedVoiceLessonId: this.data.expandedVoiceLessonId === lessonId ? '' : lessonId
|
||||||
confirmText: hasFailures ? '重试转录' : '知道了',
|
})
|
||||||
success: (result) => {
|
},
|
||||||
if (hasFailures && result.confirm) void this.retryFailedSegments()
|
|
||||||
|
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() {
|
async retryFailedSegments() {
|
||||||
|
|||||||
@@ -30,7 +30,15 @@
|
|||||||
<view class="content-field">
|
<view class="content-field">
|
||||||
<view class="content-label-row">
|
<view class="content-label-row">
|
||||||
<text class="form-label">反馈内容</text>
|
<text class="form-label">反馈内容</text>
|
||||||
|
<view class="content-actions">
|
||||||
<text class="content-count">{{contentLength}} / 2000</text>
|
<text class="content-count">{{contentLength}} / 2000</text>
|
||||||
|
<button
|
||||||
|
wx:if="{{contentLength > 0}}"
|
||||||
|
class="content-clear-button"
|
||||||
|
disabled="{{recording || voiceBusy}}"
|
||||||
|
bindtap="clearFeedbackContent"
|
||||||
|
>清空</button>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<textarea
|
<textarea
|
||||||
class="feedback-textarea"
|
class="feedback-textarea"
|
||||||
@@ -51,10 +59,50 @@
|
|||||||
<text class="voice-button-icon">{{recording ? '■' : '●'}}</text>
|
<text class="voice-button-icon">{{recording ? '■' : '●'}}</text>
|
||||||
<text>{{recording ? '结束录音' : '语音录入'}}</text>
|
<text>{{recording ? '结束录音' : '语音录入'}}</text>
|
||||||
</button>
|
</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 class="voice-status">{{voiceStatus}}</text>
|
||||||
<text wx:if="{{recording}}" class="recording-time">{{recordingTime}}</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>
|
||||||
<view wx:if="{{session && session.failedSegmentCount > 0}}" class="voice-failure-recovery">
|
<view wx:if="{{session && session.failedSegmentCount > 0}}" class="voice-failure-recovery">
|
||||||
<text class="voice-failure-hint">{{voiceFailureHint}}</text>
|
<text class="voice-failure-hint">{{voiceFailureHint}}</text>
|
||||||
|
|||||||
@@ -44,6 +44,36 @@
|
|||||||
font-size: 23rpx;
|
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 {
|
.feedback-textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 280rpx;
|
min-height: 280rpx;
|
||||||
@@ -154,10 +184,107 @@
|
|||||||
flex: none;
|
flex: none;
|
||||||
margin-left: 10rpx;
|
margin-left: 10rpx;
|
||||||
color: #8e8e93;
|
color: #8e8e93;
|
||||||
font-size: 38rpx;
|
font-size: 30rpx;
|
||||||
line-height: 1;
|
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 {
|
.recording-time {
|
||||||
flex: none;
|
flex: none;
|
||||||
margin-left: 12rpx;
|
margin-left: 12rpx;
|
||||||
|
|||||||
@@ -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 === 'failed') return '转录失败'
|
||||||
if (lesson.status === 'recording') return '录音中'
|
if (lesson.status === 'recording') return '录音中'
|
||||||
if (lesson.status === 'processing') return '处理中'
|
if (lesson.status === 'processing') return '处理中'
|
||||||
if (lesson.appliedDirectly) return '已加入输入框'
|
if (isInDraft) return '已在输入框'
|
||||||
if (lesson.includedInGeneration) return '已生成反馈'
|
if (lesson.includedInGeneration) return '已生成反馈'
|
||||||
return '待生成反馈'
|
if (lesson.appliedDirectly) return '可重新加入'
|
||||||
|
return '可加入输入框'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getVoiceFailureHint(session: FeedbackSession): string {
|
export function getVoiceFailureHint(session: FeedbackSession): string {
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ Authorization: Bearer <access-token>
|
|||||||
| `POST` | `/api/v1/feedback-sessions/{session_id}/lessons` | 开始下一节课堂录音。 |
|
| `POST` | `/api/v1/feedback-sessions/{session_id}/lessons` | 开始下一节课堂录音。 |
|
||||||
| `POST` | `/api/v1/feedback-lessons/{lesson_id}/segments` | 上传一个自动切分的片段并加入后台转录队列。 |
|
| `POST` | `/api/v1/feedback-lessons/{lesson_id}/segments` | 上传一个自动切分的片段并加入后台转录队列。 |
|
||||||
| `POST` | `/api/v1/feedback-lessons/{lesson_id}/finish` | 结束课节并汇总片段状态。 |
|
| `POST` | `/api/v1/feedback-lessons/{lesson_id}/finish` | 结束课节并汇总片段状态。 |
|
||||||
| `POST` | `/api/v1/feedback-lessons/{lesson_id}/mark-applied` | 标记短录音文字已直接加入反馈。 |
|
| `POST` | `/api/v1/feedback-lessons/{lesson_id}/mark-applied` | 标记该节转写已手动加入反馈。 |
|
||||||
| `POST` | `/api/v1/audio-segments/{segment_id}/retry` | 重试失败片段的转录。 |
|
| `POST` | `/api/v1/audio-segments/{segment_id}/retry` | 重试失败片段的转录。 |
|
||||||
| `DELETE` | `/api/v1/audio-segments/{segment_id}` | 放弃失败片段;空课堂会一并删除。 |
|
| `DELETE` | `/api/v1/audio-segments/{segment_id}` | 放弃失败片段;空课堂会一并删除。 |
|
||||||
| `POST` | `/api/v1/feedback-sessions/{session_id}/generate` | 汇总多节转录并生成反馈正文。 |
|
| `POST` | `/api/v1/feedback-sessions/{session_id}/generate` | 汇总多节转录并生成反馈正文。 |
|
||||||
|
|||||||
@@ -124,10 +124,12 @@ Bearer 会话不存在、过期或已撤销时返回 `401`,小程序会重新
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
录音上传接口在音频入库后立即返回 `processing`。Rust 后台工作线程会调用 FunASR,并将片段更新为 `ready` 或 `failed`;小程序会自动刷新状态,短录音转写完成后仍会直接加入反馈正文。
|
录音上传接口在音频入库后立即返回 `processing`。Rust 后台工作线程会调用 FunASR,并将片段更新为 `ready` 或 `failed`;小程序会自动刷新状态,转写完成后可在语音记录中逐节查看和使用。
|
||||||
|
|
||||||
失败片段可以通过 `/retry` 重新进入队列,也可以通过 `DELETE /api/v1/audio-segments/{segment_id}` 放弃。两个接口都只接受当前用户活动反馈批次中的 `failed` 片段,并通过行锁避免重试与删除并发执行。
|
失败片段可以通过 `/retry` 重新进入队列,也可以通过 `DELETE /api/v1/audio-segments/{segment_id}` 放弃。两个接口都只接受当前用户活动反馈批次中的 `failed` 片段,并通过行锁避免重试与删除并发执行。
|
||||||
|
|
||||||
|
活动反馈批次中的每节语音记录会返回按片段顺序合并的 `transcript`。小程序将语音记录作为可复用素材保存,教师可以逐节查看并按需要加入反馈输入框;录音转写完成后不会自动改写教师正在编辑的正文。
|
||||||
|
|
||||||
## 6. OpenAPI 维护方式
|
## 6. OpenAPI 维护方式
|
||||||
|
|
||||||
`/openapi.json` 在服务运行时由 `utoipa` 根据 Rust 路由注解生成,Scalar 使用同一份规范。新增或修改接口时,需要同时更新处理函数上的 `#[utoipa::path]`、请求/响应 schema 注释和示例;规范完整性测试会检查操作数量、接口说明和 Bearer 身份参数。
|
`/openapi.json` 在服务运行时由 `utoipa` 根据 Rust 路由注解生成,Scalar 使用同一份规范。新增或修改接口时,需要同时更新处理函数上的 `#[utoipa::path]`、请求/响应 schema 注释和示例;规范完整性测试会检查操作数量、接口说明和 Bearer 身份参数。
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ struct SegmentRow {
|
|||||||
sequence: i32,
|
sequence: i32,
|
||||||
duration_ms: i32,
|
duration_ms: i32,
|
||||||
status: String,
|
status: String,
|
||||||
|
transcript: Option<String>,
|
||||||
error_message: Option<String>,
|
error_message: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +142,7 @@ struct LessonSummaryResponse {
|
|||||||
duration_ms: i32,
|
duration_ms: i32,
|
||||||
applied_directly: bool,
|
applied_directly: bool,
|
||||||
included_in_generation: bool,
|
included_in_generation: bool,
|
||||||
|
transcript: String,
|
||||||
segments: Vec<AudioSegmentResponse>,
|
segments: Vec<AudioSegmentResponse>,
|
||||||
started_at: DateTime<Utc>,
|
started_at: DateTime<Utc>,
|
||||||
ended_at: Option<DateTime<Utc>>,
|
ended_at: Option<DateTime<Utc>>,
|
||||||
@@ -614,8 +616,8 @@ async fn finish_lesson_session(
|
|||||||
post,
|
post,
|
||||||
path = "/api/v1/feedback-lessons/{lesson_id}/mark-applied",
|
path = "/api/v1/feedback-lessons/{lesson_id}/mark-applied",
|
||||||
tag = "语音反馈",
|
tag = "语音反馈",
|
||||||
summary = "标记短录音已加入反馈",
|
summary = "标记录音已加入反馈",
|
||||||
description = "短录音文字直接加入反馈内容后,标记该节课无需再次汇总。",
|
description = "教师将该节转写手动加入反馈内容后,标记该节课无需再次汇总。",
|
||||||
params(
|
params(
|
||||||
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>"),
|
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>"),
|
||||||
("lesson_id" = Uuid, Path, description = "课堂录音 ID")
|
("lesson_id" = Uuid, Path, description = "课堂录音 ID")
|
||||||
@@ -967,7 +969,8 @@ async fn load_session_response(
|
|||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
let segments = sqlx::query_as::<_, SegmentRow>(
|
let segments = sqlx::query_as::<_, SegmentRow>(
|
||||||
"SELECT s.id, s.lesson_session_id, s.sequence, s.duration_ms, s.status, s.error_message \
|
"SELECT s.id, s.lesson_session_id, s.sequence, s.duration_ms, s.status, \
|
||||||
|
s.transcript, s.error_message \
|
||||||
FROM audio_segments s JOIN lesson_sessions l ON l.id = s.lesson_session_id \
|
FROM audio_segments s JOIN lesson_sessions l ON l.id = s.lesson_session_id \
|
||||||
WHERE l.feedback_session_id = $1 ORDER BY l.sequence, s.sequence",
|
WHERE l.feedback_session_id = $1 ORDER BY l.sequence, s.sequence",
|
||||||
)
|
)
|
||||||
@@ -994,7 +997,16 @@ async fn load_session_response(
|
|||||||
lesson.status == "ready" && !lesson.applied_directly && !lesson.included_in_generation
|
lesson.status == "ready" && !lesson.applied_directly && !lesson.included_in_generation
|
||||||
});
|
});
|
||||||
let mut segments_by_lesson: HashMap<Uuid, Vec<AudioSegmentResponse>> = HashMap::new();
|
let mut segments_by_lesson: HashMap<Uuid, Vec<AudioSegmentResponse>> = HashMap::new();
|
||||||
|
let mut transcripts_by_lesson: HashMap<Uuid, Vec<String>> = HashMap::new();
|
||||||
for segment in segments {
|
for segment in segments {
|
||||||
|
if segment.status == "ready" {
|
||||||
|
if let Some(transcript) = segment.transcript.as_deref() {
|
||||||
|
transcripts_by_lesson
|
||||||
|
.entry(segment.lesson_session_id)
|
||||||
|
.or_default()
|
||||||
|
.push(transcript.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
segments_by_lesson
|
segments_by_lesson
|
||||||
.entry(segment.lesson_session_id)
|
.entry(segment.lesson_session_id)
|
||||||
.or_default()
|
.or_default()
|
||||||
@@ -1016,6 +1028,10 @@ async fn load_session_response(
|
|||||||
duration_ms: lesson.duration_ms,
|
duration_ms: lesson.duration_ms,
|
||||||
applied_directly: lesson.applied_directly,
|
applied_directly: lesson.applied_directly,
|
||||||
included_in_generation: lesson.included_in_generation,
|
included_in_generation: lesson.included_in_generation,
|
||||||
|
transcript: transcripts_by_lesson
|
||||||
|
.remove(&lesson.id)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.join("\n"),
|
||||||
segments: segments_by_lesson.remove(&lesson.id).unwrap_or_default(),
|
segments: segments_by_lesson.remove(&lesson.id).unwrap_or_default(),
|
||||||
started_at: lesson.started_at,
|
started_at: lesson.started_at,
|
||||||
ended_at: lesson.ended_at,
|
ended_at: lesson.ended_at,
|
||||||
|
|||||||
@@ -968,6 +968,10 @@ mod tests {
|
|||||||
assert!(document["info"].get("license").is_none());
|
assert!(document["info"].get("license").is_none());
|
||||||
assert!(document["components"]["schemas"]["ProfileInput"]["example"].is_object());
|
assert!(document["components"]["schemas"]["ProfileInput"]["example"].is_object());
|
||||||
assert!(document["components"]["schemas"]["ProfileDefaultsInput"]["example"].is_object());
|
assert!(document["components"]["schemas"]["ProfileDefaultsInput"]["example"].is_object());
|
||||||
|
assert!(
|
||||||
|
document["components"]["schemas"]["LessonSummaryResponse"]["properties"]["transcript"]
|
||||||
|
.is_object()
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
document["components"]["schemas"]["FeedbackRecordInput"]["example"]["profile_id"]
|
document["components"]["schemas"]["FeedbackRecordInput"]["example"]["profile_id"]
|
||||||
.is_null()
|
.is_null()
|
||||||
|
|||||||
@@ -69,15 +69,16 @@ test('no clear speech failures provide an actionable explanation', () => {
|
|||||||
assert.match(hint, /放弃该失败录音/)
|
assert.match(hint, /放弃该失败录音/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('ready lessons explain whether their transcript is visible or pending generation', () => {
|
test('ready lessons explain whether their transcript can be added again', () => {
|
||||||
const lesson = {
|
const lesson = {
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
appliedDirectly: false,
|
appliedDirectly: false,
|
||||||
includedInGeneration: false
|
includedInGeneration: false
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.equal(getVoiceLessonStatusText(lesson), '待生成反馈')
|
assert.equal(getVoiceLessonStatusText(lesson), '可加入输入框')
|
||||||
assert.equal(getVoiceLessonStatusText({ ...lesson, appliedDirectly: true }), '已加入输入框')
|
assert.equal(getVoiceLessonStatusText({ ...lesson, appliedDirectly: true }), '可重新加入')
|
||||||
|
assert.equal(getVoiceLessonStatusText({ ...lesson, appliedDirectly: true }, true), '已在输入框')
|
||||||
assert.equal(getVoiceLessonStatusText({ ...lesson, includedInGeneration: true }), '已生成反馈')
|
assert.equal(getVoiceLessonStatusText({ ...lesson, includedInGeneration: true }), '已生成反馈')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ interface RawVoiceLesson {
|
|||||||
duration_ms: number
|
duration_ms: number
|
||||||
applied_directly: boolean
|
applied_directly: boolean
|
||||||
included_in_generation: boolean
|
included_in_generation: boolean
|
||||||
|
transcript?: string
|
||||||
segments: RawVoiceAudioSegment[]
|
segments: RawVoiceAudioSegment[]
|
||||||
started_at: string
|
started_at: string
|
||||||
ended_at: string | null
|
ended_at: string | null
|
||||||
@@ -324,6 +325,7 @@ function mapVoiceLesson(lesson: RawVoiceLesson): VoiceLesson {
|
|||||||
durationMs: lesson.duration_ms,
|
durationMs: lesson.duration_ms,
|
||||||
appliedDirectly: lesson.applied_directly,
|
appliedDirectly: lesson.applied_directly,
|
||||||
includedInGeneration: lesson.included_in_generation,
|
includedInGeneration: lesson.included_in_generation,
|
||||||
|
transcript: lesson.transcript || '',
|
||||||
segments: lesson.segments.map(mapVoiceSegment),
|
segments: lesson.segments.map(mapVoiceSegment),
|
||||||
startedAt: parseTimestamp(lesson.started_at),
|
startedAt: parseTimestamp(lesson.started_at),
|
||||||
endedAt: lesson.ended_at ? parseTimestamp(lesson.ended_at) : null
|
endedAt: lesson.ended_at ? parseTimestamp(lesson.ended_at) : null
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export interface VoiceLesson {
|
|||||||
durationMs: number
|
durationMs: number
|
||||||
appliedDirectly: boolean
|
appliedDirectly: boolean
|
||||||
includedInGeneration: boolean
|
includedInGeneration: boolean
|
||||||
|
transcript: string
|
||||||
segments: VoiceAudioSegment[]
|
segments: VoiceAudioSegment[]
|
||||||
startedAt: number
|
startedAt: number
|
||||||
endedAt: number | null
|
endedAt: number | null
|
||||||
|
|||||||
Reference in New Issue
Block a user