Files
MBTI_wang/miniprogram/utils/sbtiEngine.js
Ghost 6fec2965aa feat: SBTI 测评接入与管理端题目/财务/用户联动
1、修复了用户详情、定价与题目管理、财务与分销等管理端展示与接口问题。

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

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

Made-with: Cursor
2026-04-11 16:16:32 +08:00

173 lines
5.2 KiB
JavaScript
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.

/**
* 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 题、每题 13 分 → 分和约 26据此划 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
}