feat: restore learning feedback and knowledge capabilities
This commit is contained in:
@@ -39,7 +39,16 @@
|
||||
class="question-block"
|
||||
>
|
||||
<text class="question-meta">第 {{ item.seq }} 题 · {{ item.scenario }}</text>
|
||||
<text class="question-title">{{ item.questionText }}</text>
|
||||
<view class="question-title-row">
|
||||
<text class="question-title">{{ item.questionText }}</text>
|
||||
<button
|
||||
class="text-button question-voice-button"
|
||||
:disabled="speechState.status === 'loading' && speechState.key !== `question-${item.questionId}`"
|
||||
@click="speakQuestion(item)"
|
||||
>
|
||||
{{ speechButtonLabel(`question-${item.questionId}`) }}
|
||||
</button>
|
||||
</view>
|
||||
<text class="question-point">考察点:{{ item.point }}</text>
|
||||
<textarea
|
||||
v-model="interviewAnswers[item.questionId]"
|
||||
@@ -84,6 +93,13 @@
|
||||
<text class="result-score">{{ interviewResult.totalScore }}分</text>
|
||||
</view>
|
||||
<text class="result-desc">{{ interviewResult.aiSummary }}</text>
|
||||
<button
|
||||
class="secondary-button result-voice-button"
|
||||
:disabled="speechState.status === 'loading' && speechState.key !== 'interview-result'"
|
||||
@click="speechPlayback.toggle('interview-result', interviewResultSpeech())"
|
||||
>
|
||||
{{ speechButtonLabel('interview-result') }}
|
||||
</button>
|
||||
<view v-if="interviewResult.dimensions?.length" class="dimension-list">
|
||||
<view v-for="item in interviewResult.dimensions" :key="item.label" class="dimension-row">
|
||||
<view class="dimension-copy">
|
||||
@@ -120,8 +136,14 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import type { AsrResponse, InterviewFinishResponse, InterviewQuestion, InterviewRecord } from '@/types/api';
|
||||
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
|
||||
import type {
|
||||
AsrResponse,
|
||||
InterviewFinishResponse,
|
||||
InterviewQuestion,
|
||||
InterviewRecord,
|
||||
SpeechPlaybackStatus
|
||||
} from '@/types/api';
|
||||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||
import {
|
||||
finishCandidateInterview,
|
||||
@@ -131,7 +153,12 @@ import {
|
||||
} from '@/services/candidate';
|
||||
import { candidateRoutes } from '@/services/candidate-view';
|
||||
import { resetPageScroll } from '@/services/navigation';
|
||||
import { chooseSpeechAudio, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
||||
import {
|
||||
chooseSpeechAudio,
|
||||
createSpeechPlaybackController,
|
||||
transcribeSpeechBlob,
|
||||
transcribeSpeechFile
|
||||
} from '@/services/speech';
|
||||
|
||||
const currentPath = '/pages/candidate/interview/index';
|
||||
const records = ref<InterviewRecord[]>([]);
|
||||
@@ -146,8 +173,20 @@ const loggedIn = ref(false);
|
||||
const message = ref('');
|
||||
const transcribingQuestionId = ref('');
|
||||
const recordingQuestionId = ref('');
|
||||
const recordingQuestionStarting = ref(false);
|
||||
const mediaRecorder = ref<MediaRecorder | null>(null);
|
||||
const mediaStream = ref<MediaStream | null>(null);
|
||||
let questionCaptureGeneration = 0;
|
||||
let questionTranscriptionGeneration = 0;
|
||||
let abortQuestionTranscription: (() => void) | null = null;
|
||||
const speechState = ref<{ key: string; status: SpeechPlaybackStatus }>({ key: '', status: 'idle' });
|
||||
const speechPlayback = createSpeechPlaybackController(
|
||||
(state) => { speechState.value = state; },
|
||||
(text) => {
|
||||
message.value = text;
|
||||
uni.showToast({ title: text, icon: 'none' });
|
||||
}
|
||||
);
|
||||
|
||||
const interviewBusy = computed(() => interviewStatus.value === 'starting' || interviewStatus.value === 'submitting');
|
||||
const answeredCount = computed(() => interviewQuestions.value.filter((item) =>
|
||||
@@ -158,6 +197,7 @@ const refreshAuthState = () => {
|
||||
};
|
||||
|
||||
const clearDraft = () => {
|
||||
speechPlayback.stop();
|
||||
cancelQuestionRecording();
|
||||
interviewQuestions.value = [];
|
||||
interviewAnswers.value = {};
|
||||
@@ -207,7 +247,9 @@ const startInterview = async () => {
|
||||
goLogin();
|
||||
return;
|
||||
}
|
||||
cancelQuestionRecording();
|
||||
interviewStatus.value = 'starting';
|
||||
speechPlayback.stop();
|
||||
interviewQuestions.value = [];
|
||||
interviewAnswers.value = {};
|
||||
interviewAudioOssIds.value = {};
|
||||
@@ -228,12 +270,46 @@ const startInterview = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const speechButtonLabel = (key: string) => {
|
||||
if (speechState.value.key !== key) return '语音播报';
|
||||
if (speechState.value.status === 'loading') return '生成中';
|
||||
if (speechState.value.status === 'playing') return '停止播报';
|
||||
return '语音播报';
|
||||
};
|
||||
|
||||
const speakQuestion = (item: InterviewQuestion) => {
|
||||
void speechPlayback.toggle(
|
||||
`question-${item.questionId}`,
|
||||
[`第${item.seq}题`, item.questionText, item.point ? `考察点,${item.point}` : ''].filter(Boolean).join('。')
|
||||
);
|
||||
};
|
||||
|
||||
const interviewResultSpeech = () => {
|
||||
if (!interviewResult.value) return '';
|
||||
return [
|
||||
`本次面试${interviewResult.value.totalScore}分`,
|
||||
interviewResult.value.result,
|
||||
interviewResult.value.aiSummary,
|
||||
...(interviewResult.value.dimensions || []).map((item) => `${item.label}${item.value}分,${item.note}`)
|
||||
].filter(Boolean).join('。');
|
||||
};
|
||||
|
||||
const stopQuestionMediaTracks = () => {
|
||||
mediaStream.value?.getTracks().forEach((track) => track.stop());
|
||||
mediaStream.value = null;
|
||||
};
|
||||
|
||||
const cancelQuestionTranscription = () => {
|
||||
questionTranscriptionGeneration += 1;
|
||||
abortQuestionTranscription?.();
|
||||
abortQuestionTranscription = null;
|
||||
transcribingQuestionId.value = '';
|
||||
};
|
||||
|
||||
const cancelQuestionRecording = () => {
|
||||
questionCaptureGeneration += 1;
|
||||
cancelQuestionTranscription();
|
||||
recordingQuestionStarting.value = false;
|
||||
const recorder = mediaRecorder.value;
|
||||
if (recorder && recorder.state !== 'inactive') {
|
||||
recorder.ondataavailable = null;
|
||||
@@ -259,41 +335,67 @@ const applyQuestionAsrResult = (questionId: string, result: AsrResponse) => {
|
||||
|
||||
const transcribeQuestionFile = async (questionId: string) => {
|
||||
if (interviewBusy.value || transcribingQuestionId.value) return;
|
||||
const generation = ++questionTranscriptionGeneration;
|
||||
transcribingQuestionId.value = questionId;
|
||||
message.value = '语音转写中';
|
||||
try {
|
||||
applyQuestionAsrResult(questionId, await transcribeSpeechFile(await chooseSpeechAudio()));
|
||||
const file = await chooseSpeechAudio();
|
||||
if (generation !== questionTranscriptionGeneration) return;
|
||||
const result = await transcribeSpeechFile(file, (abort) => {
|
||||
if (generation === questionTranscriptionGeneration) abortQuestionTranscription = abort;
|
||||
});
|
||||
if (generation !== questionTranscriptionGeneration) return;
|
||||
applyQuestionAsrResult(questionId, result);
|
||||
} catch (error) {
|
||||
if (generation !== questionTranscriptionGeneration) return;
|
||||
handleRequestError(error, '语音转写失败');
|
||||
} finally {
|
||||
transcribingQuestionId.value = '';
|
||||
if (generation === questionTranscriptionGeneration) {
|
||||
abortQuestionTranscription = null;
|
||||
transcribingQuestionId.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const transcribeQuestionBlob = async (questionId: string, blob: Blob) => {
|
||||
if (transcribingQuestionId.value) return;
|
||||
const generation = ++questionTranscriptionGeneration;
|
||||
transcribingQuestionId.value = questionId;
|
||||
message.value = '语音转写中';
|
||||
try {
|
||||
applyQuestionAsrResult(questionId, await transcribeSpeechBlob(blob));
|
||||
const result = await transcribeSpeechBlob(blob, 'candidate-answer.webm', (abort) => {
|
||||
if (generation === questionTranscriptionGeneration) abortQuestionTranscription = abort;
|
||||
});
|
||||
if (generation !== questionTranscriptionGeneration) return;
|
||||
applyQuestionAsrResult(questionId, result);
|
||||
} catch (error) {
|
||||
if (generation !== questionTranscriptionGeneration) return;
|
||||
handleRequestError(error, '语音转写失败');
|
||||
} finally {
|
||||
transcribingQuestionId.value = '';
|
||||
if (generation === questionTranscriptionGeneration) {
|
||||
abortQuestionTranscription = null;
|
||||
transcribingQuestionId.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startQuestionRecording = async (questionId: string) => {
|
||||
if (interviewBusy.value || transcribingQuestionId.value) return;
|
||||
if (interviewBusy.value || recordingQuestionStarting.value || recordingQuestionId.value || transcribingQuestionId.value) return;
|
||||
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
||||
await transcribeQuestionFile(questionId);
|
||||
return;
|
||||
}
|
||||
const generation = ++questionCaptureGeneration;
|
||||
recordingQuestionStarting.value = true;
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
if (generation !== questionCaptureGeneration) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
return;
|
||||
}
|
||||
mediaStream.value = stream;
|
||||
const recorder = new MediaRecorder(stream);
|
||||
const chunks: Blob[] = [];
|
||||
mediaStream.value = stream;
|
||||
mediaRecorder.value = recorder;
|
||||
recordingQuestionId.value = questionId;
|
||||
recorder.ondataavailable = (event) => {
|
||||
@@ -314,8 +416,10 @@ const startQuestionRecording = async (questionId: string) => {
|
||||
message.value = '正在录音';
|
||||
} catch {
|
||||
recordingQuestionId.value = '';
|
||||
cancelQuestionRecording();
|
||||
await transcribeQuestionFile(questionId);
|
||||
stopQuestionMediaTracks();
|
||||
if (generation === questionCaptureGeneration) await transcribeQuestionFile(questionId);
|
||||
} finally {
|
||||
if (generation === questionCaptureGeneration) recordingQuestionStarting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -327,6 +431,7 @@ const stopQuestionRecording = () => {
|
||||
};
|
||||
|
||||
const toggleQuestionRecording = (questionId: string) => {
|
||||
if (recordingQuestionStarting.value) return;
|
||||
if (recordingQuestionId.value === questionId) {
|
||||
stopQuestionRecording();
|
||||
} else if (!recordingQuestionId.value) {
|
||||
@@ -401,6 +506,16 @@ onShow(() => {
|
||||
}
|
||||
void loadRecords();
|
||||
});
|
||||
|
||||
onHide(() => {
|
||||
speechPlayback.stop();
|
||||
cancelQuestionRecording();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
speechPlayback.destroy();
|
||||
cancelQuestionRecording();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -426,6 +541,29 @@ onShow(() => {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.question-title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.question-title-row .question-title {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.question-voice-button {
|
||||
flex: none;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.result-voice-button {
|
||||
width: 100%;
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
|
||||
@@ -276,7 +276,7 @@ onShow(() => {
|
||||
border-radius: 50%;
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user