- 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)
747 lines
22 KiB
Vue
747 lines
22 KiB
Vue
<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>
|