feat: SBTI 测评接入与管理端题目/财务/用户联动
1、修复了用户详情、定价与题目管理、财务与分销等管理端展示与接口问题。 2、新增 SBTI 小程序测评/结果页、sbtiData 引擎、题库 SQL 与脚本;admin sbtiDisplay。 3、优化 AppConfig/CrmReport/Test、历史与个人中心、test-select 与支付相关逻辑。 Made-with: Cursor
This commit is contained in:
172
miniprogram/utils/sbtiEngine.js
Normal file
172
miniprogram/utils/sbtiEngine.js
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* SBTI 计分与匹配(与 aisbti.com 公开页逻辑一致)
|
||||
* @param {Array<{id:number,dimension:string,question:string,options:Array}>} questions 题库全部行(含 DG1/DG2)
|
||||
* @param {Record<number|string, number>} answers questionId -> 选项 value
|
||||
*/
|
||||
const data = require('./sbtiData.js')
|
||||
|
||||
/** 每维 2 题、每题 1~3 分 → 分和约 2~6;据此划 L/M/H(非「满分 6 分」) */
|
||||
function sumToLevel(score) {
|
||||
if (score <= 3) return 'L'
|
||||
if (score === 4) return 'M'
|
||||
return 'H'
|
||||
}
|
||||
|
||||
function levelNum(level) {
|
||||
return { L: 1, M: 2, H: 3 }[level] || 1
|
||||
}
|
||||
|
||||
function parsePattern(pattern) {
|
||||
return pattern.replace(/-/g, '').split('')
|
||||
}
|
||||
|
||||
function shuffle(arr) {
|
||||
const a = arr.slice()
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[a[i], a[j]] = [a[j], a[i]]
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
/**
|
||||
* 组卷:打乱计分题,随机插入爱好闸口;饮酒时出现 DG2(由页面在 DG1=3 时插入)
|
||||
*/
|
||||
function buildShuffledPaper(allQuestions) {
|
||||
const scoring = allQuestions.filter((q) => q.dimension && !['DG1', 'DG2'].includes(q.dimension))
|
||||
const dg1 = allQuestions.find((q) => q.dimension === 'DG1')
|
||||
const dg2 = allQuestions.find((q) => q.dimension === 'DG2')
|
||||
const shuffled = shuffle(scoring)
|
||||
const insertIndex = Math.floor(Math.random() * shuffled.length) + 1
|
||||
const ordered = [...shuffled.slice(0, insertIndex)]
|
||||
if (dg1) ordered.push(dg1)
|
||||
ordered.push(...shuffled.slice(insertIndex))
|
||||
return { ordered, dg1, dg2 }
|
||||
}
|
||||
|
||||
/** 当前可见题序:选「饮酒」后在闸口题后插入 DG2 */
|
||||
function getVisibleQuestions(ordered, answers, dg2) {
|
||||
const visible = ordered.slice()
|
||||
if (!dg2) return visible
|
||||
if (visible.some((q) => q.id === dg2.id)) return visible
|
||||
const gateIdx = visible.findIndex((q) => q.dimension === 'DG1')
|
||||
if (gateIdx !== -1 && Number(answers[visible[gateIdx].id]) === 3) {
|
||||
visible.splice(gateIdx + 1, 0, dg2)
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
function computeSbtiResult(questions, answers) {
|
||||
const { TYPE_LIBRARY, NORMAL_TYPES, DIM_EXPLANATIONS, dimensionOrder } = data
|
||||
const rawScores = {}
|
||||
dimensionOrder.forEach((dim) => {
|
||||
rawScores[dim] = 0
|
||||
})
|
||||
|
||||
questions.forEach((q) => {
|
||||
const dim = q.dimension
|
||||
if (!dim || dim === 'DG1' || dim === 'DG2') return
|
||||
if (!dimensionOrder.includes(dim)) return
|
||||
const v = answers[q.id]
|
||||
if (v == null || v === '') return
|
||||
rawScores[dim] += Number(v) || 0
|
||||
})
|
||||
|
||||
const levels = {}
|
||||
Object.entries(rawScores).forEach(([dim, score]) => {
|
||||
levels[dim] = sumToLevel(score)
|
||||
})
|
||||
|
||||
const userVector = dimensionOrder.map((dim) => levelNum(levels[dim]))
|
||||
const ranked = NORMAL_TYPES.map((type) => {
|
||||
const vector = parsePattern(type.pattern).map(levelNum)
|
||||
let distance = 0
|
||||
let exact = 0
|
||||
for (let i = 0; i < vector.length; i++) {
|
||||
const diff = Math.abs(userVector[i] - vector[i])
|
||||
distance += diff
|
||||
if (diff === 0) exact += 1
|
||||
}
|
||||
const similarity = Math.max(0, Math.round((1 - distance / 30) * 100))
|
||||
return { ...type, ...TYPE_LIBRARY[type.code], distance, exact, similarity }
|
||||
}).sort((a, b) => {
|
||||
if (a.distance !== b.distance) return a.distance - b.distance
|
||||
if (b.exact !== a.exact) return b.exact - a.exact
|
||||
return b.similarity - a.similarity
|
||||
})
|
||||
|
||||
const bestNormal = ranked[0]
|
||||
let dg2Id = null
|
||||
questions.forEach((q) => {
|
||||
if (q.dimension === 'DG2') dg2Id = q.id
|
||||
})
|
||||
const drunkTriggered = dg2Id != null && Number(answers[dg2Id]) === 2
|
||||
|
||||
let finalType
|
||||
let modeKicker = '你的主类型'
|
||||
let badge = `匹配度 ${bestNormal.similarity}% · 精准命中 ${bestNormal.exact}/15 维`
|
||||
let sub = '维度命中度较高,当前结果可视为你的第一人格画像。'
|
||||
let special = false
|
||||
let secondaryType = null
|
||||
|
||||
if (drunkTriggered) {
|
||||
finalType = { ...TYPE_LIBRARY.DRUNK }
|
||||
secondaryType = bestNormal
|
||||
modeKicker = '隐藏人格已激活'
|
||||
badge = '匹配度 100% · 酒精异常因子已接管'
|
||||
sub = '乙醇亲和性过强,系统已直接跳过常规人格审判。'
|
||||
special = true
|
||||
} else if (bestNormal.similarity < 60) {
|
||||
finalType = { ...TYPE_LIBRARY.HHHH }
|
||||
modeKicker = '系统强制兜底'
|
||||
badge = `标准人格库最高匹配仅 ${bestNormal.similarity}%`
|
||||
sub = '标准人格库对你的脑回路集体罢工了,于是系统把你强制分配给了 HHHH。'
|
||||
special = true
|
||||
} else {
|
||||
finalType = { ...bestNormal }
|
||||
}
|
||||
|
||||
/** 供结果页「匹配度 / 精准命中」展示(与 badge 文案一致) */
|
||||
let matchPercent = bestNormal.similarity
|
||||
let hitDimCount = bestNormal.exact
|
||||
if (drunkTriggered) {
|
||||
matchPercent = 100
|
||||
hitDimCount = 15
|
||||
}
|
||||
|
||||
const dimExplainList = dimensionOrder.map((dim) => ({
|
||||
dim,
|
||||
name: data.dimensionMeta[dim].name,
|
||||
model: data.dimensionMeta[dim].model,
|
||||
level: levels[dim],
|
||||
raw: rawScores[dim],
|
||||
text: DIM_EXPLANATIONS[dim][levels[dim]]
|
||||
}))
|
||||
|
||||
return {
|
||||
rawScores,
|
||||
levels,
|
||||
ranked,
|
||||
bestNormal,
|
||||
finalType,
|
||||
modeKicker,
|
||||
badge,
|
||||
sub,
|
||||
special,
|
||||
secondaryType,
|
||||
dimExplainList,
|
||||
sbtiType: finalType.code,
|
||||
sbtiCn: finalType.cn,
|
||||
intro: finalType.intro,
|
||||
desc: finalType.desc,
|
||||
matchPercent,
|
||||
hitDimCount
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
computeSbtiResult,
|
||||
buildShuffledPaper,
|
||||
getVisibleQuestions,
|
||||
TYPE_IMAGES: data.TYPE_IMAGES
|
||||
}
|
||||
Reference in New Issue
Block a user