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

@@ -57,6 +57,7 @@ App({
unlockedTests: [],
// 测试结果缓存
mbtiResult: null,
sbtiResult: null,
discResult: null,
pdpResult: null,
aiResult: null,
@@ -204,6 +205,7 @@ App({
// 测试结果
this.globalData.mbtiResult = wx.getStorageSync('mbtiResult') || null
this.globalData.sbtiResult = wx.getStorageSync('sbtiResult') || null
this.globalData.discResult = wx.getStorageSync('discResult') || null
this.globalData.pdpResult = wx.getStorageSync('pdpResult') || null
this.globalData.aiResult = wx.getStorageSync('aiResult') || null

View File

@@ -6,9 +6,11 @@
"pages/index/result",
"pages/test-select/index",
"pages/test/mbti",
"pages/test/sbti",
"pages/test/disc",
"pages/test/pdp",
"pages/result/mbti",
"pages/result/sbti",
"pages/result/disc",
"pages/result/pdp",
"pages/result/resume",

View File

@@ -15,6 +15,7 @@ Page({
isEnterprise: false,
reviewMode: false,
permMbti: true,
permSbti: true,
permPdp: true,
permDisc: true,
permFace: true
@@ -31,18 +32,20 @@ Page({
_syncPermsAndTab() {
const p = app.globalData.enterprisePermissions
const permMbti = !p || p.mbti !== false
const permSbti = !p || p.sbti !== false
const permPdp = !p || p.pdp !== false
const permDisc = !p || p.disc !== false
const permFace = !p || p.face !== false
const reviewMode = !!app.globalData.reviewMode
const names = { all: '', mbti: 'MBTI', pdp: 'PDP', disc: 'DISC', ai: '面相', resume: '简历' }
const names = { all: '', mbti: 'MBTI', sbti: 'SBTI', pdp: 'PDP', disc: 'DISC', ai: '面相', resume: '简历' }
let { activeTab } = this.data
if (activeTab === 'mbti' && !permMbti) activeTab = 'all'
if (activeTab === 'sbti' && !permSbti) activeTab = 'all'
if (activeTab === 'pdp' && !permPdp) activeTab = 'all'
if (activeTab === 'disc' && !permDisc) activeTab = 'all'
if (activeTab === 'ai' && (!permFace || reviewMode)) activeTab = 'all'
this.setData({
permMbti, permPdp, permDisc, permFace, reviewMode,
permMbti, permSbti, permPdp, permDisc, permFace, reviewMode,
activeTab,
tabName: names[activeTab] || ''
})
@@ -138,8 +141,8 @@ Page({
},
formatList(rawList) {
const typeNames = { mbti: 'MBTI性格测试', disc: 'DISC性格测试', pdp: 'PDP行为偏好测试', ai: '面相分析', resume: '简历综合分析' }
const emojis = { mbti: '🧠', disc: '📊', pdp: '🦁', ai: '👁️', resume: '📋' }
const typeNames = { mbti: 'MBTI性格测试', sbti: 'SBTI性格测试', disc: 'DISC性格测试', pdp: 'PDP行为偏好测试', ai: '面相分析', resume: '简历综合分析' }
const emojis = { mbti: '🧠', sbti: '🎭', disc: '📊', pdp: '🦁', ai: '👁️', resume: '📋' }
return rawList.map((item, idx) => {
if (item.typeName) {
@@ -162,13 +165,27 @@ Page({
// 本地缓存回退
loadFromStorage() {
const { permMbti, permPdp, permDisc, permFace, reviewMode } = this.data
const { permMbti, permSbti, permPdp, permDisc, permFace, reviewMode } = this.data
const mbtiResult = wx.getStorageSync('mbtiResult')
const sbtiResult = wx.getStorageSync('sbtiResult')
const discResult = wx.getStorageSync('discResult')
const pdpResult = wx.getStorageSync('pdpResult')
const aiResult = wx.getStorageSync('aiResult')
const list = []
if (mbtiResult && permMbti) list.push({ type: 'mbti', key: 'mbti', emoji: '🧠', typeName: 'MBTI性格测试', resultText: mbtiResult.mbtiType || '未知', testTime: this.formatTime(mbtiResult.timestamp), data: mbtiResult })
if (sbtiResult && permSbti) {
const rt = sbtiResult.sbtiType || (sbtiResult.finalType && sbtiResult.finalType.code) || '未知'
const cn = sbtiResult.sbtiCn || (sbtiResult.finalType && sbtiResult.finalType.cn) || ''
list.push({
type: 'sbti',
key: 'sbti',
emoji: '🎭',
typeName: 'SBTI性格测试',
resultText: cn ? `${rt}${cn}` : rt,
testTime: this.formatTime(sbtiResult.timestamp || sbtiResult.completedAt),
data: sbtiResult
})
}
if (pdpResult && permPdp) list.push({ type: 'pdp', key: 'pdp', emoji: pdpResult.description?.emoji || '🦁', typeName: 'PDP行为偏好测试', resultText: pdpResult.description?.type || '未知', testTime: this.formatTime(pdpResult.timestamp || pdpResult.completedAt), data: pdpResult })
if (discResult && permDisc) list.push({ type: 'disc', key: 'disc', emoji: '📊', typeName: 'DISC性格测试', resultText: (discResult.dominantType || '未知') + '型', testTime: this.formatTime(discResult.timestamp || discResult.completedAt), data: discResult })
if (aiResult && permFace && !reviewMode) list.push({ type: 'ai', key: 'ai', emoji: '👁️', typeName: '面相分析', resultText: aiResult.mbti || '未知', testTime: this.formatTime(aiResult.timestamp || aiResult.completedAt), data: aiResult })
@@ -177,7 +194,7 @@ Page({
changeTab(e) {
const tab = e.currentTarget.dataset.tab
const names = { all: '', mbti: 'MBTI', pdp: 'PDP', disc: 'DISC', ai: '面相', resume: '简历' }
const names = { all: '', mbti: 'MBTI', sbti: 'SBTI', pdp: 'PDP', disc: 'DISC', ai: '面相', resume: '简历' }
this.setData({ activeTab: tab, tabName: names[tab] || '' })
this.loadAll()
},
@@ -198,6 +215,7 @@ Page({
const id = e.currentTarget.dataset.id
const routes = {
mbti: '/pages/result/mbti',
sbti: '/pages/result/sbti',
disc: '/pages/result/disc',
pdp: '/pages/result/pdp',
ai: '/pages/index/result',
@@ -213,6 +231,7 @@ Page({
goToTest() {
const routes = {
mbti: '/pages/test/mbti',
sbti: '/pages/test/sbti',
disc: '/pages/test/disc',
pdp: '/pages/test/pdp',
ai: '/pages/index/camera',

View File

@@ -10,6 +10,9 @@
<view wx:if="{{permMbti}}" class="tab-item {{activeTab === 'mbti' ? 'active' : ''}}" bindtap="changeTab" data-tab="mbti">
<text class="tab-text">MBTI</text>
</view>
<view wx:if="{{permSbti}}" class="tab-item {{activeTab === 'sbti' ? 'active' : ''}}" bindtap="changeTab" data-tab="sbti">
<text class="tab-text">SBTI</text>
</view>
<view wx:if="{{permPdp}}" class="tab-item {{activeTab === 'pdp' ? 'active' : ''}}" bindtap="changeTab" data-tab="pdp">
<text class="tab-text">PDP</text>
</view>

View File

@@ -112,6 +112,7 @@
}
.icon-mbti { background-color: #f3e8ff; }
.icon-sbti { background-color: #f2f7f3; border: 1rpx solid #dce8e0; }
.icon-pdp { background-color: #dbeafe; }
.icon-disc { background-color: #dcfce7; }
.icon-ai { background-color: #fce7f3; }
@@ -152,6 +153,9 @@
.badge-mbti { background-color: #f3e8ff; }
.badge-mbti .badge-text { color: #7c3aed; }
.badge-sbti { background-color: #f2f7f3; }
.badge-sbti .badge-text { color: #5a7268; }
.badge-pdp { background-color: #dbeafe; }
.badge-pdp .badge-text { color: #2563eb; }

View File

@@ -22,18 +22,21 @@ Page({
testCount: 0,
hasResults: false,
mbtiType: '',
sbtiType: '',
discType: '',
pdpType: '',
aiType: '',
/** 面相记录中的盖洛普前三摘要(/api/test/recent 或本地 aiResult */
gallupPreview: '',
mbtiTime: '',
sbtiTime: '',
discTime: '',
pdpTime: '',
aiTime: '',
reviewMode: false,
/** 最近记录的数据库 ID用于跳转时传参 */
mbtiResultId: null,
sbtiResultId: null,
discResultId: null,
pdpResultId: null,
aiResultId: null,
@@ -55,6 +58,7 @@ Page({
// 企业功能权限:默认全开(个人版 / 未配置时)
permFace: true,
permMbti: true,
permSbti: true,
permPdp: true,
permDisc: true,
permDistribution: true,
@@ -67,13 +71,18 @@ Page({
_computeShowLatestTestRow(d) {
const rm = !!(d.reviewMode)
if (rm) {
return !!(d.permMbti || d.permPdp || d.permDisc)
return !!(d.permMbti || d.permSbti || d.permPdp || d.permDisc)
}
return !!(d.permMbti || d.permPdp || d.permDisc || d.permFace)
return !!(d.permMbti || d.permSbti || d.permPdp || d.permDisc || d.permFace)
},
_computeShowEmptyPersonalityTags(d) {
return !((d.mbtiType && d.permMbti) || (d.discType && d.permDisc) || (d.pdpType && d.permPdp))
return !(
(d.mbtiType && d.permMbti) ||
(d.sbtiType && d.permSbti) ||
(d.discType && d.permDisc) ||
(d.pdpType && d.permPdp)
)
},
onLoad() {
@@ -85,6 +94,7 @@ Page({
const next = {
permFace: !p || p.face !== false,
permMbti: !p || p.mbti !== false,
permSbti: !p || p.sbti !== false,
permPdp: !p || p.pdp !== false,
permDisc: !p || p.disc !== false,
permDistribution: !p || p.distribution !== false,
@@ -214,21 +224,25 @@ Page({
const r = records
const discType = summaryFromRecentRecord(r.disc, 'disc')
const sbtiType = summaryFromRecentRecord(r.sbti, 'sbti')
const gallupPreview = (r.ai && r.ai.gallupPreview) ? String(r.ai.gallupPreview) : ''
const patch = {
testCount: totalCount,
hasResults: !!(r.mbti || r.disc || r.pdp || r.ai),
hasResults: !!(r.mbti || r.sbti || r.disc || r.pdp || r.ai),
mbtiType: r.mbti ? r.mbti.resultText : '',
sbtiType,
discType,
pdpType: summaryFromRecentRecord(r.pdp, 'pdp'),
aiType: r.ai ? r.ai.resultText : '',
gallupPreview,
mbtiTime: r.mbti ? r.mbti.testTime : '',
sbtiTime: r.sbti ? r.sbti.testTime : '',
discTime: r.disc ? r.disc.testTime : '',
pdpTime: r.pdp ? r.pdp.testTime : '',
aiTime: r.ai ? r.ai.testTime : '',
mbtiResultId: r.mbti ? r.mbti.id : null,
sbtiResultId: r.sbti ? r.sbti.id : null,
discResultId: r.disc ? r.disc.id : null,
pdpResultId: r.pdp ? r.pdp.id : null,
aiResultId: r.ai ? r.ai.id : null,
@@ -270,6 +284,7 @@ Page({
/** 降级:从 localStorage 读最近记录(兼容离线或 API 失败) */
_loadRecentFromStorage() {
const mbtiResult = wx.getStorageSync('mbtiResult')
const sbtiResult = wx.getStorageSync('sbtiResult')
const discResult = wx.getStorageSync('discResult')
const pdpResult = wx.getStorageSync('pdpResult')
const aiResult = wx.getStorageSync('aiResult')
@@ -281,6 +296,7 @@ Page({
let testCount = 0
if (mbtiResult) testCount++
if (sbtiResult) testCount++
if (discResult) testCount++
if (pdpResult) testCount++
if (aiResult) testCount++
@@ -296,15 +312,18 @@ Page({
testCount,
hasResults: testCount > 0,
mbtiType: mbtiResult ? getTypeOnly(mbtiResult, 'mbti') : '',
sbtiType: sbtiResult ? getTypeOnly(sbtiResult, 'sbti') : '',
discType: discResult ? getTypeOnly(discResult, 'disc') : '',
pdpType: pdpResult ? getTypeOnly(pdpResult, 'pdp') : '',
aiType: aiResult ? (aiResult.mbti || aiResult.mbtiType || aiResult.type || '') : '',
gallupPreview,
mbtiTime: _fmt(mbtiResult && (mbtiResult.createdAt || mbtiResult.timestamp || mbtiResult.testTime)),
sbtiTime: _fmt(sbtiResult && (sbtiResult.createdAt || sbtiResult.timestamp || sbtiResult.completedAt || sbtiResult.testTime)),
discTime: _fmt(discResult && (discResult.createdAt || discResult.timestamp || discResult.testTime)),
pdpTime: _fmt(pdpResult && (pdpResult.createdAt || pdpResult.timestamp || pdpResult.testTime)),
aiTime: _fmt(aiResult && (aiResult.createdAt || aiResult.timestamp || aiResult.testTime)),
mbtiResultId: null,
sbtiResultId: null,
discResultId: null,
pdpResultId: null,
aiResultId: null,
@@ -370,6 +389,11 @@ Page({
if (id) wx.navigateTo({ url: `/pages/result/mbti?id=${id}&type=mbti` })
else wx.navigateTo({ url: '/pages/test/mbti' })
},
viewSBTI() {
const id = this.data.sbtiResultId
if (id) wx.navigateTo({ url: `/pages/result/sbti?id=${id}&type=sbti` })
else wx.navigateTo({ url: '/pages/test/sbti' })
},
viewDISC() {
const id = this.data.discResultId
if (id) wx.navigateTo({ url: `/pages/result/disc?id=${id}&type=disc` })

View File

@@ -52,6 +52,9 @@
<view class="tag tag-purple" wx:if="{{mbtiType && permMbti}}">
<text class="tag-text">{{mbtiType}}</text>
</view>
<view class="tag tag-sbti" wx:if="{{sbtiType && permSbti}}">
<text class="tag-text">{{sbtiType}}</text>
</view>
<view class="tag tag-blue" wx:if="{{discType && permDisc}}">
<text class="tag-text">{{discType}}</text>
</view>
@@ -90,6 +93,15 @@
<text class="card-value">{{mbtiType || '未测评'}}</text>
<text class="card-time">{{mbtiTime || '—'}}</text>
</view>
<view class="result-card card-sbti {{sbtiType ? '' : 'result-card--placeholder'}}" bindtap="viewSBTI" wx:if="{{permSbti}}">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-sbti">
<text class="card-icon">🎭</text>
</view>
<text class="card-label">SBTI</text>
<text class="card-value">{{sbtiType || '未测评'}}</text>
<text class="card-time">{{sbtiTime || '—'}}</text>
</view>
<view class="result-card card-orange {{pdpType ? '' : 'result-card--placeholder'}}" bindtap="viewPDP" wx:if="{{permPdp}}">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-orange">
@@ -133,7 +145,7 @@
</view>
<view class="depth-inner-divider"></view>
<view class="menu-item menu-item--flat" bindtap="goToTestSelect">
<view class="menu-icon-wrap menu-icon-amber">
<view class="menu-icon-wrap menu-icon-test-select">
<text class="menu-icon">🧠</text>
</view>
<view class="menu-content">

View File

@@ -228,6 +228,15 @@ custom-tab-bar {
color: #D97706;
}
/* SBTI 主题 #f2f7f3 + 强调 #5a7268 */
.tag-sbti {
background: #f2f7f3;
border: 1rpx solid #dce8e0;
}
.tag-sbti .tag-text {
color: #5a7268;
}
.tag-gray {
background: #F3F4F6;
}
@@ -431,6 +440,7 @@ custom-tab-bar {
}
.card-purple .card-deco { background: #DDD6FE; }
.card-sbti .card-deco { background: #c5d4cc; }
.card-blue .card-deco { background: #BFDBFE; }
.card-orange .card-deco { background: #FDE68A; }
.card-rose .card-deco { background: #FECDD3; }
@@ -446,6 +456,7 @@ custom-tab-bar {
}
.card-icon-purple { background: #EDE9FE; }
.card-icon-sbti { background: #f2f7f3; border: 1rpx solid #dce8e0; }
.card-icon-blue { background: #DBEAFE; }
.card-icon-orange { background: #FEF3C7; }
.card-icon-rose { background: #FFE4E6; }
@@ -477,6 +488,7 @@ custom-tab-bar {
}
.card-purple .card-value { color: #7C3AED; }
.card-sbti .card-value { color: #5a7268; }
.card-blue .card-value { color: #2563EB; }
.card-orange .card-value { color: #D97706; }
.card-rose .card-value { color: #E11D48; }
@@ -612,7 +624,7 @@ custom-tab-bar {
.menu-icon-red { background: #FEF2F2; }
.menu-icon-indigo { background: #EEF2FF; }
.menu-icon-amber { background: #FFFBEB; }
.menu-icon-test-select { background: #eef2ff; }
.menu-icon-emerald { background: #ECFDF5; }
.menu-icon-purple { background: #EDE9FE; }

View File

@@ -0,0 +1,263 @@
// pages/result/sbti.js — SBTI 结果页
const app = getApp()
const payment = require('../../utils/payment')
const { hasPhone, bindPhoneByCode, isReportProfileComplete } = require('../../utils/phoneAuth.js')
const { TYPE_IMAGES } = require('../../utils/sbtiEngine.js')
/** 根据结果类型代码取展示图(与 sbtiData.TYPE_IMAGES 一致) */
/** 旧版结果仅有 badge / bestNormal补全 matchPercent、hitDimCount */
function normalizeSbtiResultForDisplay(result) {
if (!result || typeof result !== 'object') return result
const out = { ...result }
const bn = out.bestNormal
if (out.matchPercent == null && bn != null && typeof bn.similarity === 'number') {
out.matchPercent = bn.similarity
}
if (out.hitDimCount == null && bn != null && typeof bn.exact === 'number') {
out.hitDimCount = bn.exact
}
if (out.special && out.sbtiType === 'DRUNK') {
if (out.matchPercent == null) out.matchPercent = 100
if (out.hitDimCount == null) out.hitDimCount = 15
}
return out
}
function resolveSbtiTypeImageUrl(result) {
if (!result || typeof result !== 'object') return ''
const code = result.sbtiType || (result.finalType && result.finalType.code) || ''
if (!code || !TYPE_IMAGES) return ''
const url = TYPE_IMAGES[code]
return typeof url === 'string' ? url : ''
}
function toProfileLockedSbti(full) {
if (!full) return full
const code = full.sbtiType || full.finalType?.code || ''
return { sbtiType: code, sbtiCn: full.sbtiCn || full.finalType?.cn || '', locked: true }
}
Page({
data: {
result: null,
typeImageUrl: '',
/** 为 true 时展示顶部文字备用(无图地址或图片加载失败) */
typeImageLoadFailed: false,
/** 类型图是否已成功加载(有 URL 且 bindload 触发后为 true用于隐藏与图重复的顶部三行字 */
typeImageLoaded: false,
dimExplainList: [],
payInfo: {
requiresPayment: false,
isPaid: false,
amountYuan: 0
},
testResultId: null,
hasReloadedAfterPay: false,
hasPhone: false
},
onLoad(options) {
const id = options && options.id
const type = options && options.type
if (id && type === 'sbti') {
this.setData({ testResultId: id })
this.loadDetail(id)
return
}
const raw = wx.getStorageSync('sbtiResult')
if (raw) {
const result = isReportProfileComplete() ? raw : toProfileLockedSbti(raw)
this.applyResult(result)
this.initPayInfoFromRuntime('sbti')
} else {
wx.showToast({ title: '暂无测试结果', icon: 'none' })
setTimeout(() => wx.navigateBack(), 1500)
}
},
onShow() {
this.setData({ hasPhone: hasPhone() })
if (this.data.testResultId) return
const raw = wx.getStorageSync('sbtiResult')
if (raw) {
const result = isReportProfileComplete() ? raw : toProfileLockedSbti(raw)
this.applyResult(result)
}
},
goCompleteProfile() {
wx.navigateTo({ url: '/pages/user-profile/index' })
},
loadDetail(id) {
const apiBase = app.globalData?.apiBase || ''
const token = app.globalData?.token || wx.getStorageSync('token') || ''
if (!apiBase) {
wx.showToast({ title: '配置异常', icon: 'none' })
return
}
wx.showLoading({ title: '加载中...' })
wx.request({
url: `${apiBase}/api/test/detail`,
method: 'GET',
header: token ? { Authorization: `Bearer ${token}` } : {},
data: { id },
success: (res) => {
if (res.statusCode === 200 && res.data && res.data.code === 200) {
const payload = res.data.data || {}
const data = payload.data || payload
const isPaid = !!payload.isPaid
const paidAmount = payload.paidAmount != null ? Number(payload.paidAmount) : 0
const amountYuan = payload.amountYuan != null ? Number(payload.amountYuan) : (paidAmount > 0 ? paidAmount / 100 : 0)
const needPaymentToUnlock = payload.needPaymentToUnlock === true || (!!payload.requiresPayment && !isPaid && paidAmount > 0)
this.applyResult(data)
const payInfo = {
requiresPayment: needPaymentToUnlock,
isPaid,
amountYuan: needPaymentToUnlock ? amountYuan : 0
}
this.setData({ payInfo })
} else {
wx.showToast({ title: res.data?.message || '加载失败', icon: 'none' })
}
},
fail: () => wx.showToast({ title: '网络错误', icon: 'none' }),
complete: () => wx.hideLoading()
})
},
applyResult(result) {
if (!result) return
const normalized = normalizeSbtiResultForDisplay(result)
const dimExplainList = (!normalized.locked && normalized.dimExplainList) ? normalized.dimExplainList : []
const typeImageUrl = resolveSbtiTypeImageUrl(normalized)
this.setData({
result: normalized,
dimExplainList,
typeImageUrl,
typeImageLoadFailed: false,
typeImageLoaded: false
})
},
onTypeImageLoad() {
this.setData({ typeImageLoadFailed: false, typeImageLoaded: true })
},
onTypeImageError() {
this.setData({ typeImageLoadFailed: true, typeImageLoaded: false })
},
initPayInfoFromRuntime(testType) {
app.getRuntimeConfig()
.then((cfg) => {
const pricing = cfg.pricing || {}
const reportRequires = cfg.reportRequiresPayment || {}
const requiresPayment = !!(reportRequires && reportRequires[testType])
const amountYuan = Number(pricing[testType]) || (requiresPayment ? 1.98 : 0)
this.setData({
payInfo: {
requiresPayment,
isPaid: false,
amountYuan
}
})
})
.catch(() => {
this.setData({
payInfo: { requiresPayment: false, isPaid: false, amountYuan: 0 }
})
})
},
unlockFullReport() {
const { payInfo, testResultId, hasReloadedAfterPay } = this.data
if (!payInfo.requiresPayment || payInfo.isPaid) return
app.ensureLogin && app.ensureLogin().then((logged) => {
if (!logged) {
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
payment.purchaseSbtiTest({
testResultId: testResultId || undefined,
success: () => {
wx.showToast({ title: '已解锁完整报告', icon: 'success' })
this.setData({ 'payInfo.isPaid': true })
if (testResultId && !hasReloadedAfterPay) {
this.setData({ hasReloadedAfterPay: true })
setTimeout(() => this.loadDetail(testResultId), 500)
}
},
fail: () => {}
})
})
},
onGetPhoneNumberForSbtiPay(e) {
const { code, errMsg } = e.detail || {}
if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) {
if (!hasPhone()) {
wx.showToast({ title: '需要授权手机号才能继续', icon: 'none' })
return
}
this.unlockFullReport()
return
}
if (!code) {
if (hasPhone()) {
this.unlockFullReport()
} else {
wx.showToast({ title: '获取手机号失败', icon: 'none' })
}
return
}
bindPhoneByCode(code)
.then(() => {
this.setData({ hasPhone: true })
this.unlockFullReport()
})
.catch(() => {})
},
retakeTest() {
if (!this.data.testResultId) {
wx.removeStorageSync('sbtiResult')
}
wx.navigateTo({ url: '/pages/test/sbti' })
},
goHome() {
const scope = (getApp().globalData && getApp().globalData.appScope) || 'personal'
if (scope === 'enterprise') {
wx.navigateTo({ url: '/pages/enterprise/index' })
} else {
wx.switchTab({ url: '/pages/index/index' })
}
},
onShareAppMessage() {
const result = this.data.result
const { getSharePathByScope } = require('../../utils/share')
const label = result?.sbtiCn || result?.finalType?.cn || 'SBTI'
const code = result?.sbtiType || result?.finalType?.code || ''
const img = this.data.typeImageUrl || '/images/share-mbti.png'
return {
title: `我的 SBTI 类型是 ${code}${label}),来测测你的吧!`,
path: getSharePathByScope('/pages/index/index'),
imageUrl: img
}
},
onShareTimeline() {
const result = this.data.result
const { buildShareQuery } = require('../../utils/share')
const label = result?.sbtiCn || result?.finalType?.cn || 'SBTI'
const code = result?.sbtiType || result?.finalType?.code || ''
return {
title: `我的 SBTI 类型是 ${code}${label}),来测测你的吧!`,
query: buildShareQuery()
}
}
})

View File

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

View File

@@ -0,0 +1,110 @@
<!--pages/result/sbti.wxml-->
<view class="result-page">
<scroll-view class="content-scroll" scroll-y>
<view class="content-container">
<!-- 主类型卡片:有图且图加载成功时隐藏顶部三行(与图内文案重复);无图/失败/加载中仍显示 -->
<view class="card main-type-card">
<view
class="type-header-text-fallback"
wx:if="{{!typeImageUrl || typeImageLoadFailed || !typeImageLoaded}}"
>
<text class="section-label">你的人格类型是:</text>
<view class="type-title-area">
<text class="type-main-cn">{{result.sbtiCn}}</text>
<text class="type-main-code">{{result.sbtiType}}</text>
</view>
</view>
<image
class="type-avatar-image"
src="{{typeImageUrl}}"
mode="widthFix"
show-menu-by-longpress="{{true}}"
bindload="onTypeImageLoad"
binderror="onTypeImageError"
wx:if="{{typeImageUrl && !typeImageLoadFailed}}"
/>
<view class="type-avatar-fallback" wx:if="{{!typeImageUrl || typeImageLoadFailed}}">
<text>{{result.sbtiType}}</text>
</view>
<text class="type-intro">{{result.intro}}</text>
</view>
<!-- 你的主类型卡片 -->
<view class="card your-main-type-card">
<text class="card-title">你的主类型</text>
<view class="main-type-summary">
<text class="main-type-text">{{result.sbtiType}} ({{result.sbtiCn}})</text>
<text class="main-type-desc">维度命中度较高,当前结果可视为你的第一人格画像。</text>
</view>
<view class="match-info">
<text class="match-percent">匹配度 {{result.matchPercent}}%</text>
<text class="match-dim">精准命中 {{result.hitDimCount}}/15 维</text>
</view>
</view>
<!-- 付费墙(完整报告部分) -->
<view class="card paywall-card" wx:if="{{payInfo.requiresPayment && !payInfo.isPaid}}">
<view class="paywall-content">
<view class="paywall-blur">
<text class="paywall-fake-title">完整维度解读</text>
<text class="paywall-fake-line">• 15 维等级说明</text>
<text class="paywall-fake-line">• 匹配度与兜底说明</text>
</view>
<view class="paywall-mask"></view>
<button
class="paywall-btn"
wx:if="{{!hasPhone}}"
open-type="getPhoneNumber"
bindgetphonenumber="onGetPhoneNumberForSbtiPay"
>
<text class="paywall-btn-main">解锁完整报告</text>
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
</button>
<button
class="paywall-btn"
wx:elif="{{hasPhone}}"
bindtap="unlockFullReport"
>
<text class="paywall-btn-main">解锁完整报告</text>
<text class="paywall-btn-price">¥{{payInfo.amountYuan}} / 次</text>
</button>
</view>
</view>
<!-- 强制完善资料卡片 -->
<view class="card paywall-card" wx:elif="{{result && result.locked}}">
<view class="paywall-content">
<text class="paywall-fake-title">完整报告需完善资料</text>
<text class="paywall-fake-line">请补全头像、昵称并绑定手机号后查看完整解读。</text>
<button class="paywall-btn" bindtap="goCompleteProfile">
<text class="paywall-btn-main">去完善资料</text>
</button>
</view>
</view>
<view wx:if="{{!result.locked && (!payInfo.requiresPayment || payInfo.isPaid)}}">
<!-- 人格描述 -->
<view class="card description-card">
<text class="card-title">人格描述</text>
<text class="description-text">{{result.desc}}</text>
</view>
<!-- 十五维度详解 -->
<view class="card dimension-detail-card">
<text class="card-title">十五维度详解</text>
<view class="dim-list">
<view class="dim-item" wx:for="{{dimExplainList}}" wx:key="dim">
<view class="dim-header">
<text class="dim-name">{{item.name}}</text>
<text class="dim-score">{{item.level}} / 6分<text wx:if="{{item.raw != null && item.raw !== ''}}"> · {{item.raw}}分</text></text>
</view>
<text class="dim-text">{{item.text}}</text>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
</view>

View File

@@ -0,0 +1,270 @@
/* pages/result/sbti.wxss */
.result-page {
width: 100%;
min-height: 100vh;
background-color: #f2f7f3;
}
.content-scroll {
width: 100%;
height: 100vh;
}
.content-container {
padding: 32rpx;
padding-bottom: 64rpx;
}
.card {
background-color: #fff;
border-radius: 24rpx;
padding: 48rpx;
margin-bottom: 32rpx;
box-shadow: 0 4rpx 12rpx rgba(45, 61, 53, 0.06);
border: 1rpx solid #dce8e0;
}
.card-title {
display: block;
font-size: 36rpx;
font-weight: bold;
color: #333;
margin-bottom: 24rpx;
}
/* 主类型卡片 */
.main-type-card {
text-align: center;
}
.section-label {
display: block;
font-size: 28rpx;
color: #6b7f72;
margin-bottom: 16rpx;
}
.type-title-area {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 24rpx;
}
.type-main-cn {
font-size: 64rpx;
font-weight: bold;
color: #374151;
line-height: 1.2;
}
.type-main-code {
font-size: 36rpx;
font-weight: 500;
color: #5a7268;
margin-top: 8rpx;
}
.type-avatar-image {
display: block;
width: 100%;
height: auto;
border-radius: 20rpx;
margin: 0 auto;
background-color: #f7fcf9;
}
.type-avatar-fallback {
display: flex;
align-items: center;
justify-content: center;
width: 80%;
aspect-ratio: 1;
border-radius: 20rpx;
margin: 24rpx auto;
box-sizing: border-box;
background-color: #f7fcf9;
border: 1rpx solid #e3ebe6;
font-size: 48rpx;
font-weight: bold;
color: #5a7268;
}
.type-intro {
display: block;
font-size: 28rpx;
color: #666;
line-height: 1.6;
margin-top: 24rpx;
}
/* 你的主类型卡片 */
.your-main-type-card {
text-align: center;
}
.main-type-summary {
margin-bottom: 24rpx;
}
.main-type-text {
display: block;
font-size: 36rpx;
font-weight: bold;
color: #333;
margin-bottom: 8rpx;
}
.main-type-desc {
display: block;
font-size: 26rpx;
color: #666;
line-height: 1.5;
}
.match-info {
display: flex;
justify-content: center;
gap: 32rpx;
margin-bottom: 0;
}
.match-percent,
.match-dim {
font-size: 28rpx;
color: #5a7268;
font-weight: 500;
}
.btn {
display: flex;
align-items: center;
justify-content: center;
width: 100% !important;
padding: 28rpx;
border-radius: 16rpx;
margin-bottom: 20rpx;
border: none;
box-sizing: border-box;
overflow: hidden;
}
/* 消除微信 button 伪元素自带边框 */
.btn::after {
border: none;
}
.btn-primary {
background: linear-gradient(135deg, #5a7268 0%, #6f8a7e 100%);
box-shadow: 0 8rpx 24rpx rgba(90, 114, 104, 0.28);
}
.btn-text {
font-size: 32rpx;
font-weight: 600;
color: #fff;
}
/* 付费墙 */
.paywall-card {
margin-top: 24rpx;
border-radius: 24rpx;
overflow: hidden;
}
.paywall-content { position: relative; min-height: 360rpx; }
.paywall-blur {
padding: 32rpx 24rpx 200rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.97);
backdrop-filter: blur(6rpx);
}
.paywall-mask {
position: absolute;
left: 0; top: 0; right: 0; bottom: 0;
border-radius: 24rpx;
z-index: 1;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.08) 0%,
rgba(255, 255, 255, 0.28) 38%,
rgba(255, 255, 255, 0.58) 100%
);
pointer-events: none;
}
.paywall-fake-title { display: block; font-size: 30rpx; font-weight: 600; color: #444; margin-bottom: 16rpx; }
.paywall-fake-line { display: block; font-size: 26rpx; color: #888; line-height: 1.8; }
.paywall-btn {
position: absolute;
left: 5%;
right: 5%;
width: 90%;
bottom: 132rpx;
z-index: 3;
padding: 20rpx 0;
border-radius: 999rpx;
background: linear-gradient(135deg, #5a7268 0%, #6f8a7e 100%);
box-shadow: 0 8rpx 24rpx rgba(90, 114, 104, 0.28);
display: flex;
flex-direction: row;
align-items: baseline;
justify-content: center;
gap: 12rpx;
}
.paywall-btn-main { font-size: 30rpx; color: #fff; font-weight: 600; }
.paywall-btn-price { font-size: 24rpx; color: #dce8e0; }
/* 人格描述卡片 */
.description-card .description-text {
font-size: 28rpx;
color: #333;
line-height: 1.7;
white-space: pre-wrap; /* 保持后端返回的换行 */
}
/* 十五维度详解:白底卡片 + 左标题 / 右「H / 6分」+ 灰说明(与定稿稿式一致) */
.dimension-detail-card .dim-list {
margin-top: 8rpx;
}
.dim-item {
background-color: #fff;
border: 1rpx solid #e8e8e8;
border-radius: 16rpx;
padding: 28rpx 24rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
}
.dim-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16rpx;
margin-bottom: 12rpx;
}
.dim-name {
flex: 1;
min-width: 0;
font-size: 30rpx;
font-weight: 700;
color: #111827;
line-height: 1.35;
}
.dim-score {
flex-shrink: 0;
font-size: 28rpx;
color: #5a7268;
font-weight: 500;
line-height: 1.35;
white-space: nowrap;
}
.dim-text {
display: block;
font-size: 26rpx;
color: #6b7280;
font-weight: 400;
line-height: 1.65;
}

View File

@@ -5,6 +5,7 @@ Page({
data: {
permFace: true,
permMbti: true,
permSbti: true,
permPdp: true,
permDisc: true
},
@@ -25,6 +26,7 @@ Page({
this.setData({
permFace: !p || p.face !== false,
permMbti: !p || p.mbti !== false,
permSbti: !p || p.sbti !== false,
permPdp: !p || p.pdp !== false,
permDisc: !p || p.disc !== false
})
@@ -34,6 +36,10 @@ Page({
wx.navigateTo({ url: '/pages/test/mbti' })
},
goSBTI() {
wx.navigateTo({ url: '/pages/test/sbti' })
},
goPDP() {
wx.navigateTo({ url: '/pages/test/pdp' })
},

View File

@@ -1,4 +1,4 @@
<!--pages/test-select/index.wxml - 详细性格测试入口MBTI / PDP / DISC-->
<!--pages/test-select/index.wxml - 详细性格测试入口MBTI / SBTI / PDP / DISC-->
<view class="container">
<view class="card intro-card">
<text class="intro-title">选择一项详细性格测试</text>
@@ -16,6 +16,17 @@
<text class="entry-arrow">→</text>
</view>
<view class="card entry-card" bindtap="goSBTI" wx:if="{{permSbti}}">
<view class="entry-icon-wrap sbti">
<text class="entry-emoji">🎭</text>
</view>
<view class="entry-content">
<text class="entry-name">SBTI 性格测试</text>
<text class="entry-brief">15 维等级匹配 · 闸口题(与标准计分一致)</text>
</view>
<text class="entry-arrow">→</text>
</view>
<view class="card entry-card" bindtap="goPDP" wx:if="{{permPdp}}">
<view class="entry-icon-wrap pdp">
<text class="entry-emoji">🦁</text>

View File

@@ -66,6 +66,11 @@
background: linear-gradient(135deg, rgba(124, 58, 237, 0.15) 0%, rgba(139, 92, 246, 0.2) 100%);
}
.entry-icon-wrap.sbti {
background: linear-gradient(135deg, #f2f7f3 0%, #dce8e0 100%);
border: 1rpx solid #c5d4cc;
}
.entry-icon-wrap.pdp {
background: linear-gradient(135deg, rgba(230, 57, 70, 0.12) 0%, rgba(255, 107, 157, 0.15) 100%);
}

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

View File

@@ -466,6 +466,13 @@ function purchasePdpTest(arg1, arg2) {
purchaseByPricing('pdp', 'PDP动物性格测试付费版', { testResultId, success, fail })
}
// SBTI 测试付费版
function purchaseSbtiTest(arg1, arg2) {
const opts = typeof arg1 === 'function' || arg1 == null ? { success: arg1, fail: arg2 } : (arg1 || {})
const { testResultId, success, fail } = opts
purchaseByPricing('sbti', 'SBTI 性格测试付费版', { testResultId, success, fail })
}
// 简历综合分析付费版(支持 purchaseResumeAnalysis({ testResultId, success, fail })
function purchaseResumeAnalysis(arg1, arg2) {
const opts = typeof arg1 === 'function' || arg1 == null ? { success: arg1, fail: arg2 } : (arg1 || {})
@@ -672,6 +679,7 @@ module.exports = {
purchaseMbtiTest,
purchaseDiscTest,
purchasePdpTest,
purchaseSbtiTest,
purchaseResumeAnalysis,
purchaseFullReport,
purchaseTeamAnalysis,

View File

@@ -70,7 +70,7 @@ function applyDrawCountAfterShuffle(questions) {
}
/**
* @param {'mbti'|'disc'|'pdp'} type
* @param {'mbti'|'sbti'|'disc'|'pdp'} type
* @param {number|null|undefined} enterpriseId
* @returns {Promise<Array>}
*/
@@ -97,7 +97,7 @@ function fetchQuestionBank(type, enterpriseId) {
}
/**
* @param {'mbti'|'disc'|'pdp'} type
* @param {'mbti'|'sbti'|'disc'|'pdp'} type
* @param {{ enterpriseId?: number|null }} opts
* @returns {Promise<Array>} 乱序后的题目;接口无题或失败则 reject
*/

View File

@@ -227,6 +227,18 @@ function formatTestSummary(data, testType) {
return label || ''
}
if (t === 'sbti') {
const code = data.sbtiType || data.finalType?.code || ''
const cn = data.sbtiCn || data.finalType?.cn || ''
const sim = data.bestNormal != null ? data.bestNormal.similarity : null
if (code && cn) {
return sim != null && Number.isFinite(Number(sim))
? `${code}${cn} 匹配${toIntPercent(sim)}%`
: `${code}${cn}`
}
return String(code || cn || '')
}
return ''
}
@@ -256,6 +268,12 @@ function getTypeOnly(data, testType) {
}
return String(data.pdp ?? '')
}
if (t === 'sbti') {
const cn = data.sbtiCn || data.finalType?.cn || ''
const code = data.sbtiType || data.finalType?.code || ''
if (cn && code) return `${code}${cn}`
return String(cn || code || '')
}
return ''
}

View File

@@ -0,0 +1,393 @@
// 从 aisbti.com 测试页提取,算法与官方一致;勿手改数据结构
module.exports = {
TYPE_LIBRARY: {
"CTRL": {
"code": "CTRL",
"cn": "拿捏者",
"intro": "怎么样,被我拿捏了吧?",
"desc": "恭喜您您测出了全中国最为罕见的人格您是宇宙熵增定律的天然反抗者全世界所谓成功人士里99.99%都是您的拙劣模仿者。CTRL人格是行走的人形自走任务管理器普通人眼中的“规则”在您这里只是出厂的基础参数设置凡人所谓的“计划”对您而言不过是心血来潮的随手涂鸦。拥有一个CTRL朋友意味着什么意味着你的人生导航系统会变得更加精准、高效。因为CTRL最会拿捏了。CTRL会在你人生列车即将脱轨的前一秒用一个“Ctrl+S”帮你硬核存档再用一套无法拒绝的逻辑把你强行拽回正轨。他们是你混乱生活最后的备份盘是宇宙崩塌前唯一还亮着的那个重启键。"
},
"ATM-er": {
"code": "ATM-er",
"cn": "送钱者",
"intro": "你以为我很有钱吗?",
"desc": "恭喜您您竟然测出了这个世界上最稀有的人格。您或将成为金融界的未解之谜——是的ATM-er不一定真的“送钱”但可能永远在“支付”。支付时间、支付精力、支付耐心、支付一个本该安宁的夜晚。因此像一部老旧但坚固的ATM机插进去的是别人的焦虑和麻烦吐出来的是“没事有我”的安心保证。您的人生就是一场盛大的、无人喝彩的单人付账秀。您竟用磐石般的可靠承受了瀑布般的索取偶尔夜深人静才会对着账单——可能是精神上的——发出一声叹息我这该死的、无处安放的责任心啊。"
},
"Dior-s": {
"code": "Dior-s",
"cn": "屌丝",
"intro": "等着我屌丝逆袭。",
"desc": "恭喜!您并非屌丝,您是犬儒主义先贤第欧根尼失散多年的精神传人,因为屌丝的全称是 Diogenes' Original Realist - sage。Dior-s人格是对当代消费主义陷阱和成功学PUA最彻底的蔑视。他们不是“不求上进”而是早已看穿一切“上进”的尽头不过是更高级的牢房。屌丝有着大智慧。当别人在追逐风口被时代的巨浪拍得七荤八素时Dior-s早已在自己的精神木桶里晒着太阳达到了“人桶合一”的至高境界。他们信奉的不是空话是经过亿万次实践检验的物理法则与生物本能一、躺着比站着舒服二、饭点到了就得干饭。"
},
"BOSS": {
"code": "BOSS",
"cn": "领导者",
"intro": "方向盘给我,我来开。",
"desc": "BOSS是一个手里永远拿着方向盘的人。哪怕油箱已经亮了红灯哪怕导航在胡说八道你都会面无表情地说一句我来开。然后真的把车开到了目的地。该人格拥有独立的物理法则——永恒向上定律。BOSS人格看世界就像玩通关了的玩家在看新手教程。效率是他们的信仰秩序是他们的呼吸。他们不是“自带领袖气场”他们本身就是人形的气场发生器方圆五米内空气都会自动变得严肃而高效。他们眼中的“自我突破”约等于普通人眼中的“自虐”。今天掌握一门新语言明天考下一个专业证书后天就计划殖民火星。你说这太卷了他会用一种看弱鸡的眼神看着你不是我太狠是你太松。"
},
"THAN-K": {
"code": "THAN-K",
"cn": "感恩者",
"intro": "我感谢苍天!我感谢大地!",
"desc": "恭喜您您测出了全中国最为罕见的人格。您应当感谢我感谢您在此刻拥有了生命的滋润倘若您上班路上堵车了您也应当说一句我感谢这次堵车它让我有更多时间聆听这首美妙的歌曲并欣赏窗外每一张因焦虑而扭曲的脸庞让我更珍惜内心的平静。是的THAN-K拥有温润如玉的性格和海纳百川的胸怀。他们眼中的世界没有完全的坏人只有“尚未被感恩光芒照耀到的朋友”。拥有一个THAN-K朋友就像身边多了一个永不枯竭的正能量发射塔。TA甚至能帮你从墙角的霉斑里发现一幅梵高风格的星空图。"
},
"OH-NO": {
"code": "OH-NO",
"cn": "哦不人",
"intro": "哦不!我怎么会是这个人格?!",
"desc": "“哦不!”并非恐惧的尖叫,而是一种顶级的智慧。当普通人看到一个杯子放在桌沿,哦不人看到的是一场由“水渍-短路-火灾-全楼疏散-经济损失-蝴蝶效应-世界末日”构成的灾难史诗。于是,伴随着一声发自灵魂深处的 Oh, no!他们会以迅雷不及掩耳之势把杯子挪到桌子正中央然后再垫上一张吸水杯垫。哦不人对“边界”有一种近乎偏执的尊重你的就是你的我的就是我的。所有意外和风险都已经在他的“Oh, no!”声中,被扼杀在了萌芽状态。他们是秩序的守护神,是混乱世界里最后那批神经绷得很直的体面人。"
},
"GOGO": {
"code": "GOGO",
"cn": "行者",
"intro": "gogogo~出发咯",
"desc": "经研究发现GOGO人格的大脑构造与常人有根本性不同。GOGO活在一个极致的“所见即所得”世界里人生信条简单粗暴到令人发指只要我闭上眼睛天就是黑的只要我把钱都花了我就没有钱了只要我站在斑马线上我现在就是行人了。逻辑完美闭环根本无法反驳。别人还在为“先有鸡还是先有蛋”而辩论GOGO行者已经把鸡和蛋一起做成了一盘“鸡生蛋蛋生鸡之终极奥义盖浇饭”。他们不是在“解决问题”他们是在“清除待办事项”。对他们来说世界上只有两种状态已完成和即将被我完成。"
},
"SEXY": {
"code": "SEXY",
"cn": "尤物",
"intro": "您就是天生的尤物!",
"desc": "当您走进一个房间照明系统会自动将您识别为天生的尤物并自觉调暗亮度以避免能源浪费。当您微笑时您就变成了微笑着的尤物周围的空气湿度也会显著下降因为水蒸气都凝结成了人眼中的爱心。无论是谁都容易对您的存在产生一种超标的注意力。传说如果有足够多的SEXY人格聚集在一起开派对其释放出的综合魅力能量足以暂时扭曲时空结构让参加者产生“时间变慢了”的幸福错觉。他们不需要卖力表达很多时候单是存在本身就已经很像一篇华丽到过分的赋。"
},
"LOVE-R": {
"code": "LOVE-R",
"cn": "多情者",
"intro": "爱意太满,现实显得有点贫瘠。",
"desc": "LOVE-R人格像远古神话时代幸存至今的珍稀物种其存在概率比你在马桶里钓到作者胳膊的概率还低。您简直是这个钢铁森林时代最后的、也是最不合时宜的吟游诗人。因为您的情感处理器不是二进制的而是彩虹制的。一片落叶在常人眼里只是“秋天来了”在LOVE-R眼中则是一场关于轮回、牺牲与无言之爱的十三幕悲喜剧。您内心世界像一座永不关门的主题公园一生都在寻找那个能看懂园区地图、并愿意陪你坐旋转木马直到宇宙尽头的灵魂伴侣。"
},
"MUM": {
"code": "MUM",
"cn": "妈妈",
"intro": "或许...我可以叫你妈妈吗....?",
"desc": "恭喜您您测出了全中国最稀有的妈妈人格。是的在混沌未开、时间尚无姓名之前在第一颗恒星打出第一个嗝之前就已经有了妈妈。妈妈人格的底色是温柔擅长感知情绪具有超强共情力知道什么时候该停下来什么时候该对自己说一句“算了”。妈妈像一个医生治愈了别人的不开心。只可惜当妈妈落泪时TA给自己的药剂量总是比给别人小一号。MUM对自己的温柔常常打了折。"
},
"FAKE": {
"code": "FAKE",
"cn": "伪人",
"intro": "已经,没有人类了。",
"desc": "SCP基金会紧急报告项目编号 SCP-CN-████ “伪人”。在社交场合,伪人是八面玲珑的存在,因为他们切换人格面具比切换手机输入法还快。上一秒还是推心置腹的铁哥们模式,下一秒领导来了,瞬间切换成沉稳可靠好员工模式,连脸上的光泽度和卷曲度都会发生微调。你以为你交到了一个真心懂你的朋友?醒醒。你只是幸运地遇到了一个善于伪装、高性能的仿生人罢了。夜深人静时,伪人把面具一层层摘下来,最后才发现,面具下空得很,正是这些面具构成了自己。"
},
"OJBK": {
"code": "OJBK",
"cn": "无所谓人",
"intro": "我说随便,是真的随便。",
"desc": "让我们直面这个词的粗犷本质OJBK。这已经不是一种人格而是一种统治哲学。当凡人面临“中午吃米饭还是面条”的世纪抉择时大脑在激烈燃烧卡路里而OJBK人格会用一种批阅奏章般的淡然轻飘飘地吐出两个字都行。这不是没主见这是在告诉你尔等凡俗的选择于朕而言皆为蝼蚁。为什么不争执因为跟草履虫辩论宇宙的未来毫无意义。为什么不较真因为帝王不会在意脚下的尘埃是往左飘还是往右飘。"
},
"MALO": {
"code": "MALO",
"cn": "吗喽",
"intro": "人生是个副本,而我只是一只吗喽。",
"desc": "朋友你不是“童心未泯”你压根就没进化。你的灵魂还停留在那个挂在树上荡秋千、看见香蕉就两眼放光的快乐时代。当人类祖先决定从树上下来、学会直立行走、穿上西装打领带时吗喽人格的祖先在旁边的大树上看着他们挠了挠屁股嘴里发出一声不屑的“吱”。他们看透了一切所谓的“文明”不过是一场最无聊、最不好玩的付费游戏。规则偶尔是可以打破的天花板是用来倒挂的会议室是用来表演后空翻的。MALO本身就是一个从巨大脑洞里掉出来、忘了关门的奇思妙想。"
},
"JOKE-R": {
"code": "JOKE-R",
"cn": "小丑",
"intro": "原来我们都是小丑。",
"desc": "请注意JOKE-R人格不是一个“人”更像一个把笑话穿在身上的小丑。你打开一层是个笑话再打开一层是个段子你一层层打开直到最后你发现最里面……是空的只剩下一点微弱的回声在说没想到吧。JOKE-R是社交场合的气氛组组长兼唯一指定火力输出。有他们在场子就不会冷。所有人前仰后合地笑着而笑得最开心的往往也是他们自己——用最大的笑声盖住心碎的声音。"
},
"WOC!": {
"code": "WOC!",
"cn": "握草人",
"intro": "卧槽,我怎么是这个人格?",
"desc": "我们发现了一种神奇的生物——WOC!人。他们拥有两种完全独立的操作系统一个叫“表面系统”负责发出“我操”“牛逼”“啊”等一系列大惊小怪的拟声词另一个叫“后台系统”负责冷静分析果然不出我所料。WOC!人只会卧槽不会多管闲事因为他们深知给傻逼讲道理就像扶着烂泥上墙不仅浪费体力还弄自己一手屎。所以他们选择握着一根智慧的大草用一声饱含深情的“WOC”来为这个疯狂的世界献上最高敬意。"
},
"THIN-K": {
"code": "THIN-K",
"cn": "思考者",
"intro": "已深度思考100s。",
"desc": "经研究发现THIN-K人格的大脑构造与常人有根本性不同。正如名称所示您的大脑长时间处于思考状态。您十分会审判信息注重论点、论据、逻辑推理、潜在偏见乃至“作者本人三代以内思想背景调查报告”的全套材料。在这个信息爆炸的时代您绝不会轻易盲从会在关系中衡量利弊也十分捍卫自己的自我空间。当别人看到您独处时在发呆愚蠢那不是发呆那是您的大脑正在对今天接收到的所有信息进行分类、归档和销毁。"
},
"SHIT": {
"code": "SHIT",
"cn": "愤世者",
"intro": "这个世界,构石一坨。",
"desc": "恭喜您SHIT人格是宇宙中已知的唯一一种稀有人格。所谓狗屎并不是在抱怨而是在进行一种神秘仪式。SHIT的行为模式是一场惊天动地的悖论戏剧。嘴上这个项目简直是屎。手上默默打开 Excel开始建构函数模型和甘特图。嘴上这帮同事都是 shit。手上在同事搞砸之后一边烦着一边熬夜把烂摊子收拾得明明白白。嘴上这个世界就是一坨 shit赶紧毁灭吧。手上第二天早上七点准时起床挤上 shit 一样的地铁,去干那份 shit 一样的工作。别怕,那不是世界末日的警报,那是他马上要开始拯救世界的冲锋号。"
},
"ZZZZ": {
"code": "ZZZZ",
"cn": "装死者",
"intro": "我没死,我只是在睡觉。",
"desc": "恭喜您您测出了全中国最稀有的装死人格。群里99+条消息您可以视而不见,但当有人发出“@全体成员 还有半小时就截止了”的最后通牒时您也许会像刚从千年古墓里苏醒一样缓缓地敲出一个“收到”然后在29分钟内交出一份虽然及格的答卷。是的直到“死线”这个唯一的、最高权限的指令出现您就真正爆发了不鸣则已一鸣惊人。您向宇宙证明了一个真理有时什么都不做就不会做错。"
},
"POOR": {
"code": "POOR",
"cn": "贫困者",
"intro": "我穷,但我很专。",
"desc": "恭喜您您测出了【POOR - 贫困者】。这个“贫困”不是钱包余额的判决书更像一种欲望断舍离后的资源再分配。别人把精力撒成漫天二维码你把精力压成一束激光照哪儿哪儿就开始冒烟。POOR的世界很简单不重要的东西一律降噪重要的东西狠狠干到底。热闹、社交、虚荣、到处刷存在感抱歉没空。你不是资源少你是把资源全部灌进了一个坑里所以看起来像贫困实际上像矿井。一旦某件事被你认定值得钻外界再吵也只是背景杂音。"
},
"MONK": {
"code": "MONK",
"cn": "僧人",
"intro": "没有那种世俗的欲望。",
"desc": "当别人在KTV里参悟爱与恨的纠缠MONK人格选择在家中参悟一份大道。MONK已然看破红尘不希望闲人来扰其清修、破其道行。MONK的个人空间是他们的结界是他们的须弥山是他们的绝对领域神圣不可侵犯。踏入者会感受到一种来自灵魂深处的窒息感。MONK们不黏不缠因为在他们的世界观里万物皆有其独立轨道。行星与行星之间保持着亿万公里的距离才构成和谐宇宙人与人之间为什么不行"
},
"IMSB": {
"code": "IMSB",
"cn": "傻者",
"intro": "认真的么?我真的是傻逼么?",
"desc": "恭喜您您根本不在人类范畴内您测出了百万年一遇的【IMSB】人格。IMSB人格的大脑里住着两个不死不休的究极战士一个叫“我他妈冲了另一个叫“我是个傻逼”。当IMSB面对一个有好感的人时前者会说冲啊去要微信去约饭爱要大声说出来后者接着说人家凭什么看得上你你去了就是自取其辱最终结果盯着对方背影直到消失然后掏出手机搜索“如何克服社交恐惧症”。IMSB不是真的傻只是您的内心戏可能比漫威宇宙所有电影加起来都长。"
},
"SOLO": {
"code": "SOLO",
"cn": "孤儿",
"intro": "我哭了,我怎么会是孤儿?",
"desc": "恭喜您您测出了全中国最稀有的【SOLO - 孤儿】人格。别急着哭,国王的加冕仪式,通常都是一个人。孤儿的自我价值感偏低,因此有时主动疏远他人,孤儿们在自己的灵魂外围建起了一座名为“莫挨老子”的万里长城。每一块砖,都是过去的一道伤口。孤儿就像一只把所有软肋都藏起来,然后用最硬的刺对着世界的刺猬。那满身的尖刺不是攻击,那是一句句说不出口的“别过来,我怕你也受伤”和“求求你,别离开”。"
},
"FUCK": {
"code": "FUCK",
"cn": "草者",
"intro": "操!这是什么人格?",
"desc": "恭喜您您根本不在人类范畴内您测出了百万年一遇的【FUCK】人格。人类文明城市里出现了一株无法被任何除草剂杀死的、具有超级生命力的人形野草——那就是草者人格。它的学名就叫 FUCK。在FUCK的世界观里世俗规则简直毫无意义并且FUCK的情绪开关是物理拨片式的FUCK YEAH 和 FUCK OFF。FUCK追求的不只是当下快感也在追求一种在体内横冲直撞的生命力。当所有人都被驯化成了温顺家禽FUCK则是荒野上最后那一声狼嚎。"
},
"DEAD": {
"code": "DEAD",
"cn": "死者",
"intro": "我,还活着吗?",
"desc": "恭喜您您测出了全中国最为罕见的人格只是“死者”这个名字实在有点晦气所以也可以叫Don't Expect Any Drives。死者已经看透了那些无意义的哲学思考因此显得对一切“失去”了兴趣。死者们看世界的眼神就像一位顶级玩家通关了所有主线、支线、隐藏任务删档重开了999次之后终于发现这游戏压根就没意思。死者是超越了欲望和目标的终极贤者。他们的存在就是对这个喧嚣世界最沉默也最彻底的抗议。"
},
"IMFW": {
"code": "IMFW",
"cn": "废物",
"intro": "我真的...是废物吗?",
"desc": "恭喜您您测出的不是一个普通人格您是一种极其珍稀的、仅占世界人口0.0001%的——【废物】。废物们的自尊通常有些脆弱,缺乏安全感,偶尔也会缺乏主见,因此这种人格能精确地感知到周围最强的那个 WiFi 信号——也就是他们心里最可靠的人。走进【废物】人格的生活,就像走进了一个顶级兰花温室:需要精确控制温度、湿度,以及每天定时进行“我爱你”的言语光合作用。给废物一颗糖,他们会还你一个完全信任你、亮晶晶的眼神。你未必是废物,你只是太没防备,太容易认真。"
},
"HHHH": {
"code": "HHHH",
"cn": "傻乐者",
"intro": "哈哈哈哈哈哈。",
"desc": "恭喜您由于您的思维回路过于清奇标准人格库已全面崩溃。第一人格匹配率只有60以下时系统才会为您强制匹配这个人格——【HHHH - 傻乐者】。这个人格有什么特质?哈哈哈哈哈哈哈哈哈哈哈哈!对不起,这就是全部的特质了。您可以查看十五维度进行不专业的评估,实在是抱歉!作者设置人格时没有考虑全面,因此才会出现这样的状况。哈哈哈哈哈哈……笑着笑着,我便哭了出来。怎么会有人的脑回路这么新奇。"
},
"DRUNK": {
"code": "DRUNK",
"cn": "酒鬼",
"intro": "烈酒烧喉,不得不醉。",
"desc": "您为什么走路摇摇晃晃您为什么总是情绪高涨您为什么看东西是重影的因为您体内流淌的不是血液是美味的五粮液是国窖1573是江小白是陕西五粮液美味的白酒每一滴都在燃烧都在沸腾。您是否已经习惯了将白酒灌入保温杯当作白开水一饮而下多么伟大的白酒它让您在饭桌上谈笑风生在厕所里抱着马桶忏悔人生它让您觉得自己是夜场诗人是宇宙中心那团不灭的火直到第二天上午十点您的头像裂开的核桃嘴角挂着食物残渣灵魂缩在角落里。您终于明白昨晚那个高谈阔论、拍桌怒吼的人已经成为了一个酒鬼。"
}
},
TYPE_IMAGES: {
"IMSB": "https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/IMSB.png",
"BOSS": "https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/BOSS.png",
"MUM": "https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/MUM.png",
"FAKE": "https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/FAKE.png",
"Dior-s": "https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/Dior-s.jpg",
"DEAD": "https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/DEAD.png",
"ZZZZ": "https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/ZZZZ.png",
"GOGO": "https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/GOGO.png",
"FUCK": "https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/FUCK.png",
"CTRL": "https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/CTRL.png",
"HHHH": "https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/HHHH.png",
"SEXY": "https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/SEXY.png",
"OJBK": "https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/OJBK.png",
"JOKE-R": "https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/JOKE-R.jpg",
"POOR": "https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/POOR.png",
"OH-NO": "https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/OH-NO.png",
"MONK": "https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/MONK.png",
"SHIT": "https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/SHIT.png",
"THAN-K": "https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/THAN-K.png",
"MALO": "https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/MALO.png",
"ATM-er": "https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/ATM-er.png",
"THIN-K": "https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/THIN-K.png",
"SOLO": "https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/SOLO.png",
"LOVE-R": "https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/LOVE-R.png",
"WOC!": "https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/WOC.png",
"DRUNK": "https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/DRUNK.png",
"IMFW": "https://karuosiyujzk.oss-cn-shenzhen.aliyuncs.com/mbti/SBTI/SBTI/IMFW.png"
},
NORMAL_TYPES: [
{
"code": "CTRL",
"pattern": "HHH-HMH-MHH-HHH-MHM"
},
{
"code": "ATM-er",
"pattern": "HHH-HHM-HHH-HMH-MHL"
},
{
"code": "Dior-s",
"pattern": "MHM-MMH-MHM-HMH-LHL"
},
{
"code": "BOSS",
"pattern": "HHH-HMH-MMH-HHH-LHL"
},
{
"code": "THAN-K",
"pattern": "MHM-HMM-HHM-MMH-MHL"
},
{
"code": "OH-NO",
"pattern": "HHL-LMH-LHH-HHM-LHL"
},
{
"code": "GOGO",
"pattern": "HHM-HMH-MMH-HHH-MHM"
},
{
"code": "SEXY",
"pattern": "HMH-HHL-HMM-HMM-HLH"
},
{
"code": "LOVE-R",
"pattern": "MLH-LHL-HLH-MLM-MLH"
},
{
"code": "MUM",
"pattern": "MMH-MHL-HMM-LMM-HLL"
},
{
"code": "FAKE",
"pattern": "HLM-MML-MLM-MLM-HLH"
},
{
"code": "OJBK",
"pattern": "MMH-MMM-HML-LMM-MML"
},
{
"code": "MALO",
"pattern": "MLH-MHM-MLH-MLH-LMH"
},
{
"code": "JOKE-R",
"pattern": "LLH-LHL-LML-LLL-MLM"
},
{
"code": "WOC!",
"pattern": "HHL-HMH-MMH-HHM-LHH"
},
{
"code": "THIN-K",
"pattern": "HHL-HMH-MLH-MHM-LHH"
},
{
"code": "SHIT",
"pattern": "HHL-HLH-LMM-HHM-LHH"
},
{
"code": "ZZZZ",
"pattern": "MHL-MLH-LML-MML-LHM"
},
{
"code": "POOR",
"pattern": "HHL-MLH-LMH-HHH-LHL"
},
{
"code": "MONK",
"pattern": "HHL-LLH-LLM-MML-LHM"
},
{
"code": "IMSB",
"pattern": "LLM-LMM-LLL-LLL-MLM"
},
{
"code": "SOLO",
"pattern": "LML-LLH-LHL-LML-LHM"
},
{
"code": "FUCK",
"pattern": "MLL-LHL-LLM-MLL-HLH"
},
{
"code": "DEAD",
"pattern": "LLL-LLM-LML-LLL-LHM"
},
{
"code": "IMFW",
"pattern": "LLH-LHL-LML-LLL-MLL"
}
],
DIM_EXPLANATIONS: {
"S1": {
"L": "对自己下手比别人还狠,夸你两句你都想先验明真伪。",
"M": "自信值随天气波动,顺风能飞,逆风先缩。",
"H": "心里对自己大致有数,不太会被路人一句话打散。"
},
"S2": {
"L": "内心频道雪花较多,常在“我是谁”里循环缓存。",
"M": "平时还能认出自己,偶尔也会被情绪临时换号。",
"H": "对自己的脾气、欲望和底线都算门儿清。"
},
"S3": {
"L": "更在意舒服和安全,没必要天天给人生开冲刺模式。",
"M": "想上进,也想躺会儿,价值排序经常内部开会。",
"H": "很容易被目标、成长或某种重要信念推着往前。"
},
"E1": {
"L": "感情里警报器灵敏,已读不回都能脑补到大结局。",
"M": "一半信任,一半试探,感情里常在心里拉锯。",
"H": "更愿意相信关系本身,不会被一点风吹草动吓散。"
},
"E2": {
"L": "感情投入偏克制,心门不是没开,是门禁太严。",
"M": "会投入,但会给自己留后手,不至于全盘梭哈。",
"H": "一旦认定就容易认真,情绪和精力都给得很足。"
},
"E3": {
"L": "容易黏人也容易被黏,关系里的温度感很重要。",
"M": "亲密和独立都要一点,属于可调节型依赖。",
"H": "空间感很重要,再爱也得留一块属于自己的地。"
},
"A1": {
"L": "看世界自带防御滤镜,先怀疑,再靠近。",
"M": "既不天真也不彻底阴谋论,观望是你的本能。",
"H": "更愿意相信人性和善意,遇事不急着把世界判死刑。"
},
"A2": {
"L": "规则能绕就绕,舒服和自由往往排在前面。",
"M": "该守的时候守,该变通的时候也不死磕。",
"H": "秩序感较强,能按流程来就不爱即兴炸场。"
},
"A3": {
"L": "意义感偏低,容易觉得很多事都像在走过场。",
"M": "偶尔有目标,偶尔也想摆烂,人生观处于半开机。",
"H": "做事更有方向,知道自己大概要往哪边走。"
},
"Ac1": {
"L": "做事先考虑别翻车,避险系统比野心更先启动。",
"M": "有时想赢,有时只想别麻烦,动机比较混合。",
"H": "更容易被成果、成长和推进感点燃。"
},
"Ac2": {
"L": "做决定前容易多转几圈,脑内会议常常超时。",
"M": "会想,但不至于想死机,属于正常犹豫。",
"H": "拍板速度快,决定一下就不爱回头磨叽。"
},
"Ac3": {
"L": "执行力和死线有深厚感情,越晚越像要觉醒。",
"M": "能做,但状态看时机,偶尔稳偶尔摆。",
"H": "推进欲比较强,事情不落地心里都像卡了根刺。"
},
"So1": {
"L": "社交启动慢热,主动出击这事通常得攒半天气。",
"M": "有人来就接,没人来也不硬凑,社交弹性一般。",
"H": "更愿意主动打开场子,在人群里不太怕露头。"
},
"So2": {
"L": "关系里更想亲近和融合,熟了就容易把人划进内圈。",
"M": "既想亲近又想留缝,边界感看对象调节。",
"H": "边界感偏强,靠太近会先本能性后退半步。"
},
"So3": {
"L": "表达更直接,心里有啥基本不爱绕。",
"M": "会看气氛说话,真实和体面通常各留一点。",
"H": "对不同场景的自我切换更熟练,真实感会分层发放。"
}
},
dimensionOrder: ['S1','S2','S3','E1','E2','E3','A1','A2','A3','Ac1','Ac2','Ac3','So1','So2','So3'],
dimensionMeta: {
S1: { name: 'S1 自尊自信', model: '自我模型' },
S2: { name: 'S2 自我清晰度', model: '自我模型' },
S3: { name: 'S3 核心价值', model: '自我模型' },
E1: { name: 'E1 依恋安全感', model: '情感模型' },
E2: { name: 'E2 情感投入度', model: '情感模型' },
E3: { name: 'E3 边界与依赖', model: '情感模型' },
A1: { name: 'A1 世界观倾向', model: '态度模型' },
A2: { name: 'A2 规则与灵活度', model: '态度模型' },
A3: { name: 'A3 人生意义感', model: '态度模型' },
Ac1: { name: 'Ac1 动机导向', model: '行动驱力模型' },
Ac2: { name: 'Ac2 决策风格', model: '行动驱力模型' },
Ac3: { name: 'Ac3 执行模式', model: '行动驱力模型' },
So1: { name: 'So1 社交主动性', model: '社交模型' },
So2: { name: 'So2 人际边界感', model: '社交模型' },
So3: { name: 'So3 表达与真实度', model: '社交模型' }
}
};

View 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 题、每题 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
}