feat: SBTI 测评接入与管理端题目/财务/用户联动
1、修复了用户详情、定价与题目管理、财务与分销等管理端展示与接口问题。 2、新增 SBTI 小程序测评/结果页、sbtiData 引擎、题库 SQL 与脚本;admin sbtiDisplay。 3、优化 AppConfig/CrmReport/Test、历史与个人中心、test-select 与支付相关逻辑。 Made-with: Cursor
This commit is contained in:
249
miniprogram/pages/test/sbti.js
Normal file
249
miniprogram/pages/test/sbti.js
Normal file
@@ -0,0 +1,249 @@
|
||||
// pages/test/sbti.js — SBTI:服务端全量拉题 + 组卷(闸口 DG1/DG2)+ sbtiEngine 计分
|
||||
const { fetchQuestionBank } = require('../../utils/questionBank')
|
||||
const { buildShuffledPaper, getVisibleQuestions, computeSbtiResult } = require('../../utils/sbtiEngine')
|
||||
const app = getApp()
|
||||
|
||||
const SBTI_TIME_SEC = 45 * 60
|
||||
|
||||
Page({
|
||||
data: {
|
||||
loading: true,
|
||||
currentIndex: 0,
|
||||
currentQuestion: null,
|
||||
answers: {},
|
||||
total: 0,
|
||||
progress: 0,
|
||||
timeRemaining: SBTI_TIME_SEC,
|
||||
_initialSeconds: SBTI_TIME_SEC,
|
||||
formatTime: '45:00',
|
||||
isSubmitting: false
|
||||
},
|
||||
|
||||
timer: null,
|
||||
_paper: null,
|
||||
_advanceTimer: null,
|
||||
|
||||
onLoad(options) {
|
||||
try {
|
||||
require('../../utils/thirdPartyContext.js').ingestThirdPartyOnPageLoad(options || {}, app)
|
||||
} catch (e) {}
|
||||
fetchQuestionBank('sbti', {})
|
||||
.then((all) => {
|
||||
if (!all || !all.length) {
|
||||
wx.showToast({ title: '暂无题目', icon: 'none' })
|
||||
this.setData({ loading: false })
|
||||
return
|
||||
}
|
||||
const paper = buildShuffledPaper(all)
|
||||
this._paper = paper
|
||||
const visible = getVisibleQuestions(paper.ordered, {}, paper.dg2)
|
||||
const total = visible.length
|
||||
if (!total) {
|
||||
wx.showToast({ title: '组卷失败', icon: 'none' })
|
||||
this.setData({ loading: false })
|
||||
return
|
||||
}
|
||||
this.setData({
|
||||
loading: false,
|
||||
currentIndex: 0,
|
||||
currentQuestion: visible[0],
|
||||
total,
|
||||
progress: total ? Math.round((1 / total) * 100) : 0,
|
||||
timeRemaining: SBTI_TIME_SEC,
|
||||
_initialSeconds: SBTI_TIME_SEC,
|
||||
formatTime: '45:00'
|
||||
})
|
||||
try {
|
||||
require('../../utils/analytics').track('test_start', { type: 'sbti', total })
|
||||
} catch (e) {}
|
||||
this.startTimer()
|
||||
})
|
||||
.catch((err) => {
|
||||
this.setData({ loading: false })
|
||||
wx.showToast({ title: (err && err.message) || '加载失败', icon: 'none' })
|
||||
})
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
if (this._advanceTimer) {
|
||||
clearTimeout(this._advanceTimer)
|
||||
this._advanceTimer = null
|
||||
}
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer)
|
||||
}
|
||||
},
|
||||
|
||||
startTimer() {
|
||||
this.timer = setInterval(() => {
|
||||
let time = this.data.timeRemaining - 1
|
||||
if (time <= 0) {
|
||||
clearInterval(this.timer)
|
||||
this.submitTest({ allowIncomplete: true })
|
||||
return
|
||||
}
|
||||
const minutes = Math.floor(time / 60)
|
||||
const seconds = time % 60
|
||||
this.setData({
|
||||
timeRemaining: time,
|
||||
formatTime: `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
|
||||
})
|
||||
}, 1000)
|
||||
},
|
||||
|
||||
_rebuildVisible() {
|
||||
const paper = this._paper
|
||||
if (!paper) return []
|
||||
return getVisibleQuestions(paper.ordered, this.data.answers, paper.dg2)
|
||||
},
|
||||
|
||||
selectAnswer(e) {
|
||||
if (this._advanceTimer) {
|
||||
clearTimeout(this._advanceTimer)
|
||||
this._advanceTimer = null
|
||||
}
|
||||
const value = e.currentTarget.dataset.value
|
||||
const cq = this.data.currentQuestion
|
||||
if (value == null || !cq || cq.id == null) return
|
||||
|
||||
const questionId = cq.id
|
||||
const idx = this.data.currentIndex
|
||||
const answers = { ...this.data.answers, [questionId]: value }
|
||||
|
||||
const visible = getVisibleQuestions(this._paper.ordered, answers, this._paper.dg2)
|
||||
const tot = visible.length
|
||||
const curPos = visible.findIndex((q) => q.id === questionId)
|
||||
const nextIdx = curPos >= 0 ? curPos + 1 : idx + 1
|
||||
|
||||
this.setData({ answers }, () => {
|
||||
this._advanceTimer = setTimeout(() => {
|
||||
this._advanceTimer = null
|
||||
const d = this.data
|
||||
if (d.currentIndex !== idx || !d.currentQuestion || d.currentQuestion.id !== questionId) return
|
||||
if (nextIdx < visible.length) {
|
||||
const nq = visible[nextIdx]
|
||||
this.setData({
|
||||
currentIndex: nextIdx,
|
||||
currentQuestion: nq,
|
||||
total: tot,
|
||||
progress: tot ? Math.round(((nextIdx + 1) / tot) * 100) : 0
|
||||
})
|
||||
} else {
|
||||
this.submitTest()
|
||||
}
|
||||
}, 320)
|
||||
})
|
||||
},
|
||||
|
||||
prevQuestion() {
|
||||
if (this._advanceTimer) {
|
||||
clearTimeout(this._advanceTimer)
|
||||
this._advanceTimer = null
|
||||
}
|
||||
if (this.data.currentIndex <= 0) return
|
||||
const visible = this._rebuildVisible()
|
||||
const newIndex = this.data.currentIndex - 1
|
||||
const nq = visible[newIndex]
|
||||
if (!nq) return
|
||||
const tot = visible.length
|
||||
this.setData({
|
||||
currentIndex: newIndex,
|
||||
currentQuestion: nq,
|
||||
total: tot,
|
||||
progress: tot ? Math.round(((newIndex + 1) / tot) * 100) : 0
|
||||
})
|
||||
},
|
||||
|
||||
nextQuestion() {
|
||||
const visible = this._rebuildVisible()
|
||||
if (this.data.currentIndex < visible.length - 1) {
|
||||
const newIndex = this.data.currentIndex + 1
|
||||
const nq = visible[newIndex]
|
||||
const tot = visible.length
|
||||
this.setData({
|
||||
currentIndex: newIndex,
|
||||
currentQuestion: nq,
|
||||
total: tot,
|
||||
progress: tot ? Math.round(((newIndex + 1) / tot) * 100) : 0
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
finishTest() {
|
||||
const q = this.data.currentQuestion
|
||||
if (!q) return
|
||||
if (this.data.answers[q.id] == null) {
|
||||
wx.showToast({ title: '请先选择一项', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const visible = this._rebuildVisible()
|
||||
const missing = visible.filter((x) => this.data.answers[x.id] == null || this.data.answers[x.id] === '')
|
||||
if (missing.length) {
|
||||
wx.showToast({ title: '还有题目未作答', icon: 'none' })
|
||||
return
|
||||
}
|
||||
this.submitTest()
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {{ allowIncomplete?: boolean }} opt 计时结束允许未答完也提交
|
||||
*/
|
||||
submitTest(opt = {}) {
|
||||
if (this.data.isSubmitting) return
|
||||
const allowIncomplete = !!opt.allowIncomplete
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
|
||||
const visible = this._rebuildVisible()
|
||||
const answers = this.data.answers
|
||||
if (!allowIncomplete) {
|
||||
const missing = visible.filter((x) => answers[x.id] == null || answers[x.id] === '')
|
||||
if (missing.length) {
|
||||
wx.showToast({ title: `还有 ${missing.length} 题未作答`, icon: 'none' })
|
||||
this.startTimer()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.setData({ isSubmitting: true })
|
||||
|
||||
let result
|
||||
try {
|
||||
const paper = this._paper
|
||||
const qs = paper ? paper.ordered.slice() : []
|
||||
if (paper && paper.dg2 && !qs.some((q) => q.id === paper.dg2.id)) {
|
||||
qs.push(paper.dg2)
|
||||
}
|
||||
result = computeSbtiResult(qs, answers)
|
||||
} catch (err) {
|
||||
console.error('computeSbtiResult', err)
|
||||
wx.showToast({ title: '计算结果失败,请重试', icon: 'none' })
|
||||
this.setData({ isSubmitting: false })
|
||||
this.startTimer()
|
||||
return
|
||||
}
|
||||
|
||||
const resultData = {
|
||||
...result,
|
||||
answers,
|
||||
testDuration: (this.data._initialSeconds || SBTI_TIME_SEC) - this.data.timeRemaining,
|
||||
completedAt: new Date().toISOString(),
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
wx.setStorageSync('sbtiResult', resultData)
|
||||
app.saveTestResult('sbti', resultData)
|
||||
try {
|
||||
require('../../utils/analytics').track('test_complete', {
|
||||
type: 'sbti',
|
||||
result: result.sbtiType,
|
||||
duration: resultData.testDuration
|
||||
})
|
||||
} catch (e) {}
|
||||
|
||||
wx.redirectTo({
|
||||
url: '/pages/result/sbti'
|
||||
})
|
||||
}
|
||||
})
|
||||
7
miniprogram/pages/test/sbti.json
Normal file
7
miniprogram/pages/test/sbti.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"navigationBarTitleText": "SBTI测试",
|
||||
"navigationBarBackgroundColor": "#f2f7f3",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#f2f7f3",
|
||||
"usingComponents": {}
|
||||
}
|
||||
54
miniprogram/pages/test/sbti.wxml
Normal file
54
miniprogram/pages/test/sbti.wxml
Normal file
@@ -0,0 +1,54 @@
|
||||
<!--pages/test/sbti.wxml - SBTI 测试(闸口题由逻辑动态插入)-->
|
||||
<view class="test-page">
|
||||
<view wx:if="{{loading}}" class="test-loading">
|
||||
<text class="test-loading-text">加载题目…</text>
|
||||
</view>
|
||||
<block wx:elif="{{currentQuestion}}">
|
||||
<view class="progress-section">
|
||||
<view class="progress-info">
|
||||
<text class="question-count">问题 {{currentIndex + 1}}/{{total}}</text>
|
||||
<text class="time-remaining">剩余时间: {{formatTime}}</text>
|
||||
</view>
|
||||
<view class="progress-bar-container">
|
||||
<view class="progress-bar" style="width: {{progress}}%"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="content-area">
|
||||
<view class="question-card">
|
||||
<text class="question-text">{{currentQuestion.question}}</text>
|
||||
<view class="options-container">
|
||||
<view
|
||||
class="option-item {{answers[currentQuestion.id] === option.value ? 'selected' : ''}}"
|
||||
wx:for="{{currentQuestion.options}}"
|
||||
wx:for-item="option"
|
||||
wx:for-index="optIdx"
|
||||
wx:key="optIdx"
|
||||
bindtap="selectAnswer"
|
||||
data-value="{{option.value}}"
|
||||
>
|
||||
<view class="radio-button {{answers[currentQuestion.id] === option.value ? 'checked' : ''}}">
|
||||
<view wx:if="{{answers[currentQuestion.id] === option.value}}" class="radio-inner"></view>
|
||||
</view>
|
||||
<text class="option-text">{{option.text}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:if="{{currentIndex === total - 1}}" class="last-hint">
|
||||
<text class="last-hint-text">最后一题:选择后约 0.3 秒自动跳转结果页;若未跳转,请点右下角「查看结果」。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="footer-buttons">
|
||||
<view class="nav-button secondary {{currentIndex === 0 ? 'disabled' : ''}}" bindtap="prevQuestion">
|
||||
<text class="button-text">上一题</text>
|
||||
</view>
|
||||
<view wx:if="{{currentIndex < total - 1}}" class="nav-button secondary" bindtap="nextQuestion">
|
||||
<text class="button-text">跳过</text>
|
||||
</view>
|
||||
<view wx:else class="nav-button primary {{isSubmitting ? 'disabled' : ''}}" bindtap="finishTest">
|
||||
<text class="button-text button-text-on-primary">{{isSubmitting ? '正在生成…' : '查看结果'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
197
miniprogram/pages/test/sbti.wxss
Normal file
197
miniprogram/pages/test/sbti.wxss
Normal file
@@ -0,0 +1,197 @@
|
||||
/* pages/test/sbti.wxss — 主题底 #f2f7f3,强调 #5a7268 */
|
||||
.test-page {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #f2f7f3;
|
||||
}
|
||||
|
||||
.test-loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80rpx;
|
||||
}
|
||||
|
||||
.test-loading-text {
|
||||
font-size: 30rpx;
|
||||
color: #5a7268;
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
padding: 32rpx;
|
||||
border-bottom: 1rpx solid #dce8e0;
|
||||
flex-shrink: 0;
|
||||
background: #f2f7f3;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.question-count {
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: #2d3d35;
|
||||
}
|
||||
|
||||
.time-remaining {
|
||||
font-size: 28rpx;
|
||||
color: #6b7f72;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
width: 100%;
|
||||
height: 8rpx;
|
||||
background-color: #dce8e0;
|
||||
border-radius: 8rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #5a7268 0%, #7d9488 100%);
|
||||
border-radius: 8rpx;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 32rpx;
|
||||
}
|
||||
|
||||
.question-card {
|
||||
background-color: #fff;
|
||||
border-radius: 24rpx;
|
||||
padding: 48rpx;
|
||||
box-shadow: 0 4rpx 12rpx rgba(45, 61, 53, 0.06);
|
||||
border: 1rpx solid #dce8e0;
|
||||
}
|
||||
|
||||
.question-text {
|
||||
display: block;
|
||||
font-size: 40rpx;
|
||||
font-weight: 500;
|
||||
color: #2d3d35;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 48rpx;
|
||||
}
|
||||
|
||||
.options-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32rpx;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 32rpx;
|
||||
border: 2rpx solid #dce8e0;
|
||||
border-radius: 16rpx;
|
||||
transition: all 0.3s ease;
|
||||
background: #fafcfb;
|
||||
}
|
||||
|
||||
.option-item.selected {
|
||||
background-color: rgba(90, 114, 104, 0.1);
|
||||
border-color: #5a7268;
|
||||
}
|
||||
|
||||
.radio-button {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 50%;
|
||||
border: 2rpx solid #b8c9c0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 24rpx;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.radio-button.checked {
|
||||
background-color: #5a7268;
|
||||
border-color: #5a7268;
|
||||
}
|
||||
|
||||
.radio-inner {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.option-text {
|
||||
flex: 1;
|
||||
font-size: 32rpx;
|
||||
color: #2d3d35;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.footer-buttons {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
padding: 32rpx;
|
||||
border-top: 1rpx solid #dce8e0;
|
||||
flex-shrink: 0;
|
||||
background: #f2f7f3;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
flex: 1;
|
||||
padding: 28rpx;
|
||||
border-radius: 16rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nav-button.secondary {
|
||||
background-color: #fff;
|
||||
border: 2rpx solid #5a7268;
|
||||
}
|
||||
|
||||
.nav-button.secondary .button-text {
|
||||
color: #5a7268;
|
||||
}
|
||||
|
||||
.nav-button.primary {
|
||||
background: linear-gradient(135deg, #5a7268 0%, #6f8a7e 100%);
|
||||
border: none;
|
||||
box-shadow: 0 8rpx 24rpx rgba(90, 114, 104, 0.28);
|
||||
}
|
||||
|
||||
.button-text-on-primary {
|
||||
color: #ffffff !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-button.disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.last-hint {
|
||||
margin-top: 24rpx;
|
||||
padding: 20rpx 24rpx;
|
||||
background: rgba(90, 114, 104, 0.08);
|
||||
border-radius: 16rpx;
|
||||
border: 1rpx solid #c5d4cc;
|
||||
}
|
||||
|
||||
.last-hint-text {
|
||||
font-size: 26rpx;
|
||||
color: #3d5248;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.button-text {
|
||||
font-size: 32rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
Reference in New Issue
Block a user