fix(feedback): make error retry stateful

This commit is contained in:
zhangheng
2026-07-22 20:08:21 +08:00
parent 50b3a1b997
commit fb4adbac76
4 changed files with 103 additions and 73 deletions

View File

@@ -38,6 +38,7 @@ type PickerEvent = { detail: { value: string | number } }
type InputEvent = { detail: { value: string } } type InputEvent = { detail: { value: string } }
type CheckboxGroupEvent = { detail: { value: string[] } } type CheckboxGroupEvent = { detail: { value: string[] } }
type LessonActionEvent = { currentTarget: { dataset: { lessonId: string } } } type LessonActionEvent = { currentTarget: { dataset: { lessonId: string } } }
type ErrorRetryAction = 'load' | 'generateSummary'
interface PendingVoiceUpload { interface PendingVoiceUpload {
lessonId: string lessonId: string
@@ -75,6 +76,7 @@ let voicePollTimer: number | null = null
let summaryPollTimer: number | null = null let summaryPollTimer: number | null = null
let voicePollBusy = false let voicePollBusy = false
let summaryPollBusy = false let summaryPollBusy = false
let pendingSummaryIdempotencyKey: string | null = null
let pageVisible = false let pageVisible = false
let lessonStartedAt = 0 let lessonStartedAt = 0
let segmentStartedAt = 0 let segmentStartedAt = 0
@@ -196,6 +198,8 @@ Page({
session: null as FeedbackSession | null, session: null as FeedbackSession | null,
contentLength: 0, contentLength: 0,
loading: false, loading: false,
errorRetrying: false,
errorRetryAction: 'load' as ErrorRetryAction,
saving: false, saving: false,
summaryBusy: false, summaryBusy: false,
summaryRun: null as FeedbackSummaryRun | null, summaryRun: null as FeedbackSummaryRun | null,
@@ -293,8 +297,11 @@ Page({
}) })
}, },
async loadProfiles() { async loadProfiles(preserveError = false): Promise<boolean> {
this.setData({ loading: true, errorMessage: '', errorRequestId: '' }) this.setData(preserveError
? { loading: true }
: { loading: true, errorMessage: '', errorRequestId: '' })
let success = false
try { try {
const profiles = await listProfiles() const profiles = await listProfiles()
const matchedProfileIndex = profiles.findIndex((profile) => profile.id === this.data.draft.profileId) const matchedProfileIndex = profiles.findIndex((profile) => profile.id === this.data.draft.profileId)
@@ -306,20 +313,25 @@ Page({
selectedProfileIndex, selectedProfileIndex,
'draft.profileId': profileId 'draft.profileId': profileId
}) })
await this.loadActiveSession(profileId) success = await this.loadActiveSession(profileId)
} catch (error) { } catch (error) {
this.setData({ errorMessage: getApiErrorMessage(error), errorRequestId: getApiRequestId(error) }) this.setData({
errorMessage: getApiErrorMessage(error),
errorRequestId: getApiRequestId(error),
errorRetryAction: 'load'
})
} finally { } finally {
this.setData({ loading: false }) this.setData({ loading: false })
} }
return success
}, },
async loadActiveSession(profileId?: string | null) { async loadActiveSession(profileId?: string | null): Promise<boolean> {
try { try {
const targetProfileId = profileId === undefined ? this.data.draft.profileId : profileId const targetProfileId = profileId === undefined ? this.data.draft.profileId : profileId
const previousSessionId = this.data.session?.id || null const previousSessionId = this.data.session?.id || null
const session = await getActiveFeedbackSession(targetProfileId) const session = await getActiveFeedbackSession(targetProfileId)
if (targetProfileId !== this.data.draft.profileId) return if (targetProfileId !== this.data.draft.profileId) return false
const nextData: Record<string, unknown> = { const nextData: Record<string, unknown> = {
session, session,
'draft.feedbackSessionId': session?.id || null, 'draft.feedbackSessionId': session?.id || null,
@@ -337,8 +349,14 @@ Page({
this.syncVoicePresentation(session) this.syncVoicePresentation(session)
this.syncSummaryRun(session?.latestSummaryRun || null) this.syncSummaryRun(session?.latestSummaryRun || null)
this.scheduleVoicePolling(session) this.scheduleVoicePolling(session)
return true
} catch (error) { } catch (error) {
this.setData({ errorMessage: getApiErrorMessage(error), errorRequestId: getApiRequestId(error) }) this.setData({
errorMessage: getApiErrorMessage(error),
errorRequestId: getApiRequestId(error),
errorRetryAction: 'load'
})
return false
} }
}, },
@@ -401,10 +419,14 @@ Page({
this.scheduleSummaryPolling(run) this.scheduleSummaryPolling(run)
}, },
showApiError(error: unknown, showToast = true) { showApiError(
error: unknown,
showToast = true,
retryAction: ErrorRetryAction = 'load'
) {
const errorMessage = getApiErrorMessage(error) const errorMessage = getApiErrorMessage(error)
const errorRequestId = getApiRequestId(error) const errorRequestId = getApiRequestId(error)
this.setData({ errorMessage, errorRequestId }) this.setData({ errorMessage, errorRequestId, errorRetryAction: retryAction })
if (showToast) wx.showToast({ title: errorMessage, icon: 'none' }) if (showToast) wx.showToast({ title: errorMessage, icon: 'none' })
}, },
@@ -486,8 +508,17 @@ Page({
} }
}, },
retryLoad() { async retryError() {
void this.loadProfiles() if (this.data.loading || this.data.errorRetrying) return
this.setData({ errorRetrying: true })
try {
const success = this.data.errorRetryAction === 'generateSummary'
? await this.submitFeedbackSummary(true)
: await this.loadProfiles(true)
if (success) this.dismissError()
} finally {
this.setData({ errorRetrying: false })
}
}, },
copyRequestId() { copyRequestId() {
@@ -495,7 +526,7 @@ Page({
}, },
dismissError() { dismissError() {
this.setData({ errorMessage: '', errorRequestId: '' }) this.setData({ errorMessage: '', errorRequestId: '', errorRetryAction: 'load' })
}, },
onProfileChange(event: PickerEvent) { onProfileChange(event: PickerEvent) {
@@ -504,6 +535,7 @@ Page({
const selectedProfileIndex = Number(event.detail.value) const selectedProfileIndex = Number(event.detail.value)
const profile = this.data.profiles[selectedProfileIndex - 1] const profile = this.data.profiles[selectedProfileIndex - 1]
const profileId = profile?.id || null const profileId = profile?.id || null
pendingSummaryIdempotencyKey = null
this.setData({ this.setData({
selectedProfileIndex, selectedProfileIndex,
'draft.profileId': profileId, 'draft.profileId': profileId,
@@ -901,7 +933,9 @@ Page({
try { try {
await runWithSessionRefresh( await runWithSessionRefresh(
async () => markVoiceLessonApplied(lesson.id, merged.content), async () => markVoiceLessonApplied(lesson.id, merged.content),
async () => this.loadActiveSession() async () => {
await this.loadActiveSession()
}
) )
wx.showToast({ title: `${lesson.sequence}节已加入`, icon: 'none' }) wx.showToast({ title: `${lesson.sequence}节已加入`, icon: 'none' })
} catch (error) { } catch (error) {
@@ -932,28 +966,37 @@ Page({
this.syncVoicePresentation(this.data.session) this.syncVoicePresentation(this.data.session)
}, },
async generateFeedbackSummary() { generateFeedbackSummary(): Promise<boolean> {
pendingSummaryIdempotencyKey = null
return this.submitFeedbackSummary(false)
},
async submitFeedbackSummary(preserveError: boolean): Promise<boolean> {
const session = this.data.session const session = this.data.session
if (!session || this.data.summaryBusy || this.data.recording || this.data.voiceBusy) return if (!session || this.data.summaryBusy || this.data.recording || this.data.voiceBusy) return false
if (this.data.summaryRun?.status === 'queued' || this.data.summaryRun?.status === 'processing') { if (this.data.summaryRun?.status === 'queued' || this.data.summaryRun?.status === 'processing') {
wx.showToast({ title: '当前总结仍在生成', icon: 'none' }) wx.showToast({ title: '当前总结仍在生成', icon: 'none' })
return return false
} }
const lessonIds = this.data.voiceLessonViews const lessonIds = this.data.voiceLessonViews
.filter((lesson) => lesson.summarySelected) .filter((lesson) => lesson.summarySelected)
.map((lesson) => lesson.id) .map((lesson) => lesson.id)
if (lessonIds.length === 0) { if (lessonIds.length === 0) {
wx.showToast({ title: '请至少选择一节已就绪录音', icon: 'none' }) wx.showToast({ title: '请至少选择一节已就绪录音', icon: 'none' })
return return false
} }
this.setData({ summaryBusy: true, errorMessage: '', errorRequestId: '' }) const idempotencyKey = pendingSummaryIdempotencyKey || createSummaryIdempotencyKey(session.id)
pendingSummaryIdempotencyKey = idempotencyKey
this.setData(preserveError
? { summaryBusy: true }
: { summaryBusy: true, errorMessage: '', errorRequestId: '' })
try { try {
const run = await createFeedbackSummaryRun( const run = await createFeedbackSummaryRun(
session.id, session.id,
lessonIds, lessonIds,
this.data.draft.content, this.data.draft.content,
createSummaryIdempotencyKey(session.id) idempotencyKey
) )
logVoiceOperation('feedback_summary_queued', { logVoiceOperation('feedback_summary_queued', {
feedbackSessionId: session.id, feedbackSessionId: session.id,
@@ -961,9 +1004,12 @@ Page({
lessonIds lessonIds
}) })
this.syncSummaryRun(run) this.syncSummaryRun(run)
pendingSummaryIdempotencyKey = null
wx.showToast({ title: '已开始生成总结', icon: 'none' }) wx.showToast({ title: '已开始生成总结', icon: 'none' })
return true
} catch (error) { } catch (error) {
this.showApiError(error) this.showApiError(error, true, 'generateSummary')
return false
} finally { } finally {
this.setData({ summaryBusy: false }) this.setData({ summaryBusy: false })
} }
@@ -1026,7 +1072,9 @@ Page({
async () => { async () => {
for (const segment of failedSegments) await retryVoiceSegment(segment.id) for (const segment of failedSegments) await retryVoiceSegment(segment.id)
}, },
async () => this.loadActiveSession() async () => {
await this.loadActiveSession()
}
) )
logVoiceOperation('transcription_retry_submitted', { logVoiceOperation('transcription_retry_submitted', {
feedbackSessionId: session.id, feedbackSessionId: session.id,
@@ -1079,7 +1127,9 @@ Page({
async () => { async () => {
for (const segment of failedSegments) await discardVoiceSegment(segment.id) for (const segment of failedSegments) await discardVoiceSegment(segment.id)
}, },
async () => this.loadActiveSession() async () => {
await this.loadActiveSession()
}
) )
logVoiceOperation('failed_transcription_discarded', { logVoiceOperation('failed_transcription_discarded', {
feedbackSessionId: session.id, feedbackSessionId: session.id,

View File

@@ -6,19 +6,14 @@
<text class="page-subtitle">填写课堂表现并保存到教学反馈记录。</text> <text class="page-subtitle">填写课堂表现并保存到教学反馈记录。</text>
<view wx:if="{{errorMessage}}" class="status-banner feedback-error-banner"> <view wx:if="{{errorMessage}}" class="status-banner feedback-error-banner">
<view class="feedback-error-heading"> <text class="feedback-error-message">{{errorMessage}}</text>
<text class="feedback-error-message">{{errorMessage}}</text> <view class="feedback-error-actions">
<button wx:if="{{errorRequestId}}" class="feedback-error-copy" aria-label="复制问题编号" bindtap="copyRequestId">复制</button>
<button class="feedback-error-retry" loading="{{errorRetrying}}" disabled="{{errorRetrying}}" bindtap="retryError">重试</button>
<button class="feedback-error-close" aria-label="关闭提示" bindtap="dismissError"> <button class="feedback-error-close" aria-label="关闭提示" bindtap="dismissError">
<view class="feedback-error-close-icon"></view> <view class="feedback-error-close-icon"></view>
</button> </button>
</view> </view>
<view class="feedback-error-footer">
<view wx:if="{{errorRequestId}}" class="feedback-error-reference">
<text>问题编号:{{errorRequestId}}</text>
<button class="feedback-error-copy" bindtap="copyRequestId">复制编号</button>
</view>
<button class="feedback-error-retry" bindtap="retryLoad">重试</button>
</view>
</view> </view>
<view class="feedback-form surface"> <view class="feedback-form surface">

View File

@@ -8,27 +8,25 @@
} }
.feedback-error-banner { .feedback-error-banner {
flex-direction: column; width: 100%;
align-items: stretch;
gap: 14rpx;
}
.feedback-error-heading,
.feedback-error-footer,
.feedback-error-reference {
display: flex;
align-items: center; align-items: center;
gap: 16rpx;
padding: 18rpx 20rpx;
} }
.feedback-error-heading { .feedback-error-actions {
justify-content: space-between; display: flex;
gap: 18rpx; flex: none;
align-items: center;
gap: 8rpx;
} }
.feedback-error-message { .feedback-error-message {
display: block; display: block;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
font-size: 24rpx;
line-height: 1.4;
} }
.feedback-error-close { .feedback-error-close {
@@ -36,9 +34,9 @@
flex: none; flex: none;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 48rpx; width: 44rpx;
height: 48rpx; height: 44rpx;
margin: -8rpx -8rpx -8rpx 0; margin: 0;
padding: 0; padding: 0;
border: 0; border: 0;
background: transparent; background: transparent;
@@ -74,50 +72,31 @@
transform: rotate(-45deg); transform: rotate(-45deg);
} }
.feedback-error-footer {
justify-content: flex-end;
gap: 18rpx;
}
.feedback-error-reference {
flex: 1;
min-width: 0;
gap: 12rpx;
color: #6e6e73;
font-size: 21rpx;
}
.feedback-error-reference > text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.feedback-error-copy, .feedback-error-copy,
.feedback-error-retry { .feedback-error-retry {
display: flex; display: flex;
flex: none; flex: none;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 56rpx; height: 52rpx;
margin: 0; margin: 0;
border: 0; border: 0;
border-radius: 8rpx; border-radius: 8rpx;
font-size: 23rpx; font-size: 22rpx;
line-height: 1; line-height: 1;
} }
.feedback-error-copy { .feedback-error-copy {
padding: 0; min-width: 68rpx;
padding: 0 10rpx;
background: transparent; background: transparent;
color: #6e6e73; color: #6e6e73;
font-weight: 500; font-weight: 500;
} }
.feedback-error-retry { .feedback-error-retry {
min-width: 104rpx; min-width: 92rpx;
padding: 0 22rpx; padding: 0 16rpx;
background: #007aff; background: #007aff;
color: #ffffff; color: #ffffff;
font-weight: 600; font-weight: 600;
@@ -128,6 +107,12 @@
border: 0; border: 0;
} }
.feedback-error-retry[disabled] {
background: #7eb7f7;
color: #ffffff;
opacity: 1;
}
.feedback-field, .feedback-field,
.content-field { .content-field {
padding: 24rpx; padding: 24rpx;

View File

@@ -192,9 +192,9 @@ export function getApiErrorMessage(error: unknown): string {
if (error.statusCode === 401) return '登录状态已失效,请重试' if (error.statusCode === 401) return '登录状态已失效,请重试'
if (error.statusCode === 404) return '请求的数据不存在或无权访问' if (error.statusCode === 404) return '请求的数据不存在或无权访问'
if (error.statusCode === 503 && error.message.includes('database')) return '后端尚未配置数据库连接' if (error.statusCode === 503 && error.message.includes('database')) return '后端尚未配置数据库连接'
if (error.statusCode && error.statusCode >= 500) return '服务暂时无法完成操作,请稍后重试' if (error.statusCode && error.statusCode >= 500) return '操作未完成,请稍后重试'
if (error.message.toLowerCase() === 'internal server error') { if (error.message.toLowerCase() === 'internal server error') {
return '服务暂时无法完成操作,请稍后重试' return '操作未完成,请稍后重试'
} }
return error.message return error.message
} }