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' })
}

View File

@@ -62,60 +62,103 @@
<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">{{voiceDetailsExpanded ? '' : ''}}</text>
<view wx:elif="{{voiceActionable}}" class="voice-state-arrow {{voiceDetailsExpanded ? 'voice-state-arrow-expanded' : ''}}"></view>
</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">
<view class="voice-summary-selection-heading">
<text>已选 {{selectedSummaryLessonCount}} / {{eligibleSummaryLessonCount}}</text>
<button
wx:if="{{lesson.transcriptExpandable}}"
wx:if="{{eligibleSummaryLessonCount > 0}}"
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>
disabled="{{recording || voiceBusy || summaryBusy || (summaryRun && (summaryRun.status === 'queued' || summaryRun.status === 'processing'))}}"
bindtap="toggleAllSummaryLessons"
>{{allSummaryLessonsSelected ? '取消全选' : '全选'}}</button>
</view>
</view>
<checkbox-group bindchange="onSummarySelectionChange">
<view
wx:for="{{voiceLessonViews}}"
wx:key="id"
wx:for-item="lesson"
class="voice-record-item"
>
<view class="voice-record-header">
<view class="voice-record-title-row">
<checkbox
wx:if="{{lesson.summaryEligible}}"
class="voice-summary-checkbox"
value="{{lesson.id}}"
checked="{{lesson.summarySelected}}"
disabled="{{recording || voiceBusy || summaryBusy || (summaryRun && (summaryRun.status === 'queued' || summaryRun.status === 'processing'))}}"
color="#007aff"
/>
<view>
<text class="voice-record-title">第{{lesson.sequence}}节</text>
<text class="voice-record-duration">{{lesson.durationText}} · {{lesson.transcriptLength}}字</text>
</view>
</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>
</checkbox-group>
<view wx:if="{{eligibleSummaryLessonCount > 0}}" class="voice-summary-controls">
<button
class="voice-summary-button"
loading="{{summaryBusy}}"
disabled="{{recording || voiceBusy || summaryBusy || selectedSummaryLessonCount === 0 || (summaryRun && (summaryRun.status === 'queued' || summaryRun.status === 'processing'))}}"
bindtap="generateFeedbackSummary"
>{{summaryActionText}}{{selectedSummaryLessonCount}}节)</button>
</view>
<view wx:if="{{summaryRun}}" class="voice-summary-preview {{summaryRun.status === 'failed' ? 'voice-summary-preview-failed' : ''}}">
<view class="voice-summary-preview-heading">
<text>总结预览</text>
<text>{{summaryStatusText}}</text>
</view>
<text wx:if="{{summaryRun.content}}" selectable class="voice-summary-preview-content">{{summaryRun.content}}</text>
<text wx:elif="{{summaryRun.status === 'queued' || summaryRun.status === 'processing'}}" class="voice-summary-preview-note">已选择 {{summaryRun.lessonIds.length}} 节录音</text>
<button
wx:if="{{summaryRun.status === 'ready'}}"
class="voice-summary-apply-button"
loading="{{summaryBusy}}"
disabled="{{summaryBusy}}"
bindtap="applyGeneratedSummary"
>替换输入框</button>
</view>
</view>
<view wx:if="{{session && session.failedSegmentCount > 0}}" class="voice-failure-recovery">
<text class="voice-failure-hint">{{voiceFailureHint}}</text>
<button
class="voice-discard-button"
disabled="{{saving || generating || recording || voiceBusy}}"
disabled="{{saving || summaryBusy || recording || voiceBusy}}"
bindtap="discardFailedSegments"
>放弃失败录音</button>
</view>
</view>
</view>
<button class="save-feedback primary-button" loading="{{saving || generating}}" disabled="{{saving || generating || recording || voiceBusy || (session && session.processingSegmentCount > 0 && session.failedSegmentCount === 0)}}" bindtap="saveFeedback">{{primaryActionText}}</button>
<button class="save-feedback primary-button" loading="{{saving}}" disabled="{{saving || summaryBusy || recording || voiceBusy || (summaryRun && (summaryRun.status === 'queued' || summaryRun.status === 'processing')) || (session && session.processingSegmentCount > 0 && session.failedSegmentCount === 0)}}" bindtap="saveFeedback">{{primaryActionText}}</button>
</view>
<view wx:if="{{!loading && profiles.length === 0}}" class="profiles-hint">

