feat: SBTI 测评接入与管理端题目/财务/用户联动

1、修复了用户详情、定价与题目管理、财务与分销等管理端展示与接口问题。

2、新增 SBTI 小程序测评/结果页、sbtiData 引擎、题库 SQL 与脚本;admin sbtiDisplay。

3、优化 AppConfig/CrmReport/Test、历史与个人中心、test-select 与支付相关逻辑。

Made-with: Cursor
This commit is contained in:
Ghost
2026-04-11 16:16:32 +08:00
parent 1a66e77ab7
commit 6fec2965aa
50 changed files with 4189 additions and 1259 deletions

View 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'
})
}
})

View File

@@ -0,0 +1,7 @@
{
"navigationBarTitleText": "SBTI测试",
"navigationBarBackgroundColor": "#f2f7f3",
"navigationBarTextStyle": "black",
"backgroundColor": "#f2f7f3",
"usingComponents": {}
}

View 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>

View 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;
}