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个选项单选 * - 每题10个选项单选
* - 底部导航题目导航上一题下一题
* - 侧边题目导航面板按题号跳转
* - 提交时检测未答题目 * - 提交时检测未答题目
* - 未答题弹窗提示
*/ */
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
@ -26,17 +27,20 @@ const questions = ref([])
// { questionId: selectedIndex } // { questionId: selectedIndex }
const answers = ref({}) const answers = ref({})
//
const currentIndex = ref(0)
// //
const pageLoading = ref(true) const pageLoading = ref(true)
const submitting = ref(false) const submitting = ref(false)
//
const showNavPopup = ref(false)
// //
const showUnansweredPopup = ref(false) const showUnansweredPopup = ref(false)
const unansweredQuestions = ref([]) const unansweredQuestions = ref([])
// scroll-view
const scrollTarget = ref('')
// //
const scoreOptions = ref([ const scoreOptions = ref([
{ score: 1, label: '极弱', desc: '完全不符合' }, { score: 1, label: '极弱', desc: '完全不符合' },
@ -51,30 +55,46 @@ const scoreOptions = ref([
{ score: 10, label: '极强', desc: '完全符合' } { 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 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() { async function loadQuestions() {
pageLoading.value = true pageLoading.value = true
try { try {
//
const [questionsRes, scoreRes] = await Promise.all([ const [questionsRes, scoreRes] = await Promise.all([
getQuestionList(typeId.value), getQuestionList(typeId.value),
getScoreOptions(typeId.value) getScoreOptions(typeId.value)
]) ])
//
if (questionsRes && questionsRes.code === 0 && questionsRes.data) { if (questionsRes && questionsRes.code === 0 && questionsRes.data) {
questions.value = questionsRes.data.list || questionsRes.data || [] questions.value = questionsRes.data.list || questionsRes.data || []
} else { } else {
questions.value = generateMockQuestions() questions.value = generateMockQuestions()
} }
//
if (scoreRes && scoreRes.code === 0 && scoreRes.data && scoreRes.data.length > 0) { if (scoreRes && scoreRes.code === 0 && scoreRes.data && scoreRes.data.length > 0) {
scoreOptions.value = scoreRes.data.map(item => ({ scoreOptions.value = scoreRes.data.map(item => ({
score: item.score, score: item.score,
@ -82,7 +102,6 @@ async function loadQuestions() {
desc: item.description desc: item.description
})) }))
} }
//
} catch (error) { } catch (error) {
console.error('加载数据失败:', error) console.error('加载数据失败:', error)
questions.value = generateMockQuestions() questions.value = generateMockQuestions()
@ -118,14 +137,43 @@ function isSelected(questionId, scoreIndex) {
return answers.value[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() { async function handleSubmit() {
//
const unanswered = [] const unanswered = []
questions.value.forEach((q, index) => { questions.value.forEach((q, index) => {
const qId = q.id || index + 1 const qId = q.id || index + 1
if (answers.value[qId] === undefined) { 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 showUnansweredPopup.value = false
} }
/** 滚动到未答题目 */ /** 跳转到未答题目 */
function scrollToQuestion(questionNo) { function goToUnanswered(index) {
closeUnansweredPopup() closeUnansweredPopup()
// id currentIndex.value = index
scrollTarget.value = ''
setTimeout(() => {
scrollTarget.value = `question-${questionNo}`
}, 50)
} }
/** 页面加载 */ /** 页面加载 */
@ -197,45 +241,89 @@ onLoad((options) => {
<text class="loading-text">加载题目中...</text> <text class="loading-text">加载题目中...</text>
</view> </view>
<!-- 题目滚动区域 --> <!-- 答题区域 -->
<scroll-view v-else class="scroll-area" scroll-y :scroll-into-view="scrollTarget"> <view v-else class="question-area">
<view class="scroll-inner"> <view class="question-card" v-if="currentQuestion">
<view class="questions-card"> <!-- 进度信息 -->
<view <view class="progress-bar">
v-for="(question, index) in questions" <text class="progress-current">当前题目</text>
:key="question.id || index" <text class="progress-sep">/总题数</text>
:id="'question-' + (question.questionNo || index + 1)" <text class="progress-nums">{{ currentIndex + 1 }}/{{ totalCount }}</text>
class="question-block" </view>
>
<!-- 题目标题 -->
<view class="question-title">
<view class="question-no">{{ question.questionNo || index + 1 }}</view>
<text class="question-text">{{ question.content }}</text>
</view>
<!-- 选项列表 - 全部10个 --> <!-- 题目标题 -->
<view class="options-list"> <view class="question-title">
<view <view class="question-no">{{ currentQuestion.questionNo || currentIndex + 1 }}</view>
v-for="(option, optIdx) in scoreOptions" <text class="question-text">{{ currentQuestion.content }}</text>
:key="optIdx" </view>
class="option-row"
@click="selectAnswer(question.id || index + 1, optIdx)" <!-- 选项列表 -->
> <view class="options-list">
<view class="radio" :class="{ 'radio-active': isSelected(question.id || index + 1, optIdx) }"> <view
<view v-if="isSelected(question.id || index + 1, optIdx)" class="radio-dot"></view> v-for="(option, optIdx) in scoreOptions"
</view> :key="optIdx"
<text class="option-label">{{ option.label }}{{ option.desc }}</text> class="option-row"
</view> @click="selectAnswer(currentQuestionId, optIdx)"
>
<view class="radio" :class="{ 'radio-active': isSelected(currentQuestionId, optIdx) }">
<view v-if="isSelected(currentQuestionId, optIdx)" class="radio-dot"></view>
</view> </view>
<text class="option-label">{{ option.label }}{{ option.desc }}</text>
</view> </view>
</view> </view>
</view> </view>
</scroll-view> </view>
<!-- 底部固定提交按钮 --> <!-- 底部操作栏 -->
<view v-if="!pageLoading" class="submit-fixed"> <view v-if="!pageLoading" class="bottom-bar">
<view class="submit-btn" :class="{ 'btn-loading': submitting }" @click="handleSubmit"> <view class="nav-btn-left" @click="openNavPopup">
<text>{{ submitting ? '提交中...' : '提交' }}</text> <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>
</view> </view>
@ -250,12 +338,12 @@ onLoad((options) => {
<view class="popup-msg">以下题目尚未作答请完成后再提交</view> <view class="popup-msg">以下题目尚未作答请完成后再提交</view>
<scroll-view class="unanswered-scroll" scroll-y> <scroll-view class="unanswered-scroll" scroll-y>
<view <view
v-for="qNo in unansweredQuestions" v-for="item in unansweredQuestions"
:key="qNo" :key="item.no"
class="unanswered-row" class="unanswered-row"
@click="scrollToQuestion(qNo)" @click="goToUnanswered(item.index)"
> >
<text> {{ qNo }} </text> <text> {{ item.no }} </text>
<text class="goto-arrow"></text> <text class="goto-arrow"></text>
</view> </view>
</scroll-view> </scroll-view>
@ -307,35 +395,51 @@ onLoad((options) => {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
// ========== ========== // ========== ==========
.scroll-area { .question-area {
flex: 1; flex: 1;
overflow: hidden; overflow-y: auto;
padding: $spacing-sm $spacing-lg;
display: flex;
align-items: center;
} }
.scroll-inner { .question-card {
padding: $spacing-lg;
}
.questions-card {
background-color: $bg-white; background-color: $bg-white;
border-radius: $border-radius-xl; border-radius: 32rpx;
padding: $spacing-xl $spacing-lg; padding: $spacing-xl $spacing-lg;
width: 100%;
min-height: 65vh;
} }
// ========== ========== // ========== ==========
.question-block { .progress-bar {
margin-bottom: $spacing-xl; display: flex;
align-items: center;
margin-bottom: $spacing-lg;
&:last-child { .progress-current {
margin-bottom: 0; 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 { .question-title {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
margin-bottom: $spacing-lg; margin-bottom: $spacing-xl;
} }
.question-no { .question-no {
@ -406,32 +510,91 @@ onLoad((options) => {
line-height: 1.5; line-height: 1.5;
} }
// ========== ========== // ========== ==========
.submit-fixed { .bottom-bar {
flex-shrink: 0; flex-shrink: 0;
padding: $spacing-lg $spacing-lg; display: flex;
padding-bottom: calc(#{$spacing-lg} + env(safe-area-inset-bottom)); align-items: center;
background-color: rgba(255, 234, 231, 1); 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 { .nav-btn-left {
height: 96rpx; display: flex;
background-color: #FF6B60; align-items: center;
border-radius: 48rpx; 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; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
text { text {
font-size: $font-size-xl; font-size: $font-size-md;
font-weight: $font-weight-bold; font-weight: $font-weight-medium;
color: $text-white;
} }
&:active { &:active {
opacity: 0.9; opacity: 0.8;
transform: scale(0.98); 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 { &.btn-loading {
opacity: 0.7; 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 { .popup-mask {
position: fixed; position: fixed;
@ -446,7 +706,7 @@ onLoad((options) => {
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background-color: $bg-mask; background-color: rgba(0, 0, 0, 0.5);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;