View File

@@ -182,10 +182,18 @@
.voice-state-arrow {
flex: none;
margin-left: 10rpx;
color: #8e8e93;
font-size: 30rpx;
line-height: 1;
width: 14rpx;
height: 14rpx;
margin: -6rpx 6rpx 0 14rpx;
border-right: 3rpx solid #8e8e93;
border-bottom: 3rpx solid #8e8e93;
transform: rotate(45deg);
transition: transform 140ms cubic-bezier(0.23, 1, 0.32, 1);
}
.voice-state-arrow-expanded {
margin-top: 6rpx;
transform: rotate(225deg);
}
.voice-record-list {
@@ -210,6 +218,29 @@
font-weight: 600;
}
.voice-summary-selection-heading,
.voice-record-title-row,
.voice-summary-preview-heading {
display: flex;
align-items: center;
}
.voice-summary-selection-heading {
gap: 14rpx;
}
.voice-record-title-row {
min-width: 0;
gap: 8rpx;
}
.voice-summary-checkbox {
flex: none;
width: 46rpx;
transform: scale(0.8);
transform-origin: left center;
}
.voice-record-item {
padding: 18rpx 0;
border-top: 1rpx solid #e5e5ea;
@@ -285,6 +316,89 @@
opacity: 1;
}
.voice-summary-controls {
padding: 18rpx 0;
border-top: 1rpx solid #e5e5ea;
}
.voice-summary-button,
.voice-summary-apply-button {
width: 100%;
height: 72rpx;
margin: 0;
border: 0;
border-radius: 8rpx;
font-size: 25rpx;
font-weight: 600;
line-height: 72rpx;
}
.voice-summary-button {
background: #eaf3ff;
color: #007aff;
}
.voice-summary-apply-button {
margin-top: 18rpx;
background: #007aff;
color: #ffffff;
}
.voice-summary-button::after,
.voice-summary-apply-button::after {
border: 0;
}
.voice-summary-button[disabled],
.voice-summary-apply-button[disabled] {
background: #f1f3f6;
color: #aeaeb2;
opacity: 1;
}
.voice-summary-preview {
padding: 18rpx 0;
border-top: 1rpx solid #d8d8dc;
}
.voice-summary-preview-failed {
border-top-color: #e8b3b0;
}
.voice-summary-preview-heading {
justify-content: space-between;
gap: 18rpx;
color: #1c1c1e;
font-size: 24rpx;
font-weight: 600;
}
.voice-summary-preview-heading text:last-child {
color: #6e6e73;
font-size: 22rpx;
font-weight: 400;
text-align: right;
}
.voice-summary-preview-failed .voice-summary-preview-heading text:last-child {
color: #b8322d;
}
.voice-summary-preview-content,
.voice-summary-preview-note {
display: block;
margin-top: 14rpx;
color: #3a3a3c;
font-size: 25rpx;
line-height: 1.65;
white-space: pre-wrap;
word-break: break-all;
}
.voice-summary-preview-note {
color: #8e8e93;
}
.recording-time {
flex: none;
margin-left: 12rpx;
@@ -351,7 +465,8 @@
@media (prefers-reduced-motion: reduce) {
.voice-button,
.voice-state-actionable {
.voice-state-actionable,
.voice-state-arrow {
transition: none;
}

View File

@@ -1,12 +1,12 @@
import type { FeedbackSession, VoiceAudioSegment, VoiceLesson } from '../../utils/types'
export type VoicePrimaryAction = 'save' | 'retry' | 'processing' | 'generate'
export type VoicePrimaryAction = 'save' | 'retry' | 'processing'
export function getVoicePrimaryAction(session: FeedbackSession | null): VoicePrimaryAction {
if (!session || session.lessonCount === 0) return 'save'
if (session.failedSegmentCount > 0) return 'retry'
if (session.processingSegmentCount > 0) return 'processing'
return session.needsGeneration ? 'generate' : 'save'
return 'save'
}
export function getVoicePrimaryActionText(
@@ -15,7 +15,6 @@ export function getVoicePrimaryActionText(
): string {
if (action === 'retry') return `重试转录(${failedSegmentCount}`
if (action === 'processing') return '转录中'
if (action === 'generate') return '生成反馈'
return '保存反馈'
}