feat(assessment): 连接个人信息页与答题页数据流

- 新增 PendingRecordDto 模型,支持查询进行中的测评记录
- 后端新增 GetPendingRecord 接口,支持断点续答
- 邀请码路径改为调用 createOrder 创建订单和测评记录
- info 页面加载时检测未完成测评,支持继续上次或重新开始
- questions 页面改用 recordId 提交答案,匹配后端接口
- usePayment 返回 assessmentRecordId 供页面传递
This commit is contained in:
zpc 2026-02-23 00:06:24 +08:00
parent e73527f2ae
commit 7a54c91154
8 changed files with 438 additions and 59 deletions

View File

@ -406,4 +406,44 @@ public class AssessmentController : ControllerBase
return ApiResponse<List<ScoreOptionDto>>.Fail("获取评分标准失败");
}
}
/// <summary>
/// 获取用户进行中的测评记录
/// </summary>
/// <remarks>
/// GET /api/assessment/getPendingRecord?typeId=1
///
/// 查询当前用户状态为待测评或测评中的最新测评记录,用于断点续答。
/// 需要用户登录认证。
/// </remarks>
/// <param name="typeId">测评类型ID</param>
/// <returns>进行中的测评记录如果没有则返回null</returns>
[HttpGet("getPendingRecord")]
[Authorize]
[ProducesResponseType(typeof(ApiResponse<PendingRecordDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<PendingRecordDto>), StatusCodes.Status401Unauthorized)]
public async Task<ApiResponse<PendingRecordDto>> GetPendingRecord([FromQuery] long typeId)
{
var userId = GetCurrentUserId();
if (userId == null)
{
return ApiResponse<PendingRecordDto>.Unauthorized();
}
try
{
if (typeId <= 0)
{
return ApiResponse<PendingRecordDto>.Fail("测评类型ID无效");
}
var record = await _assessmentService.GetPendingRecordAsync(userId.Value, typeId);
return ApiResponse<PendingRecordDto>.Success(record!);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get pending record, userId: {UserId}, typeId: {TypeId}", userId, typeId);
return ApiResponse<PendingRecordDto>.Fail("查询进行中的测评记录失败");
}
}
}

View File

@ -112,4 +112,15 @@ public interface IAssessmentService
/// <param name="typeId">测评类型ID</param>
/// <returns>评分标准选项列表</returns>
Task<List<ScoreOptionDto>> GetScoreOptionsAsync(long typeId);
/// <summary>
/// 获取用户进行中的测评记录
/// </summary>
/// <remarks>
/// 查询当前用户状态为待测评(1)或测评中(2)的最新测评记录。
/// 用于页面加载时检测是否有未完成的测评,支持断点续答。
/// </remarks>
/// <param name="userId">当前用户ID</param>
/// <param name="typeId">测评类型ID</param>
/// <returns>进行中的测评记录如果没有则返回null</returns>
Task<PendingRecordDto?> GetPendingRecordAsync(long userId, long typeId);
}

View File

@ -614,4 +614,43 @@ public class AssessmentService : IAssessmentService
return options;
}
/// <inheritdoc />
public async Task<PendingRecordDto?> GetPendingRecordAsync(long userId, long typeId)
{
_logger.LogDebug("查询进行中的测评记录userId: {UserId}, typeId: {TypeId}", userId, typeId);
var record = await _dbContext.AssessmentRecords
.AsNoTracking()
.Where(r => r.UserId == userId
&& r.AssessmentTypeId == typeId
&& (r.Status == 1 || r.Status == 2)
&& !r.IsDeleted)
.OrderByDescending(r => r.CreateTime)
.Select(r => new PendingRecordDto
{
RecordId = r.Id,
OrderId = r.OrderId,
AssessmentTypeId = r.AssessmentTypeId,
Name = r.Name,
Phone = r.Phone,
Gender = r.Gender,
Age = r.Age,
EducationStage = r.EducationStage,
Province = r.Province,
City = r.City,
District = r.District,
Status = r.Status,
CreateTime = r.CreateTime
})
.FirstOrDefaultAsync();
if (record != null)
{
_logger.LogInformation("找到进行中的测评记录userId: {UserId}, recordId: {RecordId}, status: {Status}",
userId, record.RecordId, record.Status);
}
return record;
}
}

View File

