feat: restore learning feedback and knowledge capabilities

This commit is contained in:
2026-07-18 00:50:11 +08:00
parent 876a23a123
commit 6bdc86e3fc
152 changed files with 30337 additions and 953 deletions
+181
View File
@@ -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>