feat: SBTI 测评接入与管理端题目/财务/用户联动
1、修复了用户详情、定价与题目管理、财务与分销等管理端展示与接口问题。 2、新增 SBTI 小程序测评/结果页、sbtiData 引擎、题库 SQL 与脚本;admin sbtiDisplay。 3、优化 AppConfig/CrmReport/Test、历史与个人中心、test-select 与支付相关逻辑。 Made-with: Cursor
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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` })
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
263
miniprogram/pages/result/sbti.js
Normal file
263
miniprogram/pages/result/sbti.js
Normal 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()
|
||||
}
|
||||
}
|
||||
})
|
||||
7
miniprogram/pages/result/sbti.json
Normal file
7
miniprogram/pages/result/sbti.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"navigationBarTitleText": "SBTI结果",
|
||||
"navigationBarBackgroundColor": "#f2f7f3",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#f2f7f3",
|
||||
"usingComponents": {}
|
||||
}
|
||||
110
miniprogram/pages/result/sbti.wxml
Normal file
110
miniprogram/pages/result/sbti.wxml
Normal 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>
|
||||
270
miniprogram/pages/result/sbti.wxss
Normal file
270
miniprogram/pages/result/sbti.wxss
Normal 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;
|
||||
}
|
||||
@@ -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' })
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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 ''
|
||||
}
|
||||
|
||||
|
||||
393
miniprogram/utils/sbtiData.js
Normal file
393
miniprogram/utils/sbtiData.js
Normal 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: '社交模型' }
|
||||
}
|
||||
};
|
||||
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