@ -0,0 +1,72 @@
namespace MiAssessment.Model.Models.Assessment;
/// <summary>
/// 进行中的测评记录DTO
/// </summary>
public class PendingRecordDto
{
/// <summary>
/// 测评记录ID
/// </summary>
public long RecordId { get; set; }
/// <summary>
/// 订单ID
/// </summary>
public long OrderId { get; set; }
/// <summary>
/// 测评类型ID
/// </summary>
public long AssessmentTypeId { get; set; }
/// <summary>
/// 测评人姓名
/// </summary>
public string Name { get; set; } = null!;
/// <summary>
/// 手机号
/// </summary>
public string Phone { get; set; } = null!;
/// <summary>
/// 性别1男 2女
/// </summary>
public int Gender { get; set; }
/// <summary>
/// 年龄
/// </summary>
public int Age { get; set; }
/// <summary>
/// 学业阶段
/// </summary>
public int EducationStage { get; set; }
/// <summary>
/// 省份
/// </summary>
public string Province { get; set; } = null!;
/// <summary>
/// 城市
/// </summary>
public string City { get; set; } = null!;
/// <summary>
/// 区县
/// </summary>
public string District { get; set; } = null!;
/// <summary>
/// 记录状态1待测评 2测评中
/// </summary>
public int Status { get; set; }
/// <summary>
/// 创建时间
/// </summary>
public DateTime CreateTime { get; set; }
}

View File

