This commit is contained in:
zpc 2026-03-19 07:59:16 +08:00
parent f2e2a5d052
commit da590c2939

View File

@ -3,10 +3,11 @@
* 测评答题页面
*
* 功能
* - 展示所有测评题目和选项
* - 单题翻页模式展示测评题目
* - 每题10个选项单选
* - 底部导航题目导航上一题下一题
* - 侧边题目导航面板按题号跳转
* - 提交时检测未答题目
* - 未答题弹窗提示
*/
import { ref, computed } from 'vue'
@ -26,17 +27,20 @@ const questions = ref([])
// { questionId: selectedIndex }
const answers = ref({})
//
const currentIndex = ref(0)
//
const pageLoading = ref(true)
const submitting = ref(false)
//
const showNavPopup = ref(false)
//
const showUnansweredPopup = ref(false)
const unansweredQuestions = ref([])
// scroll-view
const scrollTarget = ref('')
//
const scoreOptions = ref([
{ score: 1, label: '极弱', desc: '完全不符合' },
@ -51,30 +55,46 @@ const scoreOptions = ref([
{ score: 10, label: '极强', desc: '完全符合' }
])
/** 已答题数量 */
const answeredCount = computed(() => Object.keys(answers.value).length)
/** 当前题目 */
const currentQuestion = computed(() => questions.value[currentIndex.value] || null)
/** 当前题目ID */
const currentQuestionId = computed(() => {
if (!currentQuestion.value) return 0
return currentQuestion.value.id || currentIndex.value + 1
})
/** 总题目数量 */
const totalCount = computed(() => questions.value.length)
/** 是否第一题 */
const isFirst = computed(() => currentIndex.value === 0)
/** 是否最后一题 */
const isLast = computed(() => currentIndex.value === totalCount.value - 1)
/** 判断某题是否已答 */
function isAnswered(index) {
const q = questions.value[index]
const qId = q?.id || index + 1
return answers.value[qId] !== undefined
}
/** 加载题目列表 */
async function loadQuestions() {
pageLoading.value = true
try {
//
const [questionsRes, scoreRes] = await Promise.all([
getQuestionList(typeId.value),
getScoreOptions(typeId.value)
])
//
if (questionsRes && questionsRes.code === 0 && questionsRes.data) {
questions.value = questionsRes.data.list || questionsRes.data || []
} else {
questions.value = generateMockQuestions()
}
//
if (scoreRes && scoreRes.code === 0 && scoreRes.data && scoreRes.data.length > 0) {
scoreOptions.value = scoreRes.data.map(item => ({
score: item.score,
@ -82,7 +102,6 @@ async function loadQuestions() {
desc: item.description
}))
}
//
} catch (error) {
console.error('加载数据失败:', error)
questions.value = generateMockQuestions()
@ -118,14 +137,43 @@ function isSelected(questionId, scoreIndex) {
return answers.value[questionId] === scoreIndex
}
/** 上一题 */
function goPrev() {
if (!isFirst.value) {
currentIndex.value--
}
}
/** 下一题 */
function goNext() {
if (!isLast.value) {
currentIndex.value++
}
}
/** 跳转到指定题目(题目导航用) */
function goToQuestion(index) {
currentIndex.value = index
showNavPopup.value = false
}
/** 打开题目导航 */
function openNavPopup() {
showNavPopup.value = true
}
/** 关闭题目导航 */
function closeNavPopup() {
showNavPopup.value = false
}
/** 提交答案 */
async function handleSubmit() {
//
const unanswered = []
questions.value.forEach((q, index) => {
const qId = q.id || index + 1
if (answers.value[qId] === undefined) {
unanswered.push(q.questionNo || index + 1)
unanswered.push({ no: q.questionNo || index + 1, index })
}
})
@ -167,14 +215,10 @@ function closeUnansweredPopup() {
showUnansweredPopup.value = false
}
/** 滚动到未答题目 */
function scrollToQuestion(questionNo) {
/** 跳转到未答题目 */
function goToUnanswered(index) {
closeUnansweredPopup()
// id
scrollTarget.value = ''
setTimeout(() => {
scrollTarget.value = `question-${questionNo}`
}, 50)
currentIndex.value = index
}
/** 页面加载 */
@ -197,45 +241,89 @@ onLoad((options) => {
<text class="loading-text">加载题目中...</text>
</view>
<!-- 题目滚动区域 -->
<scroll-view v-else class="scroll-area" scroll-y :scroll-into-view="scrollTarget">
<view class="scroll-inner">
<view class="questions-card">
<view
v-for="(question, index) in questions"
:key="question.id || index"
:id="'question-' + (question.questionNo || index + 1)"
class="question-block"
>
<!-- 题目标题 -->
<view class="question-title">
<view class="question-no">{{ question.questionNo || index + 1 }}</view>
<text class="question-text">{{ question.content }}</text>
</view>
<!-- 答题区域 -->
<view v-else class="question-area">
<view class="question-card" v-if="currentQuestion">
<!-- 进度信息 -->
<view class="progress-bar">
<text class="progress-current">当前题目</text>
<text class="progress-sep">/总题数</text>
<text class="progress-nums">{{ currentIndex + 1 }}/{{ totalCount }}</text>
</view>
<!-- 选项列表 - 全部10个 -->
<view class="options-list">
<view
v-for="(option, optIdx) in scoreOptions"
:key="optIdx"
class="option-row"
@click="selectAnswer(question.id || index + 1, optIdx)"
>
<view class="radio" :class="{ 'radio-active': isSelected(question.id || index + 1, optIdx) }">
<view v-if="isSelected(question.id || index + 1, optIdx)" class="radio-dot"></view>
</view>
<text class="option-label">{{ option.label }}{{ option.desc }}</text>
</view>
<!-- 题目标题 -->
<view class="question-title">
<view class="question-no">{{ currentQuestion.questionNo || currentIndex + 1 }}</view>
<text class="question-text">{{ currentQuestion.content }}</text>
</view>
<!-- 选项列表 -->
<view class="options-list">
<view
v-for="(option, optIdx) in scoreOptions"
:key="optIdx"
class="option-row"
@click="selectAnswer(currentQuestionId, optIdx)"
>
<view class="radio" :class="{ 'radio-active': isSelected(currentQuestionId, optIdx) }">
<view v-if="isSelected(currentQuestionId, optIdx)" class="radio-dot"></view>
</view>
<text class="option-label">{{ option.label }}{{ option.desc }}</text>
</view>
</view>
</view>
</scroll-view>
</view>
<!-- 底部固定提交按钮 -->
<view v-if="!pageLoading" class="submit-fixed">
<view class="submit-btn" :class="{ 'btn-loading': submitting }" @click="handleSubmit">
<text>{{ submitting ? '提交中...' : '提交' }}</text>
<!-- 底部操作栏 -->
<view v-if="!pageLoading" class="bottom-bar">
<view class="nav-btn-left" @click="openNavPopup">
<view class="nav-icon-text"></view>
<text class="nav-label">题目导航</text>
</view>
<view class="bar-divider"></view>
<view class="bar-actions">
<view class="action-btn action-prev" :class="{ 'action-disabled': isFirst }" @click="goPrev">
<text>上一题</text>
</view>
<view
v-if="!isLast"
class="action-btn action-next"
@click="goNext"
>
<text>下一题</text>
</view>
<view
v-else
class="action-btn action-next"
:class="{ 'btn-loading': submitting }"
@click="handleSubmit"
>
<text>{{ submitting ? '提交中...' : '提交' }}</text>
</view>
</view>
</view>
<!-- 题目导航侧边弹窗 -->
<view v-if="showNavPopup" class="nav-popup-mask" @click="closeNavPopup">
<view class="nav-popup-panel" @click.stop>
<view class="nav-popup-header">
<view class="nav-popup-indicator"></view>
<text class="nav-popup-title">题目导航</text>
</view>
<view class="nav-popup-divider"></view>
<scroll-view class="nav-popup-grid-scroll" scroll-y>
<view class="nav-popup-grid">
<view
v-for="(q, idx) in questions"
:key="idx"
class="nav-grid-item"
:class="{ 'nav-grid-answered': isAnswered(idx), 'nav-grid-current': idx === currentIndex }"
@click="goToQuestion(idx)"
>
<text>{{ q.questionNo || idx + 1 }}</text>
</view>
</view>
</scroll-view>
</view>
</view>
@ -250,12 +338,12 @@ onLoad((options) => {
<view class="popup-msg">以下题目尚未作答请完成后再提交</view>
<scroll-view class="unanswered-scroll" scroll-y>
<view
v-for="qNo in unansweredQuestions"
:key="qNo"
v-for="item in unansweredQuestions"
:key="item.no"
class="unanswered-row"
@click="scrollToQuestion(qNo)"
@click="goToUnanswered(item.index)"
>
<text> {{ qNo }} </text>
<text> {{ item.no }} </text>
<text class="goto-arrow"></text>
</view>
</scroll-view>
@ -307,35 +395,51 @@ onLoad((options) => {
to { transform: rotate(360deg); }
}
// ========== ==========
.scroll-area {
// ========== ==========
.question-area {
flex: 1;
overflow: hidden;
overflow-y: auto;
padding: $spacing-sm $spacing-lg;
display: flex;
align-items: center;
}
.scroll-inner {
padding: $spacing-lg;
}
.questions-card {
.question-card {
background-color: $bg-white;
border-radius: $border-radius-xl;
border-radius: 32rpx;
padding: $spacing-xl $spacing-lg;
width: 100%;
min-height: 65vh;
}
// ========== ==========
.question-block {
margin-bottom: $spacing-xl;
// ========== ==========
.progress-bar {
display: flex;
align-items: center;
margin-bottom: $spacing-lg;
&:last-child {
margin-bottom: 0;
.progress-current {
font-size: $font-size-sm;
color: #FF6B60;
}
.progress-sep {
font-size: $font-size-sm;
color: $text-placeholder;
}
.progress-nums {
font-size: $font-size-sm;
color: $text-placeholder;
margin-left: 4rpx;
}
}
// ========== ==========
.question-title {
display: flex;
align-items: flex-start;
margin-bottom: $spacing-lg;
margin-bottom: $spacing-xl;
}
.question-no {
@ -406,32 +510,91 @@ onLoad((options) => {
line-height: 1.5;
}
// ========== ==========
.submit-fixed {
// ========== ==========
.bottom-bar {
flex-shrink: 0;
padding: $spacing-lg $spacing-lg;
padding-bottom: calc(#{$spacing-lg} + env(safe-area-inset-bottom));
background-color: rgba(255, 234, 231, 1);
display: flex;
align-items: center;
padding: $spacing-md $spacing-lg;
padding-bottom: calc(#{$spacing-md} + env(safe-area-inset-bottom));
background-color: $bg-white;
border-top: 1rpx solid $border-light;
}
.submit-btn {
height: 96rpx;
background-color: #FF6B60;
border-radius: 48rpx;
.nav-btn-left {
display: flex;
align-items: center;
padding: $spacing-sm 0;
.nav-icon-text {
font-size: 32rpx;
color: #FF6B60;
margin-right: 8rpx;
line-height: 1;
}
.nav-label {
font-size: $font-size-sm;
color: #FF6B60;
}
&:active {
opacity: 0.7;
}
}
.bar-divider {
width: 1rpx;
height: 48rpx;
background-color: $border-color;
margin: 0 $spacing-lg;
}
.bar-actions {
flex: 1;
display: flex;
align-items: center;
gap: $spacing-md;
}
.action-btn {
flex: 1;
height: 76rpx;
border-radius: 38rpx;
display: flex;
align-items: center;
justify-content: center;
text {
font-size: $font-size-xl;
font-weight: $font-weight-bold;
color: $text-white;
font-size: $font-size-md;
font-weight: $font-weight-medium;
}
&:active {
opacity: 0.9;
opacity: 0.8;
transform: scale(0.98);
}
}
.action-prev {
background-color: #FF6B60;
text {
color: $text-white;
}
&.action-disabled {
opacity: 0.4;
pointer-events: none;
}
}
.action-next {
background-color: #FF6B60;
text {
color: $text-white;
}
&.btn-loading {
opacity: 0.7;
@ -439,6 +602,103 @@ onLoad((options) => {
}
}
// ========== ==========
.nav-popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
}
.nav-popup-panel {
width: 580rpx;
height: 100%;
background-color: $bg-white;
display: flex;
flex-direction: column;
}
.nav-popup-header {
display: flex;
align-items: center;
padding: $spacing-xl $spacing-lg;
padding-top: calc(#{$spacing-xl} + env(safe-area-inset-top) + 88rpx);
.nav-popup-indicator {
width: 8rpx;
height: 36rpx;
background-color: $text-color;
border-radius: 4rpx;
margin-right: $spacing-sm;
}
.nav-popup-title {
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: $text-color;
}
}
.nav-popup-divider {
height: 1rpx;
background: repeating-linear-gradient(
to right,
$border-color 0,
$border-color 8rpx,
transparent 8rpx,
transparent 16rpx
);
margin: 0 $spacing-lg;
}
.nav-popup-grid-scroll {
flex: 1;
overflow: hidden;
}
.nav-popup-grid {
display: flex;
flex-wrap: wrap;
padding: $spacing-lg;
gap: $spacing-md;
}
.nav-grid-item {
width: 100rpx;
height: 100rpx;
border-radius: $border-radius-md;
background-color: #F0F0F0;
display: flex;
align-items: center;
justify-content: center;
text {
font-size: $font-size-lg;
color: $text-secondary;
font-weight: $font-weight-medium;
}
&.nav-grid-answered {
background-color: #FF6B60;
text {
color: $text-white;
}
}
&.nav-grid-current {
border: 3rpx solid #FF6B60;
}
&:active {
opacity: 0.8;
}
}
// ========== ==========
.popup-mask {
position: fixed;
@ -446,7 +706,7 @@ onLoad((options) => {
left: 0;
right: 0;
bottom: 0;
background-color: $bg-mask;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;