mi-assessment/uniapp/pages/assessment/questions/index.vue
zpc 7a54c91154 feat(assessment): 连接个人信息页与答题页数据流
- 新增 PendingRecordDto 模型,支持查询进行中的测评记录
- 后端新增 GetPendingRecord 接口,支持断点续答
- 邀请码路径改为调用 createOrder 创建订单和测评记录
- info 页面加载时检测未完成测评,支持继续上次或重新开始
- questions 页面改用 recordId 提交答案,匹配后端接口
- usePayment 返回 assessmentRecordId 供页面传递
2026-02-23 00:06:24 +08:00

558 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
/**
* 测评答题页面
*
* 功能:
* - 展示所有测评题目和选项
* - 每题10个选项单选
* - 提交时检测未答题目
* - 未答题弹窗提示
*/
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useUserStore } from '@/store/user.js'
import { getQuestionList, submitAnswers, getScoreOptions } from '@/api/assessment.js'
import Navbar from '@/components/Navbar/index.vue'
const userStore = useUserStore()
// 页面参数
const typeId = ref(0)
const recordId = ref(0)
// 题目数据
const questions = ref([])
// 答案数据 { questionId: selectedIndex }
const answers = ref({})
// 页面状态
const pageLoading = ref(true)
const submitting = ref(false)
// 未答题弹窗
const showUnansweredPopup = ref(false)
const unansweredQuestions = ref([])
// scroll-view 滚动目标
const scrollTarget = ref('')
// 评分标准选项(从后台加载)
const scoreOptions = ref([
{ score: 1, label: '极弱', desc: '完全不符合' },
{ score: 2, label: '很弱', desc: '几乎不符合' },
{ score: 3, label: '较弱', desc: '偶尔符合' },
{ score: 4, label: '偏弱', desc: '有时候符合' },
{ score: 5, label: '中等', desc: '普通一般' },
{ score: 6, label: '略强', desc: '多数情况符合' },
{ score: 7, label: '偏强', desc: '大多数情况符合' },
{ score: 8, label: '较强', desc: '绝大多数情况符合' },
{ score: 9, label: '很强', desc: '偶尔不符合' },
{ score: 10, label: '极强', desc: '完全符合' }
])
/** 已答题数量 */
const answeredCount = computed(() => Object.keys(answers.value).length)
/** 总题目数量 */
const totalCount = computed(() => questions.value.length)
/** 加载题目列表 */
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,
label: item.label,
desc: item.description
}))
}
// 如果加载失败,保留默认值
} catch (error) {
console.error('加载数据失败:', error)
questions.value = generateMockQuestions()
} finally {
pageLoading.value = false
}
}
/** 生成模拟题目数据(开发测试用) */
function generateMockQuestions() {
const mockQuestions = [
'注重细节,主动比较不同环境中动物、植物的适应性特征(干旱、雨季、雷雨等),对比并分析这些不同特征对动植物的影响。',
'精细操作类游戏或活动表现好(如转笔、游戏操作) 或在美术课中能够画出细节丰富的作品 或能否熟练使用某种乐器 或喜欢组装复杂的手工制作或模型。',
'在超市买水果时,喜欢主动在心里按颜色、形状或软硬程度给它们分类 或 整理个人收藏(如树叶标本、玩具)时,习惯按自定的标准进行系统排列。',
'喜欢自然科学类的课程 或 喜欢进行自主的自然观察和记录。',
'能理解并讨论音乐作品的情感和意义或能演奏一种或多种乐器或能识别和分析音乐中的和声和节奏变化。'
]
return mockQuestions.map((content, index) => ({
id: index + 1,
questionNo: index + 1,
content: content,
category: '测评题目'
}))
}
/** 选择答案 */
function selectAnswer(questionId, scoreIndex) {
answers.value[questionId] = scoreIndex
}
/** 检查是否选中 */
function isSelected(questionId, scoreIndex) {
return answers.value[questionId] === scoreIndex
}
/** 提交答案 */
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)
}
})
if (unanswered.length > 0) {
unansweredQuestions.value = unanswered
showUnansweredPopup.value = true
return
}
submitting.value = true
try {
const answerList = questions.value.map((q, index) => {
const qId = q.id || index + 1
const scoreIndex = answers.value[qId]
return { questionId: qId, answerValue: scoreOptions.value[scoreIndex].score }
})
const res = await submitAnswers({
recordId: recordId.value,
answers: answerList
})
if (res && res.code === 0) {
const resRecordId = res.data?.recordId || res.data?.id || recordId.value
uni.redirectTo({ url: `/pages/assessment/loading/index?recordId=${resRecordId}` })
} else {
uni.showToast({ title: res?.message || '提交失败,请重试', icon: 'none' })
}
} catch (error) {
console.error('提交答案失败:', error)
uni.showToast({ title: '提交失败,请重试', icon: 'none' })
} finally {
submitting.value = false
}
}
/** 关闭未答题弹窗 */
function closeUnansweredPopup() {
showUnansweredPopup.value = false
}
/** 滚动到未答题目 */
function scrollToQuestion(questionNo) {
closeUnansweredPopup()
// 先清空再赋值,确保相同 id 也能触发滚动
scrollTarget.value = ''
setTimeout(() => {
scrollTarget.value = `question-${questionNo}`
}, 50)
}
/** 页面加载 */
onLoad((options) => {
typeId.value = Number(options.typeId) || 1
recordId.value = Number(options.recordId) || 0
userStore.restoreFromStorage()
loadQuestions()
})
</script>
<template>
<view class="questions-page">
<!-- 导航栏 -->
<Navbar title="多元智能测评" :showBack="true" />
<!-- 加载状态 -->
<view v-if="pageLoading" class="loading-wrap">
<view class="loading-spinner"></view>
<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>
<!-- 选项列表 - 全部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>
</view>
</view>
</view>
</scroll-view>
<!-- 底部固定提交按钮 -->
<view v-if="!pageLoading" class="submit-fixed">
<view class="submit-btn" :class="{ 'btn-loading': submitting }" @click="handleSubmit">
<text>{{ submitting ? '提交中...' : '提交' }}</text>
</view>
</view>
<!-- 未答题弹窗 -->
<view v-if="showUnansweredPopup" class="popup-mask" @click="closeUnansweredPopup">
<view class="popup-box" @click.stop>
<view class="popup-header">
<text class="popup-title">提示</text>
<view class="popup-close" @click="closeUnansweredPopup"><text>×</text></view>
</view>
<view class="popup-body">
<view class="popup-msg">以下题目尚未作答,请完成后再提交:</view>
<scroll-view class="unanswered-scroll" scroll-y>
<view
v-for="qNo in unansweredQuestions"
:key="qNo"
class="unanswered-row"
@click="scrollToQuestion(qNo)"
>
<text>第 {{ qNo }} 题</text>
<text class="goto-arrow"></text>
</view>
</scroll-view>
</view>
<view class="popup-footer">
<view class="popup-btn" @click="closeUnansweredPopup"><text>我知道了</text></view>
</view>
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
@import '@/styles/variables.scss';
.questions-page {
height: 100vh;
display: flex;
flex-direction: column;
background-color: rgba(255, 234, 231, 1);
overflow: hidden;
}
// ========== 加载状态 ==========
.loading-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 200rpx 0;
.loading-spinner {
width: 60rpx;
height: 60rpx;
border: 4rpx solid $border-color;
border-top-color: #FF6B60;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-text {
margin-top: $spacing-md;
font-size: $font-size-md;
color: $text-secondary;
}
}
@keyframes spin {
to { transform: rotate(360deg); }
}
// ========== 滚动区域 ==========
.scroll-area {
flex: 1;
overflow: hidden;
}
.scroll-inner {
padding: $spacing-lg;
}
.questions-card {
background-color: $bg-white;
border-radius: $border-radius-xl;
padding: $spacing-xl $spacing-lg;
}
// ========== 单个题目 ==========
.question-block {
margin-bottom: $spacing-xl;
&:last-child {
margin-bottom: 0;
}
}
.question-title {
display: flex;
align-items: flex-start;
margin-bottom: $spacing-lg;
}
.question-no {
width: 44rpx;
height: 44rpx;
border-radius: 50%;
background-color: rgba(255, 234, 231, 1);
color: #FF6B60;
font-size: $font-size-md;
font-weight: $font-weight-bold;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: $spacing-sm;
margin-top: 4rpx;
}
.question-text {
flex: 1;
font-size: $font-size-md;
color: $text-color;
font-weight: $font-weight-bold;
line-height: 1.7;
}
// ========== 选项列表 ==========
.options-list {
padding-left: 60rpx;
}
.option-row {
display: flex;
align-items: center;
padding: $spacing-sm 0;
&:active {
opacity: 0.7;
}
}
.radio {
width: 32rpx;
height: 32rpx;
border: 3rpx solid $border-color;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: $spacing-sm;
&.radio-active {
border-color: #FF6B60;
.radio-dot {
width: 18rpx;
height: 18rpx;
background-color: #FF6B60;
border-radius: 50%;
}
}
}
.option-label {
font-size: $font-size-sm;
color: $text-color;
line-height: 1.5;
}
// ========== 底部固定提交按钮 ==========
.submit-fixed {
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);
}
.submit-btn {
height: 96rpx;
background-color: #FF6B60;
border-radius: 48rpx;
display: flex;
align-items: center;
justify-content: center;
text {
font-size: $font-size-xl;
font-weight: $font-weight-bold;
color: $text-white;
}
&:active {
opacity: 0.9;
transform: scale(0.98);
}
&.btn-loading {
opacity: 0.7;
pointer-events: none;
}
}
// ========== 未答题弹窗 ==========
.popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: $bg-mask;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.popup-box {
width: 600rpx;
max-height: 80vh;
background-color: $bg-white;
border-radius: $border-radius-xl;
overflow: hidden;
display: flex;
flex-direction: column;
}
.popup-header {
position: relative;
padding: $spacing-lg;
text-align: center;
border-bottom: 1rpx solid $border-light;
.popup-title {
font-size: $font-size-lg;
font-weight: $font-weight-medium;
color: $text-color;
}
.popup-close {
position: absolute;
top: $spacing-md;
right: $spacing-md;
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
text {
font-size: 48rpx;
color: $text-placeholder;
line-height: 1;
}
}
}
.popup-body {
padding: $spacing-lg;
flex: 1;
overflow: hidden;
.popup-msg {
font-size: $font-size-md;
color: $text-secondary;
margin-bottom: $spacing-md;
}
}
.unanswered-scroll {
max-height: 400rpx;
}
.unanswered-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: $spacing-md $spacing-lg;
background-color: $bg-gray;
border-radius: $border-radius-md;
margin-bottom: $spacing-sm;
text {
font-size: $font-size-md;
color: #FF6B60;
}
.goto-arrow {
color: $text-placeholder;
}
&:active {
background-color: $border-light;
}
}
.popup-footer {
padding: $spacing-lg;
}
.popup-btn {
height: 88rpx;
background-color: #FF6B60;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
text {
font-size: $font-size-lg;
font-weight: $font-weight-medium;
color: $text-white;
}
&:active {
opacity: 0.8;
}
}
</style>