feat(voice): add async selectable summaries
This commit is contained in:
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 '保存反馈'
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user