fix(feedback): make error retry stateful
This commit is contained in:
@@ -38,6 +38,7 @@ type PickerEvent = { detail: { value: string | number } }
|
||||
type InputEvent = { detail: { value: string } }
|
||||
type CheckboxGroupEvent = { detail: { value: string[] } }
|
||||
type LessonActionEvent = { currentTarget: { dataset: { lessonId: string } } }
|
||||
type ErrorRetryAction = 'load' | 'generateSummary'
|
||||
|
||||
interface PendingVoiceUpload {
|
||||
lessonId: string
|
||||
@@ -75,6 +76,7 @@ let voicePollTimer: number | null = null
|
||||
let summaryPollTimer: number | null = null
|
||||
let voicePollBusy = false
|
||||
let summaryPollBusy = false
|
||||
let pendingSummaryIdempotencyKey: string | null = null
|
||||
let pageVisible = false
|
||||
let lessonStartedAt = 0
|
||||
let segmentStartedAt = 0
|
||||
@@ -196,6 +198,8 @@ Page({
|
||||
session: null as FeedbackSession | null,
|
||||
contentLength: 0,
|
||||
loading: false,
|
||||
errorRetrying: false,
|
||||
errorRetryAction: 'load' as ErrorRetryAction,
|
||||
saving: false,
|
||||
summaryBusy: false,
|
||||
summaryRun: null as FeedbackSummaryRun | null,
|
||||
@@ -293,8 +297,11 @@ Page({
|
||||
})
|
||||
},
|
||||
|
||||
async loadProfiles() {
|
||||
this.setData({ loading: true, errorMessage: '', errorRequestId: '' })
|
||||
async loadProfiles(preserveError = false): Promise<boolean> {
|
||||
this.setData(preserveError
|
||||
? { loading: true }
|
||||
: { loading: true, errorMessage: '', errorRequestId: '' })
|
||||
let success = false
|
||||
try {
|
||||
const profiles = await listProfiles()
|
||||
const matchedProfileIndex = profiles.findIndex((profile) => profile.id === this.data.draft.profileId)
|
||||
@@ -306,20 +313,25 @@ Page({
|
||||
selectedProfileIndex,
|
||||
'draft.profileId': profileId
|
||||
})
|
||||
await this.loadActiveSession(profileId)
|
||||
success = await this.loadActiveSession(profileId)
|
||||
} catch (error) {
|
||||
this.setData({ errorMessage: getApiErrorMessage(error), errorRequestId: getApiRequestId(error) })
|
||||
this.setData({
|
||||
errorMessage: getApiErrorMessage(error),
|
||||
errorRequestId: getApiRequestId(error),
|
||||
errorRetryAction: 'load'
|
||||
})
|
||||
} finally {
|
||||
this.setData({ loading: false })
|
||||
}
|
||||
return success
|
||||
},
|
||||
|
||||
async loadActiveSession(profileId?: string | null) {
|
||||
async loadActiveSession(profileId?: string | null): Promise<boolean> {
|
||||
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
|
||||
if (targetProfileId !== this.data.draft.profileId) return false
|
||||
const nextData: Record<string, unknown> = {
|
||||
session,
|
||||
'draft.feedbackSessionId': session?.id || null,
|
||||
@@ -337,8 +349,14 @@ Page({
|
||||
this.syncVoicePresentation(session)
|
||||
this.syncSummaryRun(session?.latestSummaryRun || null)
|
||||
this.scheduleVoicePolling(session)
|
||||
return true
|
||||
} 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)
|
||||
},
|
||||
|
||||
showApiError(error: unknown, showToast = true) {
|
||||
showApiError(
|
||||
error: unknown,
|
||||
showToast = true,
|
||||
retryAction: ErrorRetryAction = 'load'
|
||||
) {
|
||||
const errorMessage = getApiErrorMessage(error)
|
||||
const errorRequestId = getApiRequestId(error)
|
||||
this.setData({ errorMessage, errorRequestId })
|
||||
this.setData({ errorMessage, errorRequestId, errorRetryAction: retryAction })
|
||||
if (showToast) wx.showToast({ title: errorMessage, icon: 'none' })
|
||||
},
|
||||
|
||||
@@ -486,8 +508,17 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
retryLoad() {
|
||||
void this.loadProfiles()
|
||||
async retryError() {
|
||||
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() {
|
||||
@@ -495,7 +526,7 @@ Page({
|
||||
},
|
||||
|
||||
dismissError() {
|
||||
this.setData({ errorMessage: '', errorRequestId: '' })
|
||||
this.setData({ errorMessage: '', errorRequestId: '', errorRetryAction: 'load' })
|
||||
},
|
||||
|
||||
onProfileChange(event: PickerEvent) {
|
||||
@@ -504,6 +535,7 @@ Page({
|
||||
const selectedProfileIndex = Number(event.detail.value)
|
||||
const profile = this.data.profiles[selectedProfileIndex - 1]
|
||||
const profileId = profile?.id || null
|
||||
pendingSummaryIdempotencyKey = null
|
||||
this.setData({
|
||||
selectedProfileIndex,
|
||||
'draft.profileId': profileId,
|
||||
@@ -901,7 +933,9 @@ Page({
|
||||
try {
|
||||
await runWithSessionRefresh(
|
||||
async () => markVoiceLessonApplied(lesson.id, merged.content),
|
||||
async () => this.loadActiveSession()
|
||||
async () => {
|
||||
await this.loadActiveSession()
|
||||
}
|
||||
)
|
||||
wx.showToast({ title: `第${lesson.sequence}节已加入`, icon: 'none' })
|
||||
} catch (error) {
|
||||
@@ -932,28 +966,37 @@ Page({
|
||||
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
|
||||
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') {
|
||||
wx.showToast({ title: '当前总结仍在生成', icon: 'none' })
|
||||
return
|
||||
return false
|
||||
}
|
||||
const lessonIds = this.data.voiceLessonViews
|
||||
.filter((lesson) => lesson.summarySelected)
|
||||
.map((lesson) => lesson.id)
|
||||
if (lessonIds.length === 0) {
|
||||
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 {
|
||||
const run = await createFeedbackSummaryRun(
|
||||
session.id,
|
||||
lessonIds,
|
||||
this.data.draft.content,
|
||||
createSummaryIdempotencyKey(session.id)
|
||||
idempotencyKey
|
||||
)
|
||||
logVoiceOperation('feedback_summary_queued', {
|
||||
feedbackSessionId: session.id,
|
||||
@@ -961,9 +1004,12 @@ Page({
|
||||
lessonIds
|
||||
})
|
||||
this.syncSummaryRun(run)
|
||||
pendingSummaryIdempotencyKey = null
|
||||
wx.showToast({ title: '已开始生成总结', icon: 'none' })
|
||||
return true
|
||||
} catch (error) {
|
||||
this.showApiError(error)
|
||||
this.showApiError(error, true, 'generateSummary')
|
||||
return false
|
||||
} finally {
|
||||
this.setData({ summaryBusy: false })
|
||||
}
|
||||
@@ -1026,7 +1072,9 @@ Page({
|
||||
async () => {
|
||||
for (const segment of failedSegments) await retryVoiceSegment(segment.id)
|
||||
},
|
||||
async () => this.loadActiveSession()
|
||||
async () => {
|
||||
await this.loadActiveSession()
|
||||
}
|
||||
)
|
||||
logVoiceOperation('transcription_retry_submitted', {
|
||||
feedbackSessionId: session.id,
|
||||
@@ -1079,7 +1127,9 @@ Page({
|
||||
async () => {
|
||||
for (const segment of failedSegments) await discardVoiceSegment(segment.id)
|
||||
},
|
||||
async () => this.loadActiveSession()
|
||||
async () => {
|
||||
await this.loadActiveSession()
|
||||
}
|
||||
)
|
||||
logVoiceOperation('failed_transcription_discarded', {
|
||||
feedbackSessionId: session.id,
|
||||
|
||||
@@ -6,19 +6,14 @@
|
||||
<text class="page-subtitle">填写课堂表现并保存到教学反馈记录。</text>
|
||||
|
||||
<view wx:if="{{errorMessage}}" class="status-banner feedback-error-banner">
|
||||
<view class="feedback-error-heading">
|
||||
<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">
|
||||
<view class="feedback-error-close-icon"></view>
|
||||
</button>
|
||||
</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 class="feedback-form surface">
|
||||
|
||||
@@ -8,27 +8,25 @@
|
||||
}
|
||||
|
||||
.feedback-error-banner {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 14rpx;
|
||||
}
|
||||
|
||||
.feedback-error-heading,
|
||||
.feedback-error-footer,
|
||||
.feedback-error-reference {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
padding: 18rpx 20rpx;
|
||||
}
|
||||
|
||||
.feedback-error-heading {
|
||||
justify-content: space-between;
|
||||
gap: 18rpx;
|
||||
.feedback-error-actions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.feedback-error-message {
|
||||
display: block;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.feedback-error-close {
|
||||
@@ -36,9 +34,9 @@
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
margin: -8rpx -8rpx -8rpx 0;
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
@@ -74,50 +72,31 @@
|
||||
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-retry {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 56rpx;
|
||||
height: 52rpx;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 8rpx;
|
||||
font-size: 23rpx;
|
||||
font-size: 22rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.feedback-error-copy {
|
||||
padding: 0;
|
||||
min-width: 68rpx;
|
||||
padding: 0 10rpx;
|
||||
background: transparent;
|
||||
color: #6e6e73;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.feedback-error-retry {
|
||||
min-width: 104rpx;
|
||||
padding: 0 22rpx;
|
||||
min-width: 92rpx;
|
||||
padding: 0 16rpx;
|
||||
background: #007aff;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
@@ -128,6 +107,12 @@
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.feedback-error-retry[disabled] {
|
||||
background: #7eb7f7;
|
||||
color: #ffffff;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.feedback-field,
|
||||
.content-field {
|
||||
padding: 24rpx;
|
||||
|
||||
@@ -192,9 +192,9 @@ export function getApiErrorMessage(error: unknown): string {
|
||||
if (error.statusCode === 401) return '登录状态已失效,请重试'
|
||||
if (error.statusCode === 404) 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') {
|
||||
return '服务暂时无法完成操作,请稍后重试'
|
||||
return '操作未完成,请稍后重试'
|
||||
}
|
||||
return error.message
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user