feat: restore learning feedback and knowledge capabilities

This commit is contained in:
2026-07-18 00:50:11 +08:00
parent 876a23a123
commit 6bdc86e3fc
152 changed files with 30337 additions and 953 deletions
@@ -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;