- 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)
187 lines
10 KiB
Vue
187 lines
10 KiB
Vue
<template>
|
|
<view class="page employee-page-shell exam-page">
|
|
<view class="subpage-header">
|
|
<button class="subpage-back-button" aria-label="返回学习" :disabled="submitting" @click="goBack"><uni-icons type="back" size="20" color="#25282d" /></button>
|
|
<view class="subpage-heading">
|
|
<text class="employee-kicker">岗前 / 入职考试</text>
|
|
<text class="page-title">{{ exam?.title || '考试任务' }}</text>
|
|
<text class="page-subtitle">{{ examMeta }}</text>
|
|
</view>
|
|
</view>
|
|
<text v-if="message" class="status-message">{{ message }}</text>
|
|
<view v-if="loading" class="panel employee-card">考试加载中…</view>
|
|
<template v-else-if="exam">
|
|
<view class="summary-card employee-card">
|
|
<view><text class="summary-value">{{ exam.questionCount }}</text><text class="summary-label">题目</text></view>
|
|
<view><text class="summary-value">{{ exam.passingScore }}</text><text class="summary-label">及格分</text></view>
|
|
<view><text class="summary-value">{{ exam.totalScore }}</text><text class="summary-label">总分</text></view>
|
|
</view>
|
|
|
|
<view v-if="result" class="panel employee-card result-panel" :class="result.passed ? 'passed' : 'failed'">
|
|
<text class="result-title">{{ result.passed ? '考试通过' : '本次未通过' }}</text>
|
|
<text class="result-score">{{ result.score }} 分</text>
|
|
<text class="muted">{{ result.passed ? '上岗资格会按正式规则刷新。' : '请根据错题依据复习后再参加补考。' }}</text>
|
|
</view>
|
|
|
|
<view class="question-list">
|
|
<view v-for="(item, index) in exam.questions" :key="item.id" class="panel employee-card question-card">
|
|
<view class="question-head">
|
|
<text class="question-index">{{ index + 1 }} / {{ exam.questions.length }}</text>
|
|
<button class="ghost-button speech-button" @click="speakQuestion(item)">{{ speechLabel(item) }}</button>
|
|
</view>
|
|
<text class="question-stem">{{ item.stem }}</text>
|
|
<view v-if="item.options?.length" class="option-list">
|
|
<button
|
|
v-for="option in item.options"
|
|
:key="option"
|
|
class="option-button"
|
|
:class="{ selected: isSelected(item, option) }"
|
|
:disabled="Boolean(result)"
|
|
@click="toggleOption(item, option)"
|
|
>
|
|
<text class="option-marker"><uni-icons v-if="isSelected(item, option)" type="checkmark" size="13" color="#e60012" /></text>
|
|
<text>{{ option }}</text>
|
|
</button>
|
|
</view>
|
|
<textarea
|
|
v-else
|
|
class="textarea short-answer"
|
|
:value="String(answers[String(item.id)] || '')"
|
|
:disabled="Boolean(result)"
|
|
maxlength="500"
|
|
placeholder="请输入回答"
|
|
@input="setShortAnswer(item, $event)"
|
|
/>
|
|
<view v-if="result && item.explanation" class="explanation">
|
|
<text class="explanation-title">参考依据</text>
|
|
<text>{{ item.explanation }}</text>
|
|
</view>
|
|
</view>
|
|
</view>
|
|
|
|
<view v-if="!result" class="submit-dock">
|
|
<text>已答 {{ answeredCount }} / {{ exam.questions.length }} 题</text>
|
|
<button class="primary-button" :disabled="submitting || answeredCount !== exam.questions.length" @click="submit">
|
|
{{ submitting ? '提交中' : '提交试卷' }}
|
|
</button>
|
|
</view>
|
|
</template>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, ref } from 'vue';
|
|
import { onLoad } from '@dcloudio/uni-app';
|
|
import type { ExamDetail, ExamQuestion, ExamSubmitResult } from '@/types/api';
|
|
import { getMyExam, submitMyExam } from '@/services/exam';
|
|
import { getAuth } from '@/services/auth';
|
|
import { createSpeechPlaybackController, type SpeechPlaybackSnapshot } from '@/services/speech';
|
|
|
|
const id = ref(0);
|
|
const exam = ref<ExamDetail | null>(null);
|
|
const answers = ref<Record<string, string | string[]>>({});
|
|
const result = ref<ExamSubmitResult | null>(null);
|
|
const loading = ref(false);
|
|
const submitting = ref(false);
|
|
const message = ref('');
|
|
const speech = ref<SpeechPlaybackSnapshot>({ key: '', status: 'idle' });
|
|
const speechController = createSpeechPlaybackController(
|
|
(snapshot) => { speech.value = snapshot; },
|
|
(error) => { message.value = error; }
|
|
);
|
|
const examMeta = computed(() => exam.value
|
|
? `${exam.value.positionCode || '通用岗位'}${exam.value.dueDate ? ` · 截止 ${exam.value.dueDate}` : ''}`
|
|
: '读取正式发布的考试内容');
|
|
const answeredCount = computed(() => Object.values(answers.value).filter((value) => Array.isArray(value) ? value.length : String(value).trim()).length);
|
|
const storageKey = computed(() => `aihr_exam_draft:${getAuth().phone || 'anonymous'}:${id.value}`);
|
|
|
|
const goBack = () => uni.navigateBack({ delta: 1 });
|
|
const requestId = () => `exam:${id.value}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;
|
|
const isSelected = (question: ExamQuestion, option: string) => {
|
|
const value = answers.value[String(question.id)];
|
|
return Array.isArray(value) ? value.includes(option) : value === option;
|
|
};
|
|
const toggleOption = (question: ExamQuestion, option: string) => {
|
|
const key = String(question.id);
|
|
if (question.format === 'multiple') {
|
|
const current = Array.isArray(answers.value[key]) ? [...answers.value[key] as string[]] : [];
|
|
answers.value[key] = current.includes(option) ? current.filter((item) => item !== option) : [...current, option];
|
|
} else {
|
|
answers.value[key] = option;
|
|
}
|
|
uni.setStorageSync(storageKey.value, answers.value);
|
|
};
|
|
const setShortAnswer = (question: ExamQuestion, event: unknown) => {
|
|
const value = typeof event === 'object' && event !== null && 'detail' in event
|
|
&& typeof (event as { detail?: unknown }).detail === 'object'
|
|
&& (event as { detail?: { value?: unknown } }).detail !== null
|
|
? String((event as { detail: { value?: unknown } }).detail.value || '')
|
|
: '';
|
|
answers.value[String(question.id)] = value;
|
|
uni.setStorageSync(storageKey.value, answers.value);
|
|
};
|
|
const speakQuestion = (question: ExamQuestion) => speechController.toggle(`q-${question.id}`, [question.stem, ...(question.options || [])].join('。'));
|
|
const speechLabel = (question: ExamQuestion) => speech.value.key === `q-${question.id}`
|
|
? (speech.value.status === 'playing' ? '停止' : '生成中')
|
|
: '播报题目';
|
|
const load = async () => {
|
|
// Direct visits without a valid exam id must not fire /exams/0 (server 500).
|
|
if (!id.value || id.value <= 0) {
|
|
message.value = '请从「学习中心 · 考试任务」进入具体考试';
|
|
return;
|
|
}
|
|
loading.value = true;
|
|
try {
|
|
exam.value = await getMyExam(id.value);
|
|
const stored = uni.getStorageSync(storageKey.value);
|
|
if (stored && typeof stored === 'object') answers.value = stored as Record<string, string | string[]>;
|
|
} catch (error) { message.value = error instanceof Error ? error.message : '考试读取失败'; }
|
|
finally { loading.value = false; }
|
|
};
|
|
const submit = async () => {
|
|
if (!exam.value || submitting.value || answeredCount.value !== exam.value.questions.length) return;
|
|
submitting.value = true;
|
|
try {
|
|
result.value = await submitMyExam(id.value, answers.value, requestId());
|
|
const explanations = new Map(result.value.results.map((item) => [item.questionId, item.explanation]));
|
|
exam.value.questions = exam.value.questions.map((item) => ({
|
|
...item,
|
|
explanation: explanations.get(item.id) || item.explanation
|
|
}));
|
|
uni.removeStorageSync(storageKey.value);
|
|
message.value = result.value.passed ? '考试已通过,资格状态将刷新' : '试卷已提交,请根据错题复习';
|
|
} catch (error) { message.value = error instanceof Error ? error.message : '试卷提交失败'; }
|
|
finally { submitting.value = false; }
|
|
};
|
|
onLoad((options) => { id.value = Number(options?.id || 0); void load(); });
|
|
onBeforeUnmount(() => speechController.destroy());
|
|
</script>
|
|
|
|
<style scoped>
|
|
.exam-page { max-width: var(--mobile-shell-max-width); margin: 0 auto; padding-bottom: 140px; }
|
|
.status-message { display: block; margin-top: 14px; padding: 12px; border-radius: 10px; background: #fff0f1; color: #a9000d; }
|
|
.summary-card { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 18px; padding: 16px; text-align: center; }
|
|
.summary-value, .summary-label { display: block; }
|
|
.summary-value { font-size: 20px; font-weight: 900; }
|
|
.summary-label { margin-top: 4px; color: var(--muted); font-size: 12px; }
|
|
.result-panel { display: grid; gap: 6px; border-width: 2px; }
|
|
.result-panel.passed { border-color: #7bc99a; }
|
|
.result-panel.failed { border-color: #f0b06a; }
|
|
.result-title { font-size: 18px; font-weight: 900; }
|
|
.result-score { color: var(--brand); font-size: 36px; font-weight: 900; }
|
|
.question-list { display: grid; gap: 4px; }
|
|
.question-card { display: grid; gap: 14px; }
|
|
.question-head { display: flex; align-items: center; justify-content: space-between; }
|
|
.question-index { color: var(--knowledge); font-size: 13px; font-weight: 900; }
|
|
.speech-button { min-height: 36px; padding: 0 10px; font-size: 12px; }
|
|
.question-stem { overflow-wrap: anywhere; font-size: 17px; font-weight: 900; line-height: 1.65; }
|
|
.option-list { display: grid; gap: 9px; }
|
|
.option-button { display: flex; gap: 10px; min-height: 48px; align-items: center; padding: 10px 12px; border: 1px solid var(--line); background: #fff; color: var(--ink); text-align: left; }
|
|
.option-button.selected { border-color: #ef9ca4; background: var(--employee-primary-soft); }
|
|
.option-marker { display: flex; flex: 0 0 24px; height: 24px; align-items: center; justify-content: center; border: 1px solid #c8d0db; border-radius: 50%; color: var(--brand); line-height: 1; text-align: center; }
|
|
.short-answer { min-height: 120px; }
|
|
.explanation { display: grid; gap: 6px; padding: 12px; border-radius: 10px; background: #f1f6fb; color: #435268; font-size: 13px; line-height: 1.6; }
|
|
.explanation-title { font-weight: 900; }
|
|
.submit-dock { position: fixed; z-index: 10; right: 0; bottom: 0; left: 0; display: grid; gap: 8px; max-width: var(--mobile-shell-max-width); margin: 0 auto; padding: 12px 16px calc(12px + env(safe-area-inset-bottom)); border-top: 1px solid var(--line); background: rgba(255,255,255,.96); color: var(--muted); font-size: 12px; }
|
|
</style>
|