|
|
|
@@ -48,8 +48,29 @@
|
|
|
|
|
:disabled="interviewStatus !== 'active'"
|
|
|
|
|
placeholder="输入你的真实回答"
|
|
|
|
|
/>
|
|
|
|
|
<view class="question-audio-actions">
|
|
|
|
|
<button
|
|
|
|
|
class="text-button"
|
|
|
|
|
:disabled="interviewBusy || Boolean(transcribingQuestionId) || (Boolean(recordingQuestionId) && recordingQuestionId !== item.questionId)"
|
|
|
|
|
@click="toggleQuestionRecording(item.questionId)"
|
|
|
|
|
>
|
|
|
|
|
{{ recordingQuestionId === item.questionId ? '停止录音' : '录音转文字' }}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
class="text-button"
|
|
|
|
|
:disabled="interviewBusy || Boolean(transcribingQuestionId) || Boolean(recordingQuestionId)"
|
|
|
|
|
@click="pickQuestionAudio(item.questionId)"
|
|
|
|
|
>
|
|
|
|
|
{{ transcribingQuestionId === item.questionId ? '转写中' : '选择音频' }}
|
|
|
|
|
</button>
|
|
|
|
|
<text v-if="interviewAudioOssIds[item.questionId]" class="audio-state">已附带语音留痕</text>
|
|
|
|
|
</view>
|
|
|
|
|
</view>
|
|
|
|
|
<button class="primary-button finish-button" :disabled="interviewStatus !== 'active'" @click="finishInterview">
|
|
|
|
|
<button
|
|
|
|
|
class="primary-button finish-button"
|
|
|
|
|
:disabled="interviewStatus !== 'active' || Boolean(recordingQuestionId) || Boolean(transcribingQuestionId)"
|
|
|
|
|
@click="finishInterview"
|
|
|
|
|
>
|
|
|
|
|
{{ interviewStatus === 'submitting' ? '评分中' : '提交评分' }}
|
|
|
|
|
</button>
|
|
|
|
|
</view>
|
|
|
|
@@ -100,7 +121,7 @@
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { computed, ref } from 'vue';
|
|
|
|
|
import { onShow } from '@dcloudio/uni-app';
|
|
|
|
|
import type { InterviewFinishResponse, InterviewQuestion, InterviewRecord } from '@/types/api';
|
|
|
|
|
import type { AsrResponse, InterviewFinishResponse, InterviewQuestion, InterviewRecord } from '@/types/api';
|
|
|
|
|
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
|
|
|
|
import {
|
|
|
|
|
finishCandidateInterview,
|
|
|
|
@@ -110,17 +131,23 @@ import {
|
|
|
|
|
} from '@/services/candidate';
|
|
|
|
|
import { candidateRoutes } from '@/services/candidate-view';
|
|
|
|
|
import { resetPageScroll } from '@/services/navigation';
|
|
|
|
|
import { chooseSpeechAudio, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
|
|
|
|
|
|
|
|
|
const currentPath = '/pages/candidate/interview/index';
|
|
|
|
|
const records = ref<InterviewRecord[]>([]);
|
|
|
|
|
const interviewQuestions = ref<InterviewQuestion[]>([]);
|
|
|
|
|
const interviewAnswers = ref<Record<string, string>>({});
|
|
|
|
|
const interviewAudioOssIds = ref<Record<string, number | undefined>>({});
|
|
|
|
|
const interviewResult = ref<InterviewFinishResponse | null>(null);
|
|
|
|
|
const interviewSessionId = ref('');
|
|
|
|
|
const interviewStatus = ref<'idle' | 'starting' | 'active' | 'submitting' | 'finished'>('idle');
|
|
|
|
|
const recordsLoading = ref(false);
|
|
|
|
|
const loggedIn = ref(false);
|
|
|
|
|
const message = ref('');
|
|
|
|
|
const transcribingQuestionId = ref('');
|
|
|
|
|
const recordingQuestionId = ref('');
|
|
|
|
|
const mediaRecorder = ref<MediaRecorder | null>(null);
|
|
|
|
|
const mediaStream = ref<MediaStream | null>(null);
|
|
|
|
|
|
|
|
|
|
const interviewBusy = computed(() => interviewStatus.value === 'starting' || interviewStatus.value === 'submitting');
|
|
|
|
|
const answeredCount = computed(() => interviewQuestions.value.filter((item) =>
|
|
|
|
@@ -131,8 +158,10 @@ const refreshAuthState = () => {
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const clearDraft = () => {
|
|
|
|
|
cancelQuestionRecording();
|
|
|
|
|
interviewQuestions.value = [];
|
|
|
|
|
interviewAnswers.value = {};
|
|
|
|
|
interviewAudioOssIds.value = {};
|
|
|
|
|
interviewResult.value = null;
|
|
|
|
|
interviewSessionId.value = '';
|
|
|
|
|
interviewStatus.value = 'idle';
|
|
|
|
@@ -181,6 +210,7 @@ const startInterview = async () => {
|
|
|
|
|
interviewStatus.value = 'starting';
|
|
|
|
|
interviewQuestions.value = [];
|
|
|
|
|
interviewAnswers.value = {};
|
|
|
|
|
interviewAudioOssIds.value = {};
|
|
|
|
|
interviewResult.value = null;
|
|
|
|
|
interviewSessionId.value = '';
|
|
|
|
|
message.value = '';
|
|
|
|
@@ -189,6 +219,7 @@ const startInterview = async () => {
|
|
|
|
|
interviewSessionId.value = data.sessionId;
|
|
|
|
|
interviewQuestions.value = data.questions || [];
|
|
|
|
|
interviewAnswers.value = Object.fromEntries(interviewQuestions.value.map((item) => [item.questionId, '']));
|
|
|
|
|
interviewAudioOssIds.value = {};
|
|
|
|
|
interviewStatus.value = 'active';
|
|
|
|
|
message.value = `已生成 ${interviewQuestions.value.length} 道题`;
|
|
|
|
|
} catch (error) {
|
|
|
|
@@ -197,12 +228,126 @@ const startInterview = async () => {
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const stopQuestionMediaTracks = () => {
|
|
|
|
|
mediaStream.value?.getTracks().forEach((track) => track.stop());
|
|
|
|
|
mediaStream.value = null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const cancelQuestionRecording = () => {
|
|
|
|
|
const recorder = mediaRecorder.value;
|
|
|
|
|
if (recorder && recorder.state !== 'inactive') {
|
|
|
|
|
recorder.ondataavailable = null;
|
|
|
|
|
recorder.onstop = null;
|
|
|
|
|
recorder.stop();
|
|
|
|
|
}
|
|
|
|
|
mediaRecorder.value = null;
|
|
|
|
|
stopQuestionMediaTracks();
|
|
|
|
|
recordingQuestionId.value = '';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const applyQuestionAsrResult = (questionId: string, result: AsrResponse) => {
|
|
|
|
|
const text = (result.text || '').trim();
|
|
|
|
|
if (!text) throw new Error('语音没有识别到文字');
|
|
|
|
|
interviewAnswers.value = { ...interviewAnswers.value, [questionId]: text };
|
|
|
|
|
const ossId = Number(result.ossId);
|
|
|
|
|
interviewAudioOssIds.value = {
|
|
|
|
|
...interviewAudioOssIds.value,
|
|
|
|
|
[questionId]: Number.isFinite(ossId) ? ossId : undefined
|
|
|
|
|
};
|
|
|
|
|
message.value = '语音已转成文字,请确认后提交';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const transcribeQuestionFile = async (questionId: string) => {
|
|
|
|
|
if (interviewBusy.value || transcribingQuestionId.value) return;
|
|
|
|
|
transcribingQuestionId.value = questionId;
|
|
|
|
|
message.value = '语音转写中';
|
|
|
|
|
try {
|
|
|
|
|
applyQuestionAsrResult(questionId, await transcribeSpeechFile(await chooseSpeechAudio()));
|
|
|
|
|
} catch (error) {
|
|
|
|
|
handleRequestError(error, '语音转写失败');
|
|
|
|
|
} finally {
|
|
|
|
|
transcribingQuestionId.value = '';
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const transcribeQuestionBlob = async (questionId: string, blob: Blob) => {
|
|
|
|
|
if (transcribingQuestionId.value) return;
|
|
|
|
|
transcribingQuestionId.value = questionId;
|
|
|
|
|
message.value = '语音转写中';
|
|
|
|
|
try {
|
|
|
|
|
applyQuestionAsrResult(questionId, await transcribeSpeechBlob(blob));
|
|
|
|
|
} catch (error) {
|
|
|
|
|
handleRequestError(error, '语音转写失败');
|
|
|
|
|
} finally {
|
|
|
|
|
transcribingQuestionId.value = '';
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const startQuestionRecording = async (questionId: string) => {
|
|
|
|
|
if (interviewBusy.value || transcribingQuestionId.value) return;
|
|
|
|
|
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
|
|
|
|
await transcribeQuestionFile(questionId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
|
|
|
const recorder = new MediaRecorder(stream);
|
|
|
|
|
const chunks: Blob[] = [];
|
|
|
|
|
mediaStream.value = stream;
|
|
|
|
|
mediaRecorder.value = recorder;
|
|
|
|
|
recordingQuestionId.value = questionId;
|
|
|
|
|
recorder.ondataavailable = (event) => {
|
|
|
|
|
if (event.data?.size) chunks.push(event.data);
|
|
|
|
|
};
|
|
|
|
|
recorder.onstop = () => {
|
|
|
|
|
mediaRecorder.value = null;
|
|
|
|
|
recordingQuestionId.value = '';
|
|
|
|
|
stopQuestionMediaTracks();
|
|
|
|
|
const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' });
|
|
|
|
|
if (!blob.size) {
|
|
|
|
|
message.value = '未录到音频,请改用文字输入';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
void transcribeQuestionBlob(questionId, blob);
|
|
|
|
|
};
|
|
|
|
|
recorder.start();
|
|
|
|
|
message.value = '正在录音';
|
|
|
|
|
} catch {
|
|
|
|
|
recordingQuestionId.value = '';
|
|
|
|
|
cancelQuestionRecording();
|
|
|
|
|
await transcribeQuestionFile(questionId);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const stopQuestionRecording = () => {
|
|
|
|
|
const recorder = mediaRecorder.value;
|
|
|
|
|
if (!recordingQuestionId.value || !recorder || recorder.state === 'inactive') return;
|
|
|
|
|
message.value = '录音结束,正在转写';
|
|
|
|
|
recorder.stop();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const toggleQuestionRecording = (questionId: string) => {
|
|
|
|
|
if (recordingQuestionId.value === questionId) {
|
|
|
|
|
stopQuestionRecording();
|
|
|
|
|
} else if (!recordingQuestionId.value) {
|
|
|
|
|
void startQuestionRecording(questionId);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const pickQuestionAudio = (questionId: string) => {
|
|
|
|
|
void transcribeQuestionFile(questionId);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const finishInterview = async () => {
|
|
|
|
|
if (interviewBusy.value) return;
|
|
|
|
|
if (interviewStatus.value !== 'active' || !interviewSessionId.value) {
|
|
|
|
|
message.value = '请先开始面试';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (recordingQuestionId.value || transcribingQuestionId.value) {
|
|
|
|
|
message.value = '请先完成语音转写';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const answers = Object.fromEntries(
|
|
|
|
|
Object.entries(interviewAnswers.value).map(([key, value]) => [key, value.trim()])
|
|
|
|
|
);
|
|
|
|
@@ -215,7 +360,7 @@ const finishInterview = async () => {
|
|
|
|
|
message.value = '';
|
|
|
|
|
try {
|
|
|
|
|
await Promise.all(Object.entries(answers).map(([questionId, answer]) =>
|
|
|
|
|
saveInterviewAnswer(interviewSessionId.value, questionId, answer)
|
|
|
|
|
saveInterviewAnswer(interviewSessionId.value, questionId, answer, interviewAudioOssIds.value[questionId])
|
|
|
|
|
));
|
|
|
|
|
interviewResult.value = await finishCandidateInterview(interviewSessionId.value, answers);
|
|
|
|
|
records.value = interviewResult.value.records?.length
|
|
|
|
@@ -368,6 +513,26 @@ onShow(() => {
|
|
|
|
|
margin-top: 12px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.question-audio-actions {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.question-audio-actions .text-button {
|
|
|
|
|
min-height: 32px;
|
|
|
|
|
padding: 0 8px;
|
|
|
|
|
border: 1px solid #e2e8f0;
|
|
|
|
|
background: #ffffff;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.audio-state {
|
|
|
|
|
color: #64748b;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.result-title {
|
|
|
|
|
display: block;
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|