Files
prop-ai-hr/mobile-uni/src/pages/candidate/interview/index.vue
T
admin c47bf748cb fix(mobile): replace emoji glyphs with uni-icons and flex centering app-wide
- ChatComposer panel contract: callers now pass uni-icons type names
  (image/sound/list/help/checkmark) instead of emoji
- voice playback buttons: sound icon + pulse animation, no more 🔊/◼ chars
- practice recent list: rare symbols ⌕⌁◉ (tofu risk) -> wallet/gear/chatbubble
- sop resource icon ▶/文 -> 视/文 character badges
- back arrows: 22 char-based ← -> uni-icons back across 21 pages
- line-height centering -> flex in 8 spots (back buttons, media-remove,
  turn-voice capsule, option-marker, text-button, play-question)
2026-07-19 20:16:05 +08:00

747 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page candidate-subpage interview-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回候选首页" title="返回候选首页" @click="goBack"><uni-icons type="back" size="20" color="#25282d" /></button>
<view class="subpage-heading">
<text class="page-title">面试任务</text>
<text class="page-subtitle">生活顾问结构化面试</text>
</view>
</view>
<view v-if="message" class="status-message">{{ message }}</view>
<view v-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后开始面试并查看本人记录。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<template v-else>
<view class="panel intro-panel">
<text class="intro-label">当前任务</text>
<text class="intro-title">完成岗位问答</text>
<text class="intro-desc">题目由已配置模型生成,全部作答后形成正式面试记录。</text>
<button class="primary-button start-button" :disabled="interviewBusy" @click="startInterview">
{{ interviewStatus === 'active' ? '重新生成题目' : interviewBusy ? '处理中' : '开始面试' }}
</button>
</view>
<view v-if="interviewQuestions.length" class="question-section">
<view class="section-heading">
<text class="section-title">面试问答</text>
<text class="muted">{{ answeredCount }}/{{ interviewQuestions.length }} 已填写</text>
</view>
<view
v-for="item in interviewQuestions"
:key="item.questionId"
class="question-block"
>
<text class="question-meta">第 {{ item.seq }} 题 · {{ item.scenario }}</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]"
class="textarea answer-textarea"
maxlength="400"
: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' || Boolean(recordingQuestionId) || Boolean(transcribingQuestionId)"
@click="finishInterview"
>
{{ interviewStatus === 'submitting' ? '评分中' : '提交评分' }}
</button>
</view>
<view v-if="interviewResult" class="panel result-panel">
<view class="result-head">
<view>
<text class="panel-title">本次结果</text>
<text class="result-title">{{ interviewResult.result }}</text>
</view>
<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">
<text class="dimension-title">{{ item.label }}</text>
<text class="dimension-note">{{ item.note }}</text>
</view>
<text class="dimension-value">{{ item.value }}</text>
</view>
</view>
<button class="secondary-button progress-button" @click="openProgress">查看办理进度</button>
</view>
<view class="panel records-panel">
<view class="section-head">
<text class="panel-title">最近面试</text>
<button class="text-button" :disabled="recordsLoading" @click="loadRecords">
{{ recordsLoading ? '刷新中' : '刷新' }}
</button>
</view>
<view v-if="records.length" class="record-list">
<view v-for="item in records" :key="`${item.time}-${item.score}`" class="record-row">
<view class="record-copy">
<text class="record-title">{{ item.position }} · {{ item.result }}</text>
<text class="record-desc">{{ item.time }} · {{ item.summary }}</text>
</view>
<text class="record-score">{{ item.score }}</text>
</view>
</view>
<view v-else-if="!recordsLoading" class="empty compact-empty">暂无面试记录</view>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
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,
getCandidateInterviewRecords,
saveInterviewAnswer,
startCandidateInterview
} from '@/services/candidate';
import { candidateRoutes } from '@/services/candidate-view';
import { resetPageScroll } from '@/services/navigation';
import {
chooseSpeechAudio,
createSpeechPlaybackController,
INTERVIEWER_VOICE_PROFILE,
normalizeOssId,
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, string | 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 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' });
},
INTERVIEWER_VOICE_PROFILE
);
const interviewBusy = computed(() => interviewStatus.value === 'starting' || interviewStatus.value === 'submitting');
const answeredCount = computed(() => interviewQuestions.value.filter((item) =>
Boolean(interviewAnswers.value[item.questionId]?.trim())).length);
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const clearDraft = () => {
speechPlayback.stop();
cancelQuestionRecording();
interviewQuestions.value = [];
interviewAnswers.value = {};
interviewAudioOssIds.value = {};
interviewResult.value = null;
interviewSessionId.value = '';
interviewStatus.value = 'idle';
};
const clearPrivateData = () => {
clearDraft();
records.value = [];
};
const handleRequestError = (error: unknown, fallback: string) => {
refreshAuthState();
if (!loggedIn.value) {
clearPrivateData();
message.value = '登录已过期,请重新登录';
return true;
}
message.value = error instanceof Error ? error.message : fallback;
return false;
};
const loadRecords = async () => {
refreshAuthState();
if (!loggedIn.value) {
clearPrivateData();
return;
}
if (recordsLoading.value) return;
recordsLoading.value = true;
try {
records.value = await getCandidateInterviewRecords();
} catch (error) {
handleRequestError(error, '面试记录加载失败');
} finally {
recordsLoading.value = false;
}
};
const startInterview = async () => {
if (interviewBusy.value) return;
refreshAuthState();
if (!loggedIn.value) {
goLogin();
return;
}
cancelQuestionRecording();
interviewStatus.value = 'starting';
speechPlayback.stop();
interviewQuestions.value = [];
interviewAnswers.value = {};
interviewAudioOssIds.value = {};
interviewResult.value = null;
interviewSessionId.value = '';
message.value = '';
try {
const data = await startCandidateInterview();
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) {
interviewStatus.value = 'idle';
handleRequestError(error, '面试启动失败');
}
};
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;
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 = normalizeOssId(result.ossId);
interviewAudioOssIds.value = {
...interviewAudioOssIds.value,
[questionId]: ossId
};
message.value = '语音已转成文字,请确认后提交';
};
const transcribeQuestionFile = async (questionId: string) => {
if (interviewBusy.value || transcribingQuestionId.value) return;
const generation = ++questionTranscriptionGeneration;
transcribingQuestionId.value = questionId;
message.value = '语音转写中';
try {
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 {
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 {
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 {
if (generation === questionTranscriptionGeneration) {
abortQuestionTranscription = null;
transcribingQuestionId.value = '';
}
}
};
const startQuestionRecording = async (questionId: string) => {
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[] = [];
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 = '';
stopQuestionMediaTracks();
if (generation === questionCaptureGeneration) await transcribeQuestionFile(questionId);
} finally {
if (generation === questionCaptureGeneration) recordingQuestionStarting.value = false;
}
};
const stopQuestionRecording = () => {
const recorder = mediaRecorder.value;
if (!recordingQuestionId.value || !recorder || recorder.state === 'inactive') return;
message.value = '录音结束,正在转写';
recorder.stop();
};
const toggleQuestionRecording = (questionId: string) => {
if (recordingQuestionStarting.value) return;
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()])
);
if (interviewQuestions.value.some((item) => !answers[item.questionId])) {
message.value = `请填写全部 ${interviewQuestions.value.length} 题回答`;
return;
}
interviewStatus.value = 'submitting';
message.value = '';
try {
await Promise.all(Object.entries(answers).map(([questionId, answer]) =>
saveInterviewAnswer(interviewSessionId.value, questionId, answer, interviewAudioOssIds.value[questionId])
));
interviewResult.value = await finishCandidateInterview(interviewSessionId.value, answers);
records.value = interviewResult.value.records?.length
? interviewResult.value.records
: await getCandidateInterviewRecords();
interviewStatus.value = 'finished';
message.value = '面试完成,已生成面试记录';
} catch (error) {
if (!handleRequestError(error, '面试提交失败')) {
interviewStatus.value = 'active';
}
}
};
const openProgress = () => {
uni.navigateTo({ url: candidateRoutes.progress });
};
const goLogin = () => {
rememberLoginRedirect(currentPath);
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goBack = () => {
if (getCurrentPages().length <= 1) {
uni.redirectTo({ url: candidateRoutes.index });
return;
}
uni.navigateBack({ delta: 1, fail: () => uni.redirectTo({ url: candidateRoutes.index }) });
};
onShow(() => {
resetPageScroll();
refreshAuthState();
if (!loggedIn.value) {
clearPrivateData();
return;
}
void loadRecords();
});
onHide(() => {
speechPlayback.stop();
cancelQuestionRecording();
});
onUnload(() => {
speechPlayback.destroy();
cancelQuestionRecording();
});
</script>
<style scoped>
.candidate-subpage {
background: #f4f7fb;
}
.candidate-subpage .panel,
.candidate-subpage button,
.question-block {
border-radius: 8px;
}
.login-panel,
.section-head,
.result-head,
.record-row,
.dimension-row,
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
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;
font-size: 14px;
line-height: 1.5;
}
.login-button {
min-width: 96px;
}
.intro-panel {
border-left: 4px solid #e60012;
}
.intro-label,
.question-meta {
color: #b91c1c;
font-size: 12px;
font-weight: 800;
}
.intro-title {
display: block;
margin-top: 10px;
color: #172033;
font-size: 22px;
font-weight: 800;
}
.intro-desc,
.result-desc {
display: block;
margin-top: 8px;
color: #64748b;
font-size: 14px;
line-height: 1.55;
}
.start-button,
.finish-button,
.progress-button {
width: 100%;
margin-top: 16px;
}
.question-section {
margin-top: 22px;
}
.section-title {
color: #172033;
font-size: 20px;
font-weight: 800;
}
.question-block {
margin-top: 12px;
padding: 16px;
border: 1px solid #e2e8f0;
background: #ffffff;
}
.question-title {
display: block;
margin-top: 8px;
color: #172033;
font-size: 16px;
font-weight: 800;
line-height: 1.5;
}
.question-point {
display: inline-block;
margin-top: 8px;
padding: 4px 8px;
background: #f1f5f9;
color: #475569;
border-radius: 4px;
font-size: 12px;
font-weight: 800;
}
.answer-textarea {
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;
color: #15803d;
font-size: 15px;
font-weight: 800;
}
.result-score,
.record-score {
color: #b91c1c;
font-size: 24px;
font-weight: 800;
}
.dimension-list,
.record-list {
margin-top: 14px;
border-top: 1px solid #e2e8f0;
}
.dimension-row,
.record-row {
padding: 12px 0;
border-bottom: 1px solid #e2e8f0;
align-items: flex-start;
}
.dimension-copy,
.record-copy {
min-width: 0;
flex: 1;
}
.dimension-title,
.record-title {
display: block;
color: #172033;
font-size: 14px;
font-weight: 800;
}
.dimension-note,
.record-desc {
display: block;
margin-top: 5px;
overflow-wrap: anywhere;
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.dimension-value {
color: #172033;
font-size: 18px;
font-weight: 800;
}
.text-button {
min-height: 36px;
padding: 0;
background: transparent;
color: #1d4ed8;
font-size: 13px;
}
.compact-empty {
padding-bottom: 4px;
}
</style>