feat(mobile): add voice transcription to practice

This commit is contained in:
2026-07-09 13:41:48 +08:00
parent 04405b5e11
commit 4ef388873b
6 changed files with 245 additions and 2 deletions
+156 -2
View File
@@ -126,6 +126,12 @@
placeholder="先承接业主情绪,再说明处理动作和反馈时间。"
/>
<view class="reply-actions">
<button class="secondary-button" :disabled="busy || transcribing" @click="toggleRecording">
{{ recording ? '停止录音' : '录音转写' }}
</button>
<button class="ghost-button" :disabled="busy || recording || transcribing" @click="pickAudio">
{{ transcribing ? '转写中' : '选音频' }}
</button>
<button class="secondary-button" :disabled="busy || helpLoading" @click="loadHelp">
{{ helpLoading ? '求助中' : '求助' }}
</button>
@@ -134,6 +140,7 @@
{{ status === 'submitting' ? '提交中' : '提交回应' }}
</button>
</view>
<text v-if="draftAudioUrl" class="voice-state">已附带语音回放</text>
</view>
<view v-if="helpAnswer || helpRefs.length" class="panel help-card">
@@ -170,6 +177,7 @@ import { answerDailyDrill, finishPractice, getPracticeAssignments, getPracticePr
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { searchKnowledge } from '@/services/knowledge';
import { resetPageScroll } from '@/services/navigation';
import { chooseSpeechAudio, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished';
@@ -203,6 +211,12 @@ const answeringId = ref<number | null>(null);
const activeAssignmentId = ref<number | undefined>();
const highlightedAssignmentId = ref<number | null>(null);
const loggedIn = ref(false);
const recording = ref(false);
const transcribing = ref(false);
const draftAudioUrl = ref('');
const draftAudioOssId = ref<number | undefined>();
const mediaRecorder = ref<MediaRecorder | null>(null);
const mediaStream = ref<MediaStream | null>(null);
const busy = computed(() => status.value === 'starting' || status.value === 'submitting');
const canStartPractice = computed(() => status.value === 'idle' || status.value === 'finished');
const hasTraineeReply = computed(() => turns.value.some((item) => item.role === 'trainee'));
@@ -237,6 +251,7 @@ const refreshAuthState = () => {
};
const clearPracticeData = () => {
cancelRecording();
assignments.value = [];
drillAnswers.value = {};
prepCard.value = null;
@@ -261,6 +276,8 @@ const clearPracticeData = () => {
answeringId.value = null;
activeAssignmentId.value = undefined;
highlightedAssignmentId.value = null;
transcribing.value = false;
clearDraftAudio();
};
const requireLogin = () => {
@@ -276,6 +293,117 @@ const appendTurn = (role: PracticeRole, text?: string) => {
turns.value.push({ role, text });
};
const clearDraftAudio = () => {
draftAudioUrl.value = '';
draftAudioOssId.value = undefined;
};
const stopMediaTracks = () => {
mediaStream.value?.getTracks().forEach((track) => track.stop());
mediaStream.value = null;
};
const cancelRecording = () => {
const recorder = mediaRecorder.value;
if (recorder && recorder.state !== 'inactive') {
recorder.ondataavailable = null;
recorder.onstop = null;
recorder.stop();
}
mediaRecorder.value = null;
stopMediaTracks();
recording.value = false;
};
const applyAsrResult = (result: { text?: string; audioUrl?: string; ossId?: number | string }) => {
const text = (result.text || '').trim();
if (!text) throw new Error('语音没有识别到文字');
const current = draft.value.trim();
draft.value = current ? `${current}\n${text}` : text;
draftAudioUrl.value = result.audioUrl || '';
const ossId = Number(result.ossId);
draftAudioOssId.value = Number.isFinite(ossId) ? ossId : undefined;
message.value = '语音已转成文字,请确认后提交';
};
const transcribeBlob = async (blob: Blob) => {
if (transcribing.value) return;
transcribing.value = true;
message.value = '语音转写中';
try {
applyAsrResult(await transcribeSpeechBlob(blob));
} catch (error) {
handleError(error, '语音转写失败');
} finally {
transcribing.value = false;
}
};
const pickAudio = async () => {
if (busy.value || recording.value || transcribing.value) return;
if (!requireLogin()) return;
transcribing.value = true;
message.value = '语音转写中';
try {
const file = await chooseSpeechAudio();
applyAsrResult(await transcribeSpeechFile(file));
} catch (error) {
handleError(error, '语音转写失败');
} finally {
transcribing.value = false;
}
};
const startRecording = async () => {
if (busy.value || transcribing.value) return;
if (!requireLogin()) return;
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
await pickAudio();
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
const chunks: Blob[] = [];
mediaStream.value = stream;
mediaRecorder.value = recorder;
recorder.ondataavailable = (event) => {
if (event.data?.size) chunks.push(event.data);
};
recorder.onstop = () => {
recording.value = false;
mediaRecorder.value = null;
stopMediaTracks();
const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' });
if (!blob.size) {
message.value = '未录到音频,请改用文字输入';
return;
}
void transcribeBlob(blob);
};
recorder.start();
recording.value = true;
message.value = '正在录音';
} catch (error) {
await pickAudio();
}
};
const stopRecording = () => {
const recorder = mediaRecorder.value;
if (!recording.value || !recorder || recorder.state === 'inactive') return;
message.value = '录音结束,正在转写';
recorder.stop();
};
const toggleRecording = () => {
if (recording.value) {
stopRecording();
return;
}
void startRecording();
};
const handleError = (error: unknown, fallback: string) => {
const text = error instanceof Error ? error.message : fallback;
refreshAuthState();
@@ -386,6 +514,7 @@ const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
result.value = null;
turns.value = [];
draft.value = '';
clearDraftAudio();
helpAnswer.value = '';
helpRefs.value = [];
lastTraineeText.value = '';
@@ -482,7 +611,10 @@ const submit = async () => {
}
status.value = 'submitting';
message.value = '';
const audioUrl = draftAudioUrl.value || undefined;
const audioOssId = draftAudioOssId.value;
draft.value = '';
clearDraftAudio();
const retryingRedFlag = redFlagActive.value && lastCoachRoundIndex.value === roundIndex.value;
if (retryingRedFlag) discardLastCoachAttempt();
appendTurn('trainee', text);
@@ -491,7 +623,9 @@ const submit = async () => {
const data = await submitPracticeTurn({
sessionId: sessionId.value,
roundIndex: submittedRound,
traineeText: text
traineeText: text,
traineeAudioUrl: audioUrl,
traineeAudioOssId: audioOssId
});
lastTraineeText.value = text;
lastCoachRoundIndex.value = submittedRound;
@@ -513,6 +647,9 @@ const submit = async () => {
status.value = 'active';
} catch (error) {
status.value = 'active';
draft.value = text;
draftAudioUrl.value = audioUrl || '';
draftAudioOssId.value = audioOssId;
handleError(error, '提交失败');
}
};
@@ -699,10 +836,27 @@ onShow(() => {
.reply-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.reply-actions {
flex-wrap: wrap;
}
.reply-actions button {
min-height: 48px;
min-width: 92px;
flex: 1;
white-space: nowrap;
word-break: keep-all;
}
.voice-state {
color: #2563eb;
font-size: 13px;
font-weight: 800;
}
.round-label {
color: #64748b;
font-size: 13px;