// pages/result/sbti.js — SBTI 结果页 const app = getApp() const payment = require('../../utils/payment') const { hasPhone, bindPhoneByCode, needsResultProfileGate, navigateToCompleteProfileAfterPhoneIfNeeded } = require('../../utils/phoneAuth.js') const unlockGate = require('../../utils/unlockGate.js') const inviteCodeGate = require('../../utils/inviteCodeGate.js') const { shouldHideInviteCodeEntry } = require('../../utils/miniprogramAuditGate.js') const { slicePreviewText, openTimelineShareHint } = require('../../utils/resultProfileGate.js') const { decorateSbtiDims, groupSbtiDims, splitSbtiDesc } = require('../../utils/sbtiDisplay.js') const { TYPE_IMAGES } = require('../../utils/sbtiEngine.js') const { computeJourney, markShared, markCamera } = require('../../utils/resultJourneyState.js') const resultScrollSync = require('../../utils/resultSectionScrollSync.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 : '' } Page({ data: { result: null, typeImageUrl: '', /** 为 true 时展示顶部文字备用(无图地址或图片加载失败) */ typeImageLoadFailed: false, /** 类型图是否已成功加载(有 URL 且 bindload 触发后为 true,用于隐藏与图重复的顶部三行字) */ typeImageLoaded: false, dimExplainList: [], payInfo: { requiresPayment: false, isPaid: false, amountYuan: 0 }, testResultId: null, shareToken: '', hasReloadedAfterPay: false, hasPhone: false, /** 分享落地(path 带 fs=1 或旧版仅 id+st),用于隐藏「登录解锁」区、展示底部「我也要测试」 */ fromShare: false, profileGate: false, previewSbtiIntro: '', previewSbtiDesc: '', previewSbtiDescParts: [], descParts: [], dimGroups: [], openGroup: '', journey: { step1Unlocked: false, step2Unlocked: false, step3Unlocked: false, activeStep: 1 }, sectionNav: [ { id: 'sec-hero', label: 'SBTI 画像', emoji: '🎭' }, { id: 'sec-desc', label: '人格描述', emoji: '📝' }, { id: 'sec-dim', label: '十五维度', emoji: '📊' }, { id: 'sec-cta', label: '深度方案', emoji: '💎' } ], scrollTarget: '', activeSection: '', showInviteCodeDialog: false, hideInviteCodeEntry: false }, onTapSectionNav(e) { const id = e && e.detail && e.detail.id if (!id) return this.setData({ scrollTarget: '', activeSection: id }, () => { this.setData({ scrollTarget: id }) }) }, onSectionScroll(e) { resultScrollSync.onScroll(this, e) }, _syncJourney() { this.setData({ journey: computeJourney({ profileGate: !!this.data.profileGate, payRequired: !!(this.data.payInfo && this.data.payInfo.requiresPayment), isPaid: !!(this.data.payInfo && this.data.payInfo.isPaid) }) }) }, onLoad(options) { this._vscope = options && options.vscope ? String(options.vscope) : '' try { wx.showShareMenu({ withShareTicket: true, menus: ['shareAppMessage', 'shareTimeline'] }) } catch (e) {} const fromShareFs = options && (String(options.fs) === '1' || options.from === 'share') const id = options && options.id != null && options.id !== '' ? String(options.id) : '' const st = options && options.st ? String(options.st).trim() : '' const type = options && options.type ? String(options.type).toLowerCase() : '' // id + type:免登录拉取(/api/test/share-detail) if (id && type === 'sbti') { this.setData({ testResultId: id, fromShare: !!fromShareFs }) this.loadShareDetail(id, st, 'sbti') return } // 旧版分享仅 id + st if (id && st) { this.setData({ testResultId: id, fromShare: true }) this.loadShareDetail(id, st, '') return } const raw = wx.getStorageSync('sbtiResult') if (raw) { this.applyResult(raw) this.initPayInfoFromRuntime('sbti') } else { wx.showToast({ title: '暂无测试结果', icon: 'none' }) setTimeout(() => wx.navigateBack(), 1500) } }, onShow() { const gd = app.globalData || {} this.setData({ hasPhone: hasPhone(), hideInviteCodeEntry: shouldHideInviteCodeEntry(gd) }) if (this.data.testResultId && this.data.result) { this.applyResult(this.data.result) } else if (!this.data.testResultId) { const raw = wx.getStorageSync('sbtiResult') if (raw) this.applyResult(raw) } this._syncJourney() }, goCompleteProfile() { try { require('../../utils/analytics').track('tap_complete_profile', { from: 'sbti' }) } catch (e) {} wx.navigateTo({ url: '/pages/user-profile/index' }) }, applyDetailPayload(payload) { 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 } const patch = { payInfo, shareToken: payload.shareToken || '' } if (payload.id != null && payload.id !== '') { patch.testResultId = String(payload.id) } this.setData(patch) this._reportPaywallOnce('sbti', payInfo) }, 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, ...(this._vscope === 'gaokao' || app.globalData.appScope === 'gaokao' ? { scope: 'gaokao' } : {}) }, success: (res) => { if (res.statusCode === 200 && res.data && res.data.code === 200) { this.applyDetailPayload(res.data.data || {}) } else { wx.showToast({ title: res.data?.message || '加载失败', icon: 'none' }) } }, fail: () => wx.showToast({ title: '网络错误', icon: 'none' }), complete: () => wx.hideLoading() }) }, loadShareDetail(id, st, testType) { const apiBase = app.globalData?.apiBase || '' if (!apiBase) { wx.showToast({ title: '配置异常', icon: 'none' }) return } const data = { id: String(id), ...(this._vscope === 'gaokao' || app.globalData.appScope === 'gaokao' ? { scope: 'gaokao' } : {}) } if (st) data.st = st if (testType) data.type = testType wx.showLoading({ title: '加载中...' }) wx.request({ url: `${apiBase}/api/test/share-detail`, method: 'GET', data, success: (res) => { if (res.statusCode === 200 && res.data && res.data.code === 200) { this.applyDetailPayload(res.data.data || {}) } 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 fromShare = !!this.data.fromShare const profileGate = needsResultProfileGate(fromShare) const src = Array.isArray(normalized.dimExplainList) ? normalized.dimExplainList : [] const allowDims = profileGate || !normalized.locked let dimExplainList = [] if (src.length && allowDims) { dimExplainList = profileGate ? src.slice(0, Math.max(1, Math.ceil(src.length * 0.3))) : src } dimExplainList = decorateSbtiDims(dimExplainList) const dimGroups = groupSbtiDims(dimExplainList) const descParts = profileGate ? [] : splitSbtiDesc(normalized.desc || '', 12) const previewSbtiDescParts = profileGate ? splitSbtiDesc(slicePreviewText(normalized.desc || '', 0.3), 4) : [] const typeImageUrl = resolveSbtiTypeImageUrl(normalized) this.setData({ result: normalized, profileGate, previewSbtiIntro: slicePreviewText(normalized.intro || '', 0.3), previewSbtiDesc: slicePreviewText(normalized.desc || '', 0.3), previewSbtiDescParts, descParts, dimExplainList, dimGroups, openGroup: dimGroups[0] ? dimGroups[0].key : '', typeImageUrl, typeImageLoadFailed: false, typeImageLoaded: false }) this._syncJourney() }, toggleGroup(e) { const key = e.currentTarget.dataset.key if (!key) return this.setData({ openGroup: this.data.openGroup === key ? '' : key }) }, onTapDeepService() { try { require('../../utils/analytics').track('tap_deep_service_from_sbti', {}) } catch (e) {} wx.navigateTo({ url: '/pages/purchase/index' }) }, onTapPromoCenter() { try { require('../../utils/analytics').track('tap_promo_from_sbti', {}) } catch (e) {} wx.navigateTo({ url: '/pages/promo/index' }) }, onPhoneLoginForResultGate(e) { const { code, errMsg } = e.detail || {} if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) { wx.showToast({ title: '需要授权手机号才能查看完整报告', icon: 'none' }) return } if (!code) { wx.showToast({ title: '获取手机号失败', icon: 'none' }) return } bindPhoneByCode(code) .then(() => { this.setData({ hasPhone: hasPhone() }) const r = this.data.result || wx.getStorageSync('sbtiResult') if (r) this.applyResult(r) navigateToCompleteProfileAfterPhoneIfNeeded() }) .catch(() => {}) }, onTapReadFull() { try { require('../../utils/analytics').track('tap_read_full', { type: 'sbti' }) } catch (e) {} if (this.data.profileGate) { unlockGate.scrollToUnlockAnchor(this, { scrollIntoView: true }) wx.showToast({ title: this.data.hasPhone ? '请先完善头像与昵称' : '请在上滑区域内完成手机号授权', icon: 'none' }) return } if (this.data.payInfo.requiresPayment && !this.data.payInfo.isPaid) { this.unlockFullReport() return } wx.showToast({ title: '当前已是完整报告', icon: 'none' }) }, onTapShareMoment() { try { require('../../utils/analytics').track('tap_share_moment', { type: 'sbti' }) } catch (e) {} if (!this.data.journey.step1Unlocked) { wx.showToast({ title: '请先解锁全文', icon: 'none' }) this.onTapReadFull() return } markShared() this._syncJourney() openTimelineShareHint() }, onTapFaceCamera() { try { require('../../utils/analytics').track('tap_face_camera', { from: 'sbti' }) } catch (e) {} if (!this.data.journey.step2Unlocked) { wx.showToast({ title: '请先分享朋友圈', icon: 'none' }) return } markCamera() this._syncJourney() wx.switchTab({ url: '/pages/index/camera' }) }, goReadFullFromShare() { try { require('../../utils/analytics').track('tap_read_full', { type: 'sbti', from: 'share' }) } catch (e) {} wx.switchTab({ url: '/pages/profile/index' }) }, onTypeImageLoad() { this.setData({ typeImageLoadFailed: false, typeImageLoaded: true }) }, onTypeImageError() { this.setData({ typeImageLoadFailed: true, typeImageLoaded: false }) }, _reportPaywallOnce(testType, payInfo) { if (!payInfo || !payInfo.requiresPayment || payInfo.isPaid) return if (this._paywallReported) return this._paywallReported = true try { require('../../utils/analytics').track('paywall_view', { type: testType, amountYuan: payInfo.amountYuan }) } catch (e) {} }, 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) const payInfo = { requiresPayment, isPaid: false, amountYuan } this.setData({ payInfo }) this._reportPaywallOnce(testType, payInfo) this._syncJourney() }) .catch(() => { this.setData({ payInfo: { requiresPayment: false, isPaid: false, amountYuan: 0 } }) }) }, unlockFullReport() { const { payInfo, testResultId, hasReloadedAfterPay } = this.data if (!payInfo.requiresPayment || payInfo.isPaid) return try { require('../../utils/analytics').track('tap_unlock_full', { type: 'sbti', amountYuan: payInfo.amountYuan }) } catch (e) {} app.ensureLogin && app.ensureLogin().then((logged) => { if (!logged) { wx.showToast({ title: '请先登录', icon: 'none' }) return } unlockGate.ensureUnlockPrerequisitesBeforePay(this, { scrollIntoView: true }).then((ok) => { if (!ok) return inviteCodeGate.ensureInviteCodeGate(this).then((go) => { if (!go) return payment.purchaseSbtiTest({ testResultId: testResultId || undefined, success: () => { wx.showToast({ title: '已解锁完整报告', icon: 'success' }) this.setData({ 'payInfo.isPaid': true }) this._syncJourney() if (testResultId && !hasReloadedAfterPay) { this.setData({ hasReloadedAfterPay: true }) setTimeout(() => this.loadDetail(testResultId), 500) } }, fail: () => {} }) }) }) }) }, openInviteCodeFill() { app.ensureLogin().then((ok) => { if (!ok) { wx.showToast({ title: '请先登录', icon: 'none' }) return } inviteCodeGate.openInviteCodeDialog(this) }) }, onInviteCodeSkip() { inviteCodeGate.finishInviteCodeGate(this, true) }, onInviteCodeSuccess() { inviteCodeGate.finishInviteCodeGate(this, true) }, 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: hasPhone() }) this.unlockFullReport() }) .catch(() => {}) }, retakeTest() { if (!this.data.testResultId) { wx.removeStorageSync('sbtiResult') } wx.navigateTo({ url: '/pages/test/sbti' }) }, /** 分享落地页:引导好友自己做测试 */ goWantTest() { 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 { getResultSharePath } = 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: getResultSharePath('/pages/result/sbti', { id: this.data.testResultId, type: 'sbti' }), imageUrl: img } }, onShareTimeline() { const result = this.data.result const { getResultShareTimelineQuery } = require('../../utils/share') const label = result?.sbtiCn || result?.finalType?.cn || 'SBTI' const code = result?.sbtiType || result?.finalType?.code || '' return { title: `我的 SBTI 类型是 ${code}(${label}),来测测你的吧!`, query: getResultShareTimelineQuery({ id: this.data.testResultId, type: 'sbti' }) } } })