feat: restore learning feedback and knowledge capabilities
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<view class="page employee-page-shell exam-page">
|
||||
<view class="subpage-header">
|
||||
<button class="subpage-back-button" aria-label="返回学习" :disabled="submitting" @click="goBack">←</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">{{ isSelected(item, option) ? '✓' : '' }}</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 () => {
|
||||
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 { flex: 0 0 24px; height: 24px; border: 1px solid #c8d0db; border-radius: 50%; color: var(--brand); line-height: 22px; 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>
|
||||
Reference in New Issue
Block a user