@ -25,8 +25,7 @@ export function getQuestionList(typeId) {
/**
* 提交答案
* @param {Object} data - 提交数据
* @param {number} data.typeId - 测评类型ID
* @param {Object} data.userInfo - 用户信息
* @param {number} data.recordId - 测评记录ID
* @param {Array} data.answers - 答案列表
* @returns {Promise<Object>}
*/
@ -81,6 +80,15 @@ export function getScoreOptions(typeId) {
return get('/assessment/getScoreOptions', { typeId })
}
/**
* 获取用户进行中的测评记录
* @param {number} typeId - 测评类型ID
* @returns {Promise<Object>}
*/
export function getPendingRecord(typeId) {
return get('/assessment/getPendingRecord', { typeId })
}
export default {
getIntro,
getQuestionList,
@ -89,5 +97,6 @@ export default {
getResult,
verifyInviteCode,
getHistoryList,
getScoreOptions
getScoreOptions,
getPendingRecord
}

View File

@ -141,7 +141,7 @@ export function usePayment() {
// 3. 查询支付结果
const result = await checkPayResult(order.orderId)
if (result && result.paid) {
return { success: true, orderId: order.orderId }
return { success: true, orderId: order.orderId, assessmentRecordId: order.assessmentRecordId }
}
return { success: false, orderId: order.orderId, error: '支付结果确认失败' }

View File

@ -7,6 +7,7 @@
* - 表单填写姓名手机号性别年龄学业阶段省市区
* - 支付测评和邀请码免费测评两个入口
* - 邀请码验证弹窗
* - 进行中测评记录恢复断点续答
*/
import { ref, computed, watch } from 'vue'
@ -14,7 +15,8 @@ import { onLoad, onUnload } from '@dcloudio/uni-app'
import { useUserStore } from '@/store/user.js'
import { useAuth } from '@/composables/useAuth.js'
import { usePayment } from '@/composables/usePayment.js'
import { getIntro, verifyInviteCode } from '@/api/assessment.js'
import { getIntro, verifyInviteCode, getPendingRecord } from '@/api/assessment.js'
import { createOrder } from '@/api/order.js'
import { isPhone } from '@/utils/validate.js'
import Navbar from '@/components/Navbar/index.vue'
@ -62,6 +64,21 @@ function getEducationStageValue(label) {
return map[label] || 1
}
/**
* 数值转学业阶段文本
*/
function getEducationStageLabel(value) {
const map = { 1: '小学及以下', 2: '初中', 3: '高中', 4: '大专' }
return map[value] || ''
}
/**
* 数值转性别文本
*/
function getGenderLabel(value) {
return value === 1 ? '男' : value === 2 ? '女' : ''
}
//
const genderIndex = ref(-1)
const ageIndex = ref(-1)
@ -78,6 +95,10 @@ const inviteLoading = ref(false)
//
const pageLoading = ref(true)
//
const pendingRecord = ref(null)
const showPendingPopup = ref(false)
// key
const STORAGE_KEY = 'assessment_info_form'
@ -156,6 +177,19 @@ const isFormComplete = computed(() => {
)
})
/**
* 是否处于继续上次测评模式
*/
const isContinueMode = computed(() => pendingRecord.value !== null)
/**
* 底部按钮文案
*/
const payBtnText = computed(() => {
if (isContinueMode.value) return '开始答题'
return `支付¥${introData.value.price || 20}元开始测评`
})
/**
* 加载测评介绍
*/
@ -178,6 +212,56 @@ async function loadIntro() {
}
}
/**
* 检查是否有进行中的测评记录
*/
async function checkPendingRecord() {
if (!userStore.isLoggedIn) return
try {
const res = await getPendingRecord(typeId.value)
if (res && res.code === 0 && res.data) {
pendingRecord.value = res.data
showPendingPopup.value = true
}
} catch (error) {
console.error('查询进行中测评记录失败:', error)
}
}
/**
* 用户选择继续上次测评
*/
function handleContinuePending() {
showPendingPopup.value = false
const record = pendingRecord.value
//
formData.value.name = record.name || ''
formData.value.phone = record.phone || ''
formData.value.gender = getGenderLabel(record.gender)
formData.value.age = record.age ? `${record.age}` : ''
formData.value.educationStage = getEducationStageLabel(record.educationStage)
formData.value.province = record.province || ''
formData.value.city = record.city || ''
formData.value.district = record.district || ''
//
genderIndex.value = record.gender === 1 ? 0 : record.gender === 2 ? 1 : -1
ageIndex.value = record.age ? record.age - 10 : -1
const eduIdx = educationOptions.indexOf(getEducationStageLabel(record.educationStage))
educationIndex.value = eduIdx >= 0 ? eduIdx : -1
regionValue.value = [record.province || '', record.city || '', record.district || '']
}
/**
* 用户选择不继续上次测评
*/
function handleDismissPending() {
showPendingPopup.value = false
pendingRecord.value = null
}
/** 性别选择 */
function onGenderChange(e) {
genderIndex.value = e.detail.value
@ -234,9 +318,35 @@ function validateForm() {
return true
}
/** 支付测评 */
/**
* 构建测评信息对象
*/
function buildAssessmentInfo() {
return {
name: formData.value.name,
phone: formData.value.phone,
gender: formData.value.gender === '男' ? 1 : 2,
age: parseInt(formData.value.age) || 0,
educationStage: getEducationStageValue(formData.value.educationStage),
province: formData.value.province,
city: formData.value.city,
district: formData.value.district
}
}
/** 支付测评 / 继续上次测评 */
async function handlePayAssessment() {
if (!checkLogin()) return
//
if (isContinueMode.value) {
clearFormStorage()
uni.redirectTo({
url: `/pages/assessment/questions/index?typeId=${typeId.value}&recordId=${pendingRecord.value.recordId}`
})
return
}
if (!validateForm()) return
try {
@ -244,23 +354,16 @@ async function handlePayAssessment() {
const result = await processPayment({
orderType: 1,
productId: typeId.value,
assessmentInfo: {
name: formData.value.name,
phone: formData.value.phone,
gender: formData.value.gender === '男' ? 1 : 2,
age: parseInt(formData.value.age) || 0,
educationStage: getEducationStageValue(formData.value.educationStage),
province: formData.value.province,
city: formData.value.city,
district: formData.value.district
}
assessmentInfo: buildAssessmentInfo()
})
uni.hideLoading()
if (result.success) {
clearFormStorage()
// createOrder assessmentRecordId
const recordId = result.assessmentRecordId || ''
uni.redirectTo({
url: `/pages/assessment/questions/index?typeId=${typeId.value}&orderId=${result.orderId}`
url: `/pages/assessment/questions/index?typeId=${typeId.value}&recordId=${recordId}`
})
} else if (result.error) {
uni.showToast({ title: result.error, icon: 'none' })
@ -300,22 +403,42 @@ async function submitInviteCode() {
inviteLoading.value = true
try {
// 1.
const res = await verifyInviteCode(inviteCode.value.trim())
if (res && res.code === 0 && res.data && res.data.isValid) {
closeInvitePopup()
clearFormStorage()
uni.redirectTo({
url: `/pages/assessment/questions/index?typeId=${typeId.value}&inviteCode=${inviteCode.value}`
})
} else {
if (!res || res.code !== 0 || !res.data || !res.data.isValid) {
uni.showToast({
title: res?.data?.errorMessage || res?.message || '邀请码有误,请重新输入',
icon: 'none'
})
return
}
// 2. inviteCodeId
const inviteCodeId = res.data.inviteCodeId
uni.showLoading({ title: '创建订单中...' })
const orderRes = await createOrder({
orderType: 1,
productId: typeId.value,
assessmentInfo: buildAssessmentInfo(),
inviteCodeId: inviteCodeId
})
uni.hideLoading()
if (orderRes && orderRes.code === 0 && orderRes.data) {
closeInvitePopup()
clearFormStorage()
const recordId = orderRes.data.assessmentRecordId || ''
uni.redirectTo({
url: `/pages/assessment/questions/index?typeId=${typeId.value}&recordId=${recordId}`
})
} else {
uni.showToast({ title: orderRes?.message || '创建订单失败', icon: 'none' })
}
} catch (error) {
console.error('验证邀请码失败:', error)
uni.showToast({ title: '验证失败,请重试', icon: 'none' })
uni.hideLoading()
console.error('邀请码提交失败:', error)
uni.showToast({ title: '提交失败,请重试', icon: 'none' })
} finally {
inviteLoading.value = false
}
@ -336,6 +459,8 @@ onLoad((options) => {
}
loadIntro()
//
checkPendingRecord()
})
/** 页面卸载时保存 */
@ -364,7 +489,9 @@ onUnload(() => {
<!-- 表单卡片 -->
<view class="form-card">
<view class="form-tip">正式测评前请先填写您的基本信息</view>
<view class="form-tip">
{{ isContinueMode ? '您有一次未完成的测评,信息已自动填充' : '正式测评前,请先填写您的基本信息' }}
</view>
<!-- 姓名 -->
<view class="form-item">
@ -376,6 +503,7 @@ onUnload(() => {
placeholder-class="input-placeholder"
v-model="formData.name"
maxlength="20"
:disabled="isContinueMode"
/>
</view>
</view>
@ -390,14 +518,15 @@ onUnload(() => {
placeholder-class="input-placeholder"
v-model="formData.phone"
maxlength="11"
:disabled="isContinueMode"
/>
</view>
</view>
<!-- 性别 -->
<view class="form-item">
<view class="form-label"><text class="required">*</text></view>
<picker mode="selector" :range="genderOptions" @change="onGenderChange">
<view class="form-label"><text class="required">*</text></view>
<picker mode="selector" :range="genderOptions" @change="onGenderChange" :disabled="isContinueMode">
<view class="form-select-box">
<text :class="formData.gender ? 'select-value' : 'select-placeholder'">
{{ formData.gender || '请选择' }}
@ -410,7 +539,7 @@ onUnload(() => {
<!-- 年龄 -->
<view class="form-item">
<view class="form-label"><text class="required">*</text>年龄</view>
<picker mode="selector" :range="ageOptions" @change="onAgeChange">
<picker mode="selector" :range="ageOptions" @change="onAgeChange" :disabled="isContinueMode">
<view class="form-select-box">
<text :class="formData.age ? 'select-value' : 'select-placeholder'">
{{ formData.age || '请选择' }}
@ -423,7 +552,7 @@ onUnload(() => {
<!-- 学业阶段 -->
<view class="form-item">
<view class="form-label"><text class="required">*</text>学业阶段</view>
<picker mode="selector" :range="educationOptions" @change="onEducationChange">
<picker mode="selector" :range="educationOptions" @change="onEducationChange" :disabled="isContinueMode">
<view class="form-select-box">
<text :class="formData.educationStage ? 'select-value' : 'select-placeholder'">
{{ formData.educationStage || '请选择' }}
@ -436,7 +565,7 @@ onUnload(() => {
<!-- 所在城市 -->
<view class="form-item form-item-last">
<view class="form-label"><text class="required">*</text>所在城市</view>
<picker mode="region" @change="onRegionChange">
<picker mode="region" @change="onRegionChange" :disabled="isContinueMode">
<view class="form-region-row">
<view class="region-box">
<text :class="formData.province ? 'select-value' : 'select-placeholder'">
@ -463,11 +592,38 @@ onUnload(() => {
<!-- 底部按钮 -->
<view class="btn-group">
<view class="btn-invite" @click="openInvitePopup">
<view v-if="!isContinueMode" class="btn-invite" @click="openInvitePopup">
<text>邀请码免费测评</text>
</view>
<view class="btn-pay" @click="handlePayAssessment">
<text>支付¥{{ introData.price || 20 }}元开始测评</text>
<view class="btn-pay" :class="{ 'btn-full': isContinueMode }" @click="handlePayAssessment">
<text>{{ payBtnText }}</text>
</view>
</view>
<!-- 进行中测评弹窗 -->
<view v-if="showPendingPopup" class="popup-mask" @click="handleDismissPending">
<view class="popup-container" @click.stop>
<view class="popup-header">
<text class="popup-title">提示</text>
<view class="popup-close" @click="handleDismissPending">
<text>×</text>
</view>
</view>
<view class="popup-body">
<view class="pending-msg">您有一次未完成的测评是否继续上次的测评</view>
<view class="pending-info">
<text>姓名{{ pendingRecord?.name }}</text>
<text>手机号{{ pendingRecord?.phone }}</text>
</view>
</view>
<view class="popup-footer pending-footer">
<view class="popup-btn-outline" @click="handleDismissPending">
<text>重新开始</text>
</view>
<view class="popup-btn" @click="handleContinuePending">
<text>继续测评</text>
</view>
</view>
</view>
</view>
@ -615,7 +771,6 @@ onUnload(() => {
font-size: $font-size-md;
color: $text-placeholder;
}
}
// CSS
@ -711,9 +866,13 @@ onUnload(() => {
&:active {
opacity: 0.8;
}
&.btn-full {
flex: 1;
}
}
// ========== ==========
// ========== ==========
.popup-mask {
position: fixed;
top: 0;
@ -768,18 +927,6 @@ onUnload(() => {
padding: $spacing-xl $spacing-lg;
}
.invite-input {
width: 100%;
height: 88rpx;
background-color: $bg-gray;
border-radius: $border-radius-md;
padding: 0 $spacing-lg;
font-size: $font-size-lg;
color: $text-color;
text-align: center;
letter-spacing: 8rpx;
}
.popup-footer {
padding: 0 $spacing-lg $spacing-xl;
}
@ -807,4 +954,69 @@ onUnload(() => {
pointer-events: none;
}
}
.popup-btn-outline {
height: 88rpx;
background-color: $bg-white;
border: 2rpx solid $border-color;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
text {
font-size: $font-size-lg;
font-weight: $font-weight-medium;
color: $text-secondary;
}
&:active {
opacity: 0.8;
}
}
// ========== ==========
.pending-msg {
font-size: $font-size-md;
color: $text-color;
text-align: center;
margin-bottom: $spacing-lg;
}
.pending-info {
display: flex;
flex-direction: column;
gap: $spacing-sm;
padding: $spacing-md $spacing-lg;
background-color: $bg-gray;
border-radius: $border-radius-md;
text {
font-size: $font-size-sm;
color: $text-secondary;
}
}
.pending-footer {
display: flex;
gap: $spacing-md;
.popup-btn-outline,
.popup-btn {
flex: 1;
}
}
// ========== ==========
.invite-input {
width: 100%;
height: 88rpx;
background-color: $bg-gray;
border-radius: $border-radius-md;
padding: 0 $spacing-lg;
font-size: $font-size-lg;
color: $text-color;
text-align: center;
letter-spacing: 8rpx;
}
</style>

View File

@ -19,8 +19,7 @@ const userStore = useUserStore()
//
const typeId = ref(0)
const orderId = ref('')
const inviteCode = ref('')
const recordId = ref(0)
//
const questions = ref([])
@ -141,19 +140,17 @@ async function handleSubmit() {
const answerList = questions.value.map((q, index) => {
const qId = q.id || index + 1
const scoreIndex = answers.value[qId]
return { questionId: qId, score: scoreOptions.value[scoreIndex].score }
return { questionId: qId, answerValue: scoreOptions.value[scoreIndex].score }
})
const res = await submitAnswers({
typeId: typeId.value,
orderId: orderId.value,
inviteCode: inviteCode.value,
recordId: recordId.value,
answers: answerList
})
if (res && res.code === 0) {
const recordId = res.data?.recordId || res.data?.id || ''
uni.redirectTo({ url: `/pages/assessment/loading/index?recordId=${recordId}` })
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' })
}
@ -183,8 +180,7 @@ function scrollToQuestion(questionNo) {
/** 页面加载 */
onLoad((options) => {
typeId.value = Number(options.typeId) || 1
orderId.value = options.orderId || ''
inviteCode.value = options.inviteCode || ''
recordId.value = Number(options.recordId) || 0
userStore.restoreFromStorage()
loadQuestions()
})