feat: CRM 报告与双端结果页、管理端用户/设置与默认企业配置

1、修复了用户详情与人脸/DISC 展示、手机号与资料校验、相机页与测试选择等体验问题。

2、新增了 SystemDefaultEnterprise、discDisplay、管理端设置扩展接口;CrmReport/Test/Analyze 等 API 与路由能力补强。

3、优化了 resultFormat、descriptions、payment、企业上下文;海报与 PDP/DISC 文案;超管 Commerce/Users 与 admin Settings/Users。

Made-with: Cursor
This commit is contained in:
Ghost
2026-04-01 17:16:20 +08:00
parent 8abc5182af
commit ef19f6860e
63 changed files with 2718 additions and 1190 deletions

View File

@@ -1,6 +1,6 @@
// pages/index/camera.js - 拍照页,拍完后上传到服务器再跳转结果页
const app = getApp()
const { hasPhone, bindPhoneByCode, ensureProfileCompleteAndRedirect } = require('../../utils/phoneAuth.js')
const { ensureProfileCompleteAndRedirect } = require('../../utils/phoneAuth.js')
Page({
data: {
@@ -9,7 +9,6 @@ Page({
guideTexts: ['请正对镜头', '请向左转45°', '请向右转45°'],
guideText: '请正对镜头',
uploading: false,
needPhoneAuth: false,
aiAnalysisText: '分析',
reviewMode: true
},
@@ -104,30 +103,6 @@ Page({
}
},
// 本页就地授权手机号
onGetPhoneNumber(e) {
const { code, errMsg } = e.detail || {}
if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) {
wx.showToast({ title: '需要授权手机号才能继续', icon: 'none' })
return
}
if (!code && !hasPhone()) {
wx.showToast({ title: '获取手机号失败', icon: 'none' })
return
}
if (!code && hasPhone()) {
// 已有手机号,无需重复请求
this.setData({ needPhoneAuth: false })
return
}
bindPhoneByCode(code).then(() => {
this.setData({ needPhoneAuth: false })
}).catch(() => {
// 失败时保持 needPhoneAuth 为 true等待用户重新授权
this.setData({ needPhoneAuth: !hasPhone() })
})
},
// 拍照
takePhoto() {
if (this.data.photos.length >= 3) {
@@ -201,11 +176,6 @@ Page({
// 完成拍照:先上传 3 张图到服务器,拿到 URL 后再跳转结果页
completeCapture() {
if (!hasPhone()) {
wx.showToast({ title: '请先授权手机号', icon: 'none' })
this.setData({ needPhoneAuth: true })
return
}
const photos = this.data.photos
if (!photos || photos.length === 0) {
wx.showToast({ title: '请先拍摄照片', icon: 'none' })

View File

@@ -67,14 +67,6 @@
</view>
</view>
</view>
<!-- 手机号授权入口:仅在未授权时显示,就地弹出微信系统手机号授权 -->
<view class="phone-auth-section" wx:if="{{needPhoneAuth}}">
<text class="phone-auth-tip">为保障服务与联系,请先授权手机号。</text>
<button class="phone-auth-btn" open-type="getPhoneNumber" bindgetphonenumber="onGetPhoneNumber">
授权手机号后继续拍摄
</button>
</view>
</view>
<custom-tab-bar />
</view>

View File

@@ -225,31 +225,6 @@
letter-spacing: 1rpx;
}
.phone-auth-section {
padding: 24rpx 24rpx 0;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.phone-auth-tip {
font-size: 26rpx;
color: #4b5563;
}
.phone-auth-btn {
height: 80rpx;
line-height: 80rpx;
border-radius: 40rpx;
background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);
color: #ffffff;
font-size: 28rpx;
}
.phone-auth-btn::after {
border: none;
}
.capture-button {
width: 100%;
max-width: 100%;

View File

@@ -303,8 +303,8 @@
.report-gate-cta-wrap {
width: 100%;
box-sizing: border-box;
/* 占遮罩层约一半高度,保证内部 flex 垂直居中真正留白上下对称 */
height: 48%;
/* 遮挡/雾面展示区占 report-gate 整体的 55%,与渐变分层一致 */
height: 55%;
min-height: 400rpx;
max-height: 640rpx;
display: flex;
@@ -323,14 +323,14 @@
top: 0;
bottom: 0;
pointer-events: none;
/* 约前 50% 区域保持清晰预览以下渐强变白 */
/* 约前 45% 清晰预览以下 55% 为渐强遮罩展示区 */
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0) 50%,
rgba(255, 255, 255, 0.2) 52%,
rgba(255, 255, 255, 0.55) 68%,
rgba(255, 255, 255, 0.82) 88%,
rgba(255, 255, 255, 0) 45%,
rgba(255, 255, 255, 0.2) 48%,
rgba(255, 255, 255, 0.55) 64%,
rgba(255, 255, 255, 0.82) 85%,
rgba(255, 255, 255, 0.92) 100%
);
}

View File

@@ -2,6 +2,12 @@
const app = getApp()
const payment = require('../../utils/payment')
const { getTypeOnly } = require('../../utils/resultFormat')
const { isReportProfileComplete } = require('../../utils/phoneAuth.js')
function toProfileLockedDisc(full) {
if (!full) return full
return { dominantType: full.dominantType || full.disc || '', locked: true }
}
function toIntPercent(v) {
if (v == null) return 0
@@ -28,10 +34,10 @@ Page({
/** 主+次高权重展示(如 S+I型与接口摘要一致 */
typeSummaryLine: '',
typeList: [
{ type: 'D', label: 'D型 - 支配型', colorClass: 'fill-d' },
{ type: 'I', label: 'I型 - 影响型', colorClass: 'fill-i' },
{ type: 'S', label: 'S型 - 稳健型', colorClass: 'fill-s' },
{ type: 'C', label: 'C型 - 谨慎型', colorClass: 'fill-c' }
{ type: 'D', label: 'D型 - 力量', colorClass: 'fill-d' },
{ type: 'I', label: 'I型 - 活跃', colorClass: 'fill-i' },
{ type: 'S', label: 'S型 - 和平', colorClass: 'fill-s' },
{ type: 'C', label: 'C型 - 完美', colorClass: 'fill-c' }
],
payInfo: { requiresPayment: false, isPaid: false, amountYuan: 0 },
testResultId: null,
@@ -46,10 +52,11 @@ Page({
this.loadDetail(id)
return
}
const result = wx.getStorageSync('discResult')
if (result) {
const r = withPercentagesInt(result)
this.setData({ result: r, typeSummaryLine: getTypeOnly(result, 'disc') })
const raw = wx.getStorageSync('discResult')
if (raw) {
const gated = isReportProfileComplete() ? raw : toProfileLockedDisc(raw)
const r = withPercentagesInt(gated)
this.setData({ result: r, typeSummaryLine: getTypeOnly(gated, 'disc') })
this.initPayInfoFromRuntime('disc')
} else {
wx.showToast({ title: '暂无测试结果', icon: 'none' })
@@ -95,6 +102,19 @@ Page({
})
},
onShow() {
if (this.data.testResultId) return
const raw = wx.getStorageSync('discResult')
if (!raw) return
const gated = isReportProfileComplete() ? raw : toProfileLockedDisc(raw)
const r = withPercentagesInt(gated)
this.setData({ result: r, typeSummaryLine: getTypeOnly(gated, 'disc') })
},
goCompleteProfile() {
wx.navigateTo({ url: '/pages/user-profile/index' })
},
initPayInfoFromRuntime(testType) {
app.getRuntimeConfig()
.then((cfg) => {

View File

@@ -25,7 +25,17 @@
</view>
</view>
<view class="scores-section" wx:if="{{!payInfo.requiresPayment || payInfo.isPaid}}">
<view class="paywall-card" wx:elif="{{result && result.locked}}">
<view class="paywall-content">
<text class="paywall-fake-title">完整报告需完善资料</text>
<text class="paywall-fake-line">请补全头像、昵称并绑定手机号后查看完整 DISC 报告。</text>
<view class="paywall-btn" bindtap="goCompleteProfile">
<text class="paywall-btn-main">去完善资料</text>
</view>
</view>
</view>
<view class="scores-section" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid)}}">
<text class="section-title">DISC得分详情</text>
<view class="score-item" wx:for="{{typeList}}" wx:key="type">
<view class="score-header">
@@ -39,7 +49,7 @@
</view>
</view>
<view class="analysis-card" wx:if="{{!payInfo.requiresPayment || payInfo.isPaid}}">
<view class="analysis-card" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid)}}">
<text class="card-title">主要性格特征分析</text>
<view class="trait-section">
<text class="trait-title">优势</text>
@@ -57,7 +67,7 @@
</view>
</view>
<view class="analysis-card" wx:if="{{!payInfo.requiresPayment || payInfo.isPaid}}">
<view class="analysis-card" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid)}}">
<text class="card-title">职业匹配度分析</text>
<view class="career-item" wx:for="{{result.description.careers}}" wx:key="*this">
<view class="career-dot"></view>

View File

@@ -1,7 +1,12 @@
// pages/result/mbti.js - MBTI结果页支持付费墙 + 历史详情拉取)
const app = getApp()
const payment = require('../../utils/payment')
const { hasPhone, bindPhoneByCode } = require('../../utils/phoneAuth.js')
const { hasPhone, bindPhoneByCode, isReportProfileComplete } = require('../../utils/phoneAuth.js')
function toProfileLockedMbti(full) {
if (!full) return full
return { mbtiType: full.mbtiType || full.mbti || '', locked: true }
}
Page({
data: {
@@ -35,8 +40,9 @@ Page({
return
}
const result = wx.getStorageSync('mbtiResult')
if (result) {
const raw = wx.getStorageSync('mbtiResult')
if (raw) {
const result = isReportProfileComplete() ? raw : toProfileLockedMbti(raw)
this.applyResult(result)
this.initPayInfoFromRuntime('mbti')
} else {
@@ -47,6 +53,16 @@ Page({
onShow() {
this.setData({ hasPhone: hasPhone() })
if (this.data.testResultId) return
const raw = wx.getStorageSync('mbtiResult')
if (raw) {
const result = isReportProfileComplete() ? raw : toProfileLockedMbti(raw)
this.applyResult(result)
}
},
goCompleteProfile() {
wx.navigateTo({ url: '/pages/user-profile/index' })
},
loadDetail(id) {

View File

@@ -42,7 +42,17 @@
</view>
</view>
<view class="dimensions-section" wx:if="{{!payInfo.requiresPayment || payInfo.isPaid}}">
<view class="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 class="dimensions-section" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid)}}">
<view class="dimension-item" wx:for="{{dimensions}}" wx:key="key">
<view class="dimension-labels">
<text class="label-left">{{item.left}}</text>
@@ -59,7 +69,7 @@
</view>
</view>
<view class="analysis-card" wx:if="{{!payInfo.requiresPayment || payInfo.isPaid}}">
<view class="analysis-card" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid)}}">
<text class="card-title">性格特征分析</text>
<view class="trait-section">
<text class="trait-title">优势</text>
@@ -86,7 +96,7 @@
</view>
</view>
<view class="analysis-card" wx:if="{{(!payInfo.requiresPayment || payInfo.isPaid) && mbtiDesc.relationships}}">
<view class="analysis-card" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid) && mbtiDesc.relationships}}">
<text class="card-title">人际关系分析</text>
<text class="relationship-text">{{mbtiDesc.relationships}}</text>
</view>

View File

@@ -2,6 +2,16 @@
const app = getApp()
const payment = require('../../utils/payment')
const { getTypeOnly } = require('../../utils/resultFormat')
const { isReportProfileComplete } = require('../../utils/phoneAuth.js')
function toProfileLockedPdp(full) {
if (!full) return full
const desc = full.description || {}
return {
description: { type: desc.type || '', emoji: desc.emoji || '' },
locked: true
}
}
const PDP_KEYS = ['Tiger', 'Peacock', 'Koala', 'Owl', 'Chameleon']
@@ -28,7 +38,7 @@ Page({
typeList: [
{ type: 'Tiger', emoji: '🐅', label: '老虎型', colorClass: 'fill-tiger' },
{ type: 'Peacock', emoji: '🦚', label: '孔雀型', colorClass: 'fill-peacock' },
{ type: 'Koala', emoji: '🐨', label: '考拉型', colorClass: 'fill-koala' },
{ type: 'Koala', emoji: '🐨', label: '无尾熊型', colorClass: 'fill-koala' },
{ type: 'Owl', emoji: '🦉', label: '猫头鹰型', colorClass: 'fill-owl' },
{ type: 'Chameleon', emoji: '🦎', label: '变色龙型', colorClass: 'fill-chameleon' }
],
@@ -95,6 +105,21 @@ Page({
})
},
onShow() {
if (this.data.testResultId) return
const raw = wx.getStorageSync('pdpResult')
if (!raw) return
const gated = isReportProfileComplete() ? raw : toProfileLockedPdp(raw)
this.setData({
result: withPercentagesInt(gated),
typeSummaryLine: getTypeOnly(gated, 'pdp')
})
},
goCompleteProfile() {
wx.navigateTo({ url: '/pages/user-profile/index' })
},
initPayInfoFromRuntime(testType) {
app.getRuntimeConfig()
.then((cfg) => {

View File

@@ -26,7 +26,17 @@
</view>
</view>
<view class="scores-section" wx:if="{{!payInfo.requiresPayment || payInfo.isPaid}}">
<view class="paywall-card" wx:elif="{{result && result.locked}}">
<view class="paywall-content">
<text class="paywall-fake-title">完整报告需完善资料</text>
<text class="paywall-fake-line">请补全头像、昵称并绑定手机号后查看完整 PDP 报告。</text>
<view class="paywall-btn" bindtap="goCompleteProfile">
<text class="paywall-btn-main">去完善资料</text>
</view>
</view>
</view>
<view class="scores-section" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid)}}">
<text class="section-title">PDP得分详情</text>
<view class="score-item" wx:for="{{typeList}}" wx:key="type">
<view class="score-header">
@@ -40,7 +50,7 @@
</view>
</view>
<view class="analysis-card" wx:if="{{!payInfo.requiresPayment || payInfo.isPaid}}">
<view class="analysis-card" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid)}}">
<text class="card-title">性格特征</text>
<view class="trait-section">
<text class="trait-title">优势</text>
@@ -58,12 +68,12 @@
</view>
</view>
<view class="analysis-card" wx:if="{{!payInfo.requiresPayment || payInfo.isPaid}}">
<view class="analysis-card" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid)}}">
<text class="card-title">团队角色</text>
<text class="relationship-text">{{result.description.teamRole}}</text>
</view>
<view class="analysis-card" wx:if="{{!payInfo.requiresPayment || payInfo.isPaid}}">
<view class="analysis-card" wx:if="{{result && !result.locked && (!payInfo.requiresPayment || payInfo.isPaid)}}">
<text class="card-title">推荐职业</text>
<view class="career-item" wx:for="{{result.description.careers}}" wx:key="*this">
<view class="career-dot"></view>

View File

@@ -21,7 +21,8 @@ Page({
amountYuan: 0
},
testResultId: 0,
paying: false
paying: false,
profileGate: false
},
onLoad(options) {
@@ -36,8 +37,19 @@ Page({
}
},
onShow() {
const id = this.data.testResultId
if (id && this.data.profileGate) {
this.loadFromHistory(String(id))
}
},
goCompleteProfile() {
wx.navigateTo({ url: '/pages/user-profile/index' })
},
loadFromHistory(id) {
this.setData({ loading: true, error: '', content: '', sections: [], resumeData: null })
this.setData({ loading: true, error: '', content: '', sections: [], resumeData: null, profileGate: false })
const apiBase = (app.globalData && app.globalData.apiBase) || wx.getStorageSync('apiBase') || ''
const token = (app.globalData && app.globalData.token) || wx.getStorageSync('token') || ''
wx.request({
@@ -53,30 +65,43 @@ Page({
// 兼容:历史数据可能是字符串,或包在 content 里的 JSON 字符串
data = this.normalizeStructuredData(data)
const profileIncomplete = payload.profileIncomplete === true
const locked = !!(data && data.locked)
const structured = !!data && data._structured === true
const requiresPayment = !!payload.requiresPayment
const isPaid = !!payload.isPaid
const amountYuan = payload.paidAmount ? payload.paidAmount / 100 : 0
const payWall = requiresPayment && !isPaid
const profileGate = !payWall && (profileIncomplete || locked)
let content = ''
let sections = []
let resumeData = null
if (structured) {
if (payWall || profileGate) {
content = ''
sections = []
resumeData = null
} else if (structured) {
const built = this.buildSectionsFromStructured(data)
content = built.content
sections = built.sections
resumeData = data
} else {
const raw = data.content || ''
content = raw
sections = this.parseContent(raw)
}
const requiresPayment = !!payload.requiresPayment
const isPaid = !!payload.isPaid
const amountYuan = payload.paidAmount ? payload.paidAmount / 100 : 0
this.setData({
loading: false,
testResultId: payload.id || parseInt(id) || 0,
content: (!requiresPayment || isPaid) ? content : '',
sections: (!requiresPayment || isPaid) ? sections : [],
resumeData: structured ? data : null,
payInfo: { requiresPayment: requiresPayment && !isPaid, isPaid, amountYuan }
content,
sections,
resumeData,
profileGate,
payInfo: { requiresPayment: payWall, isPaid, amountYuan }
})
} else {
this.setData({ loading: false, error: '加载历史记录失败,请返回重试。' })
@@ -140,15 +165,23 @@ Page({
// 兼容API 直接返回结构化对象,或把 JSON 放在 content 里
d = this.normalizeStructuredData(d)
const locked = !!(d && d.locked)
const structured = !!d && d._structured === true
let content = ''
let sections = []
let resumeData = null
let profileGate = false
if (structured) {
if (requiresPayment) {
profileGate = false
} else if (locked) {
profileGate = true
} else if (structured) {
const built = this.buildSectionsFromStructured(d)
content = built.content
sections = built.sections
resumeData = d
} else {
const raw = d.content || ''
content = raw || '分析已完成,但未返回可展示的内容。'
@@ -161,7 +194,8 @@ Page({
testResultId: resultId,
content: requiresPayment ? '' : content,
sections: requiresPayment ? [] : sections,
resumeData: structured ? d : null,
resumeData,
profileGate,
payInfo: { requiresPayment, isPaid: !requiresPayment, amountYuan }
})
} else {

View File

@@ -16,7 +16,7 @@
</view>
<!-- 结果显示 -->
<block wx:if="{{!loading || error || content}}">
<block wx:if="{{!loading || error || content || profileGate}}">
<!-- 页面顶部 Hero -->
<view class="hero-banner {{error ? 'hero-error' : 'hero-success'}}">
@@ -50,8 +50,18 @@
</view>
</view>
<!-- 资料未完善:与问卷/面相报告一致 -->
<view class="card pay-gate-card" wx:if="{{profileGate && (!payInfo.requiresPayment || payInfo.isPaid) && !error}}">
<text class="pay-gate-icon">📝</text>
<text class="pay-gate-title">完善资料后查看完整报告</text>
<text class="pay-gate-desc">请补全头像、昵称并绑定手机号\n即可查看简历综合分析全文</text>
<view class="pay-gate-btn" bindtap="goCompleteProfile">
<text class="pay-gate-btn-text">去完善资料</text>
</view>
</view>
<!-- 成功内容:分块卡片展示 -->
<block wx:if="{{content && sections.length > 0 && !payInfo.requiresPayment}}">
<block wx:if="{{content && sections.length > 0 && !payInfo.requiresPayment && !profileGate}}">
<view class="card section-card" wx:for="{{sections}}" wx:key="index">
<view class="section-header">
<text class="section-icon">{{item.icon}}</text>
@@ -71,10 +81,10 @@
<!-- 操作按钮 -->
<view class="action-section" wx:if="{{!loading || error}}">
<button class="btn btn-primary" open-type="share" wx:if="{{!payInfo.requiresPayment && content}}">
<button class="btn btn-primary" open-type="share" wx:if="{{!payInfo.requiresPayment && !profileGate && content}}">
<text class="btn-text">分享分析报告</text>
</button>
<view class="btn btn-outline" bindtap="fetchResumeAnalysis" wx:if="{{!loading && content && !payInfo.requiresPayment}}">
<view class="btn btn-outline" bindtap="fetchResumeAnalysis" wx:if="{{!loading && content && !payInfo.requiresPayment && !profileGate}}">
<text class="btn-text-outline">重新分析生成</text>
</view>
<view class="btn btn-outline" bindtap="goHome">

View File

@@ -22,7 +22,7 @@
</view>
<view class="entry-content">
<text class="entry-name">PDP 行为偏好测试</text>
<text class="entry-brief">老虎 / 孔雀 / 考拉 / 猫头鹰 · 行为风格</text>
<text class="entry-brief">老虎 / 孔雀 / 无尾熊 / 猫头鹰 / 变色龙 · 行为风格</text>
</view>
<text class="entry-arrow">→</text>
</view>

View File

@@ -19,15 +19,16 @@
<text class="question-text">{{currentQuestion.question}}</text>
<view class="options-container">
<view
class="option-item {{selectedAnswer === option.value ? 'selected' : ''}}"
class="option-item {{answers[currentQuestion.id] === option.value ? 'selected' : ''}}"
wx:for="{{currentQuestion.options}}"
wx:for-item="option"
wx:key="value"
wx:for-index="optIdx"
wx:key="optIdx"
bindtap="selectAnswer"
data-value="{{option.value}}"
>
<view class="radio-button {{selectedAnswer === option.value ? 'checked' : ''}}">
<view wx:if="{{selectedAnswer === option.value}}" class="radio-inner"></view>
<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 File

@@ -171,7 +171,7 @@ Page({
finishTest() {
const q = this.data.currentQuestion
if (!q) return
if (this.data.answers[q.id] == null && this.data.selectedAnswer == null) {
if (this.data.answers[q.id] == null) {
wx.showToast({ title: '请先选择一项', icon: 'none' })
return
}

View File

@@ -19,15 +19,16 @@
<text class="question-text">{{currentQuestion.question}}</text>
<view class="options-container">
<view
class="option-item {{selectedAnswer === option.value ? 'selected' : ''}}"
class="option-item {{answers[currentQuestion.id] === option.value ? 'selected' : ''}}"
wx:for="{{currentQuestion.options}}"
wx:for-item="option"
wx:key="value"
wx:for-index="optIdx"
wx:key="optIdx"
bindtap="selectAnswer"
data-value="{{option.value}}"
>
<view class="radio-button {{selectedAnswer === option.value ? 'checked' : ''}}">
<view wx:if="{{selectedAnswer === option.value}}" class="radio-inner"></view>
<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 File

@@ -19,15 +19,16 @@
<text class="question-text">{{currentQuestion.question}}</text>
<view class="options-container">
<view
class="option-item {{selectedAnswer === option.value ? 'selected' : ''}}"
class="option-item {{answers[currentQuestion.id] === option.value ? 'selected' : ''}}"
wx:for="{{currentQuestion.options}}"
wx:for-item="option"
wx:key="value"
wx:for-index="optIdx"
wx:key="optIdx"
bindtap="selectAnswer"
data-value="{{option.value}}"
>
<view class="radio-button {{selectedAnswer === option.value ? 'checked' : ''}}">
<view wx:if="{{selectedAnswer === option.value}}" class="radio-inner"></view>
<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 File

@@ -45,7 +45,7 @@ Page({
const gender = (userInfo.gender !== undefined && userInfo.gender !== null) ? Number(userInfo.gender) : 0
const genderText = this._genderText(gender)
const genderIndex = Math.min(Math.max(0, gender), 2)
const phone = userInfo.phone || ''
const phone = (userInfo.phone || userInfo.phoneNumber || '').trim()
const { avatarLetter, avatarBgColor } = this._avatarFromNickname(nickname || '我')
@@ -123,7 +123,8 @@ Page({
return
}
bindPhoneByCode(code).then((user) => {
this.setData({ phone: user.phone || '' })
const phone = ((user && (user.phone || user.phoneNumber)) || '').trim()
this.setData({ phone })
}).catch(() => {})
},
@@ -153,7 +154,28 @@ Page({
this.setData({ gender, genderIndex: idx, genderText })
},
/** 保存前校验:头像、昵称、手机号均必填(与页面提示及后端门禁一致) */
_validateRequiredForSave() {
const avatar = (this.data.avatar || '').trim()
const nickname = (this.data.nickname || '').trim()
const phone = (this.data.phone || this.data.userInfo?.phone || this.data.userInfo?.phoneNumber || '').trim()
const missing = []
if (!avatar) missing.push('头像')
if (!nickname) missing.push('昵称')
if (!phone) missing.push('手机号')
if (missing.length === 0) return ''
return missing.length === 1
? (missing[0] === '头像' ? '请先上传头像' : missing[0] === '昵称' ? '请填写昵称' : '请先授权绑定手机号')
: `请完善:${missing.join('、')}`
},
onSave() {
const tip = this._validateRequiredForSave()
if (tip) {
wx.showToast({ title: tip, icon: 'none' })
return
}
const { nickname, birthday, gender, userInfo } = this.data
const profile = {}
const origNickname = (userInfo?.nickname || userInfo?.nickName || '').trim()

View File

@@ -1,44 +1,44 @@
// utils/descriptions.js - 测试结果描述
// MBTI类型描述
const mbtiDescriptions = {
ISTJ: { type: "ISTJ", name: "检查员", category: "守护者", description: "务实、负责、可靠的传统主义者", strengths: ["可靠负责", "注重细节", "有条理"], weaknesses: ["可能过于固执", "不喜变化"], careers: ["会计师", "审计师", "项目经理"] },
ISFJ: { type: "ISFJ", name: "保护者", category: "守护者", description: "安静、友好、负责任的守护者", strengths: ["忠诚体贴", "观察力强", "耐心"], weaknesses: ["不善拒绝", "过于谦虚"], careers: ["护士", "教师", "行政"] },
INFJ: { type: "INFJ", name: "提倡者", category: "理想主义者", description: "寻求意义和联系的理想主义者", strengths: ["洞察力强", "有远见", "坚定"], weaknesses: ["过于理想化", "容易疲惫"], careers: ["心理咨询", "作家", "人力资源"] },
INTJ: { type: "INTJ", name: "建筑师", category: "理想主义者", description: "独立、有战略眼光的思考者", strengths: ["战略思维", "独立自信", "意志坚定"], weaknesses: ["可能傲慢", "过于苛刻"], careers: ["战略顾问", "科学家", "架构师"] },
ISTP: { type: "ISTP", name: "鉴赏家", category: "探险家", description: "灵活、务实的问题解决者", strengths: ["适应力强", "动手能力", "冷静"], weaknesses: ["可能冷漠", "不善表达"], careers: ["工程师", "技术员", "飞行员"] },
ISFP: { type: "ISFP", name: "探险家", category: "探险家", description: "温和、敏感的艺术家", strengths: ["创造力", "同理心", "灵活"], weaknesses: ["过于敏感", "避免冲突"], careers: ["设计师", "艺术家", "摄影师"] },
INFP: { type: "INFP", name: "调停者", category: "理想主义者", description: "理想主义、忠诚的调解者", strengths: ["创造力", "同理心", "真诚"], weaknesses: ["过于理想化", "情绪化"], careers: ["作家", "心理咨询", "社工"] },
INTP: { type: "INTP", name: "逻辑学家", category: "理想主义者", description: "创新、逻辑的思考者", strengths: ["逻辑思维", "创新能力", "客观"], weaknesses: ["可能孤僻", "忽视情感"], careers: ["程序员", "研究员", "分析师"] },
ESTP: { type: "ESTP", name: "企业家", category: "探险家", description: "精力充沛、务实的行动者", strengths: ["果断", "务实", "善于应变"], weaknesses: ["可能冲动", "缺乏耐心"], careers: ["销售", "企业家", "运动员"] },
ESFP: { type: "ESFP", name: "表演者", category: "探险家", description: "热情、友好的社交达人", strengths: ["热情友好", "乐观", "灵活"], weaknesses: ["可能肤浅", "容易分心"], careers: ["演员", "销售", "主持人"] },
ENFP: { type: "ENFP", name: "竞选者", category: "理想主义者", description: "热情、有创造力的社交者", strengths: ["创造力", "热情", "善于沟通"], weaknesses: ["可能不切实际", "缺乏专注"], careers: ["市场营销", "记者", "顾问"] },
ENTP: { type: "ENTP", name: "辩论家", category: "理想主义者", description: "聪明、好奇的思想家", strengths: ["创新能力", "辩论能力", "适应力"], weaknesses: ["可能争辩", "不善执行"], careers: ["律师", "企业家", "咨询"] },
ESTJ: { type: "ESTJ", name: "总经理", category: "守护者", description: "务实、果断的组织者", strengths: ["领导力", "组织能力", "务实"], weaknesses: ["可能专制", "不够灵活"], careers: ["管理者", "项目经理", "律师"] },
ESFJ: { type: "ESFJ", name: "执政官", category: "守护者", description: "热心、合作的支持者", strengths: ["关心他人", "负责任", "善于合作"], weaknesses: ["过于在意评价", "不善拒绝"], careers: ["教师", "护士", "人力资源"] },
ENFJ: { type: "ENFJ", name: "主人公", category: "理想主义者", description: "有魅力、鼓舞人心的领导者", strengths: ["领导力", "同理心", "说服力"], weaknesses: ["过于理想化", "过度付出"], careers: ["培训师", "顾问", "教师"] },
ENTJ: { type: "ENTJ", name: "指挥官", category: "理想主义者", description: "大胆、有远见的领导者", strengths: ["领导力", "战略思维", "果断"], weaknesses: ["可能专制", "不耐烦"], careers: ["CEO", "企业家", "律师"] }
}
// DISC类型描述
const discDescriptions = {
D: { type: "D型", title: "支配型", color: "#EF4444", description: "天生的领导者,注重结果和效率", strengths: ["决断力强", "目标导向", "行动迅速"], weaknesses: ["可能过于强势", "缺乏耐心"], careers: ["企业高管", "创业者", "项目经理"] },
I: { type: "I型", title: "影响型", color: "#F59E0B", description: "热情友好,善于社交和表达", strengths: ["善于沟通", "乐观积极", "创意丰富"], weaknesses: ["可能过于乐观", "注意力分散"], careers: ["市场营销", "公关", "培训师"] },
S: { type: "S型", title: "稳健型", color: "#10B981", description: "稳重可靠,注重团队和谐", strengths: ["可靠稳定", "团队协作", "耐心倾听"], weaknesses: ["可能抗拒变化", "决策较慢"], careers: ["人力资源", "客户服务", "行政"] },
C: { type: "C型", title: "谨慎型", color: "#3B82F6", description: "注重细节和准确性,追求完美", strengths: ["分析能力强", "注重细节", "准确严谨"], weaknesses: ["可能过于完美主义", "决策较慢"], careers: ["数据分析", "工程师", "会计师"] }
}
// PDP类型描述
const pdpDescriptions = {
Tiger: { type: "老虎型", emoji: "🐅", title: "支配者", color: "#F59E0B", description: "天生的领导者,具有强烈的目标导向和执行力", strengths: ["决断力强", "目标导向", "行动迅速"], weaknesses: ["可能过于强势", "缺乏耐心"], careers: ["企业高管", "创业者", "项目经理"], teamRole: "适合担任领导者角色" },
Peacock: { type: "孔雀型", emoji: "🦚", title: "表现者", color: "#8B5CF6", description: "热情友好,善于表达和社交", strengths: ["善于沟通", "乐观积极", "影响力强"], weaknesses: ["可能过于乐观", "注意力分散"], careers: ["市场营销", "公关", "培训师"], teamRole: "适合担任激励者和调解者角色" },
Koala: { type: "考拉型", emoji: "🐨", title: "支持者", color: "#10B981", description: "稳重可靠,注重团队和谐", strengths: ["可靠稳定", "团队协作", "善解人意"], weaknesses: ["可能抗拒变化", "决策较慢"], careers: ["人力资源", "客户服务", "教师"], teamRole: "适合担任协调者和支持者角色" },
Owl: { type: "猫头鹰型", emoji: "🦉", title: "分析者", color: "#3B82F6", description: "注重细节和准确性,追求完美", strengths: ["分析能力强", "注重细节", "系统思维"], weaknesses: ["可能过于完美主义", "不善社交"], careers: ["数据分析师", "工程师", "研究员"], teamRole: "适合担任专家和质量控制者角色" },
Chameleon: { type: "变色龙型", emoji: "🦎", title: "整合者", color: "#06B6D4", description: "适应能力强,能在不同情境中灵活调整", strengths: ["适应力强", "灵活多变", "平衡能力"], weaknesses: ["可能缺乏主见", "身份认同模糊"], careers: ["咨询顾问", "协调员", "自由职业者"], teamRole: "适合担任协调者和多面手角色" }
}
module.exports = {
mbtiDescriptions,
discDescriptions,
pdpDescriptions
}
// utils/descriptions.js - 测试结果描述
// MBTI类型描述
const mbtiDescriptions = {
ISTJ: { type: "ISTJ", name: "物流师", category: "守护者", description: "务实、负责、可靠的传统主义者", strengths: ["可靠负责", "注重细节", "有条理"], weaknesses: ["可能过于固执", "不喜变化"], careers: ["会计师", "审计师", "项目经理"] },
ISFJ: { type: "ISFJ", name: "守卫者", category: "守护者", description: "安静、友好、负责任的守护者", strengths: ["忠诚体贴", "观察力强", "耐心"], weaknesses: ["不善拒绝", "过于谦虚"], careers: ["护士", "教师", "行政"] },
INFJ: { type: "INFJ", name: "提倡者", category: "理想主义者", description: "寻求意义和联系的理想主义者", strengths: ["洞察力强", "有远见", "坚定"], weaknesses: ["过于理想化", "容易疲惫"], careers: ["心理咨询", "作家", "人力资源"] },
INTJ: { type: "INTJ", name: "建筑师", category: "理想主义者", description: "独立、有战略眼光的思考者", strengths: ["战略思维", "独立自信", "意志坚定"], weaknesses: ["可能傲慢", "过于苛刻"], careers: ["战略顾问", "科学家", "架构师"] },
ISTP: { type: "ISTP", name: "鉴赏家", category: "探险家", description: "灵活、务实的问题解决者", strengths: ["适应力强", "动手能力", "冷静"], weaknesses: ["可能冷漠", "不善表达"], careers: ["工程师", "技术员", "飞行员"] },
ISFP: { type: "ISFP", name: "艺术家", category: "探险家", description: "温和、敏感的艺术家", strengths: ["创造力", "同理心", "灵活"], weaknesses: ["过于敏感", "避免冲突"], careers: ["设计师", "艺术家", "摄影师"] },
INFP: { type: "INFP", name: "调停者", category: "理想主义者", description: "理想主义、忠诚的调解者", strengths: ["创造力", "同理心", "真诚"], weaknesses: ["过于理想化", "情绪化"], careers: ["作家", "心理咨询", "社工"] },
INTP: { type: "INTP", name: "逻辑学家", category: "理想主义者", description: "创新、逻辑的思考者", strengths: ["逻辑思维", "创新能力", "客观"], weaknesses: ["可能孤僻", "忽视情感"], careers: ["程序员", "研究员", "分析师"] },
ESTP: { type: "ESTP", name: "动力者", category: "探险家", description: "精力充沛、务实的行动者", strengths: ["果断", "务实", "善于应变"], weaknesses: ["可能冲动", "缺乏耐心"], careers: ["销售", "企业家", "运动员"] },
ESFP: { type: "ESFP", name: "表演者", category: "探险家", description: "热情、友好的社交达人", strengths: ["热情友好", "乐观", "灵活"], weaknesses: ["可能肤浅", "容易分心"], careers: ["演员", "销售", "主持人"] },
ENFP: { type: "ENFP", name: "竞选者", category: "理想主义者", description: "热情、有创造力的社交者", strengths: ["创造力", "热情", "善于沟通"], weaknesses: ["可能不切实际", "缺乏专注"], careers: ["市场营销", "记者", "顾问"] },
ENTP: { type: "ENTP", name: "辩论家", category: "理想主义者", description: "聪明、好奇的思想家", strengths: ["创新能力", "辩论能力", "适应力"], weaknesses: ["可能争辩", "不善执行"], careers: ["律师", "企业家", "咨询"] },
ESTJ: { type: "ESTJ", name: "管理者", category: "守护者", description: "务实、果断的组织者", strengths: ["领导力", "组织能力", "务实"], weaknesses: ["可能专制", "不够灵活"], careers: ["管理者", "项目经理", "律师"] },
ESFJ: { type: "ESFJ", name: "执政官", category: "守护者", description: "热心、合作的支持者", strengths: ["关心他人", "负责任", "善于合作"], weaknesses: ["过于在意评价", "不善拒绝"], careers: ["教师", "护士", "人力资源"] },
ENFJ: { type: "ENFJ", name: "主人公", category: "理想主义者", description: "有魅力、鼓舞人心的领导者", strengths: ["领导力", "同理心", "说服力"], weaknesses: ["过于理想化", "过度付出"], careers: ["培训师", "顾问", "教师"] },
ENTJ: { type: "ENTJ", name: "指挥官", category: "理想主义者", description: "大胆、有远见的领导者", strengths: ["领导力", "战略思维", "果断"], weaknesses: ["可能专制", "不耐烦"], careers: ["CEO", "企业家", "律师"] }
}
// DISC类型描述
const discDescriptions = {
D: { type: "D型", title: "力量", color: "#EF4444", description: "天生的领导者,注重结果和效率", strengths: ["决断力强", "目标导向", "行动迅速"], weaknesses: ["可能过于强势", "缺乏耐心"], careers: ["企业高管", "创业者", "项目经理"] },
I: { type: "I型", title: "活跃", color: "#F59E0B", description: "热情友好,善于社交和表达", strengths: ["善于沟通", "乐观积极", "创意丰富"], weaknesses: ["可能过于乐观", "注意力分散"], careers: ["市场营销", "公关", "培训师"] },
S: { type: "S型", title: "和平", color: "#10B981", description: "稳重可靠,注重团队和谐", strengths: ["可靠稳定", "团队协作", "耐心倾听"], weaknesses: ["可能抗拒变化", "决策较慢"], careers: ["人力资源", "客户服务", "行政"] },
C: { type: "C型", title: "完美", color: "#3B82F6", description: "注重细节和准确性,追求卓越", strengths: ["分析能力强", "注重细节", "准确严谨"], weaknesses: ["可能过于完美主义", "决策较慢"], careers: ["数据分析", "工程师", "会计师"] }
}
// PDP类型描述
const pdpDescriptions = {
Tiger: { type: "老虎型", emoji: "🐅", title: "支配者", color: "#F59E0B", description: "天生的领导者,具有强烈的目标导向和执行力", strengths: ["决断力强", "目标导向", "行动迅速"], weaknesses: ["可能过于强势", "缺乏耐心"], careers: ["企业高管", "创业者", "项目经理"], teamRole: "适合担任领导者角色" },
Peacock: { type: "孔雀型", emoji: "🦚", title: "表现者", color: "#8B5CF6", description: "热情友好,善于表达和社交", strengths: ["善于沟通", "乐观积极", "影响力强"], weaknesses: ["可能过于乐观", "注意力分散"], careers: ["市场营销", "公关", "培训师"], teamRole: "适合担任激励者和调解者角色" },
Koala: { type: "无尾熊型", emoji: "🐨", title: "支持者", color: "#10B981", description: "稳重可靠,注重团队和谐", strengths: ["可靠稳定", "团队协作", "善解人意"], weaknesses: ["可能抗拒变化", "决策较慢"], careers: ["人力资源", "客户服务", "教师"], teamRole: "适合担任协调者和支持者角色" },
Owl: { type: "猫头鹰型", emoji: "🦉", title: "分析者", color: "#3B82F6", description: "注重细节和准确性,追求完美", strengths: ["分析能力强", "注重细节", "系统思维"], weaknesses: ["可能过于完美主义", "不善社交"], careers: ["数据分析师", "工程师", "研究员"], teamRole: "适合担任专家和质量控制者角色" },
Chameleon: { type: "变色龙型", emoji: "🦎", title: "整合者", color: "#06B6D4", description: "适应能力强,能在不同情境中灵活调整", strengths: ["适应力强", "灵活多变", "平衡能力"], weaknesses: ["可能缺乏主见", "身份认同模糊"], careers: ["咨询顾问", "协调员", "自由职业者"], teamRole: "适合担任协调者和多面手角色" }
}
module.exports = {
mbtiDescriptions,
discDescriptions,
pdpDescriptions
}

View File

@@ -28,8 +28,9 @@ function getEffectiveEnterpriseId() {
/**
* 提交给后端的 enterpriseIdanalyze、test/submit、支付创建订单、简历分析等
* - 个人版 TabappScope=personal仅传递「本次入口携带的 eid」enterpriseIdFromScene
* 不因「已绑定企业」或「超管默认企业带参,避免个人入口却走企业定价/落库企业字段
* 不因「已绑定企业」或 runtime 默认企业带参,避免客户端误把个人页当成强制企业入口
* - 企业版appScope=enterprise与 getEffectiveEnterpriseId 一致。
* - 未传 enterpriseId 时,后端 Test::submit / Analyze 会按「绑定企业 → 系统默认企业」回落并写入 test_results.enterpriseId。
* @returns {number|null}
*/
function getEnterpriseIdForApiPayload() {

View File

@@ -130,6 +130,7 @@ function wxPay(options) {
// 支付成功后轮询后端订单状态 3~5 次,确保通过微信订单查询接口确认成功
pollOrderStatus(orderId, 5, 1000, (ok, order) => {
if (ok) {
try { reportCrmTestPaymentAfterSuccess(productType, testResultId, enterpriseId || 0) } catch (e) {}
try { require('./analytics').reportPayResult(true, { productType: productType || '', orderId, amount }) } catch (e) {}
wx.showToast({
title: '支付成功',
@@ -266,6 +267,35 @@ function pollOrderStatus(orderId, maxAttempts = 5, intervalMs = 1000, done) {
tick()
}
/**
* 测评类付费(人脸/MBTI/PDP/DISC支付确认成功后上报存客宝线索KEY 由后端按企业 cunkebao_keys 解析(对齐深度服务「完成付款」)
*/
function reportCrmTestPaymentAfterSuccess(productType, testResultId, contextEnterpriseId) {
const types = { face: true, mbti: true, disc: true, pdp: true }
if (!types[productType]) return
const token = wx.getStorageSync('token')
if (!token) return
wx.request({
url: `${paymentApiBase()}/api/crm/report`,
method: 'POST',
header: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
data: {
testType: productType,
testResultId: testResultId || 0,
contextEnterpriseId: contextEnterpriseId != null ? Number(contextEnterpriseId) : 0
},
success(res) {
console.log('[CRM] 测评付费线索', res.data)
},
fail(err) {
console.warn('[CRM] 测评付费线索请求失败', err)
}
})
}
/**
* 购买VIP会员
* @param {String} vipType - 'month' | 'quarter' | 'year' | 'lifetime'

View File

@@ -20,6 +20,19 @@ function isProfileComplete() {
return nickname.length > 0 && avatar.length > 0
}
/**
* 与后端 Test::isWechatProfileComplete 一致:昵称、头像、手机号均必填时才解锁完整报告
*/
function isReportProfileComplete() {
const app = getApp()
const user = app.globalData.userInfo || wx.getStorageSync('userInfo')
if (!user) return false
const phone = (user.phone || user.phoneNumber || '').trim()
const nickname = (user.nickname || user.nickName || user.username || '').trim()
const avatar = (user.avatar || user.avatarUrl || '').trim()
return nickname.length > 0 && avatar.length > 0 && phone.length > 0
}
/**
* 若资料未完善则跳转到个人资料页,需登录
* @returns {boolean} true=已完善可继续false=已跳转
@@ -112,5 +125,6 @@ module.exports = {
hasPhone,
bindPhoneByCode,
isProfileComplete,
isReportProfileComplete,
ensureProfileCompleteAndRedirect,
}

View File

@@ -1,268 +1,268 @@
/**
* 测试结果摘要格式化:类型 + 整数百分比(与管理后台一致)
*/
function toIntPercent(value) {
if (value == null) return 0
const n = typeof value === 'number' ? value : Number(value)
return Number.isFinite(n) ? Math.round(n) : 0
}
const PDP_EN_TO_CN = {
Tiger: '老虎型',
Peacock: '孔雀型',
Koala: '考拉型',
Owl: '猫头鹰型',
Chameleon: '变色龙型'
}
function pdpOrderedFromScores(scores) {
if (!scores || typeof scores !== 'object') return ['', '']
const pairs = []
for (const k of Object.keys(scores)) {
if (!PDP_EN_TO_CN[k]) continue
pairs.push([k, Number(scores[k]) || 0])
}
pairs.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
return [pairs[0] ? pairs[0][0] : '', pairs[1] ? pairs[1][0] : '']
}
function discOrderedFromScores(scores) {
if (!scores || typeof scores !== 'object') return ['', '']
const allow = { D: 1, I: 1, S: 1, C: 1 }
const pairs = []
for (const k of Object.keys(scores)) {
const u = String(k).trim().toUpperCase().charAt(0)
if (!allow[u]) continue
pairs.push([u, Number(scores[k]) || 0])
}
pairs.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
return [pairs[0] ? pairs[0][0] : '', pairs[1] ? pairs[1][0] : '']
}
/** 与 scores 相同维度DISC 接口常以 percentages 存四维而无 scores */
function discOrderedFromPercentages(pct) {
return discOrderedFromScores(pct)
}
/** DISC「S+I型」仅最后一项带「型」 */
function discPrimaryLetter(data) {
if (!data || typeof data !== 'object') return ''
const dType = data.description && data.description.type
if (typeof dType === 'string' && dType) {
const noXing = String(dType).trim().replace(/型$/, '')
if (noXing.length === 1) {
const u = noXing.toUpperCase()
if (['D', 'I', 'S', 'C'].includes(u)) return u
}
}
if (data.dominantType) {
const u = String(data.dominantType).trim().toUpperCase().charAt(0)
if (['D', 'I', 'S', 'C'].includes(u)) return u
}
if (data.disc) {
const noXing = String(data.disc).trim().replace(/型$/, '')
if (noXing.length === 1) {
const u = noXing.toUpperCase()
if (['D', 'I', 'S', 'C'].includes(u)) return u
}
}
return ''
}
function discResolveTwoLetters(data) {
let f = discPrimaryLetter(data)
let s = ''
if (data.secondaryType) {
const u = String(data.secondaryType).trim().toUpperCase().charAt(0)
if (['D', 'I', 'S', 'C'].includes(u)) s = u
}
let a0 = ''
let b = ''
if (data.scores && typeof data.scores === 'object') {
const ord = discOrderedFromScores(data.scores)
a0 = ord[0] || ''
b = ord[1] || ''
}
if (!a0 && !b && data.percentages && typeof data.percentages === 'object') {
const ord = discOrderedFromPercentages(data.percentages)
a0 = ord[0] || ''
b = ord[1] || ''
}
if (!f && a0) f = a0
if (!s || s === f) {
if (b && b !== f) s = b
else s = ''
}
return [f, s]
}
/** 旧接口/库存「S型 + I型」「S型+I型」→ S+I型 */
function discNormalizeLegacyDualType(desc) {
if (typeof desc !== 'string' || !desc) return ''
const t = desc.replace(/\s+/g, '').replace(/\uFF0B/g, '+')
let m = t.match(/^([DISC])型\+([DISC])型$/i)
if (m) return m[1].toUpperCase() + '+' + m[2].toUpperCase() + '型'
m = t.match(/^([DISC])型$/i)
if (m) return m[1].toUpperCase() + '型'
return ''
}
function discTopTwoLabel(data) {
if (!data || typeof data !== 'object') return ''
const [fL, sL] = discResolveTwoLetters(data)
if (fL || sL) {
if (!fL) return sL ? sL + '型' : ''
if (!sL || sL === fL) return fL + '型'
return fL + '+' + sL + '型'
}
return discNormalizeLegacyDualType(data.description && data.description.type)
}
function pdpPrimaryFull(data) {
if (!data || typeof data !== 'object') return ''
const desc = data.description && data.description.type
if (typeof desc === 'string' && desc) return desc.trim()
if (data.dominantType) {
const key = String(data.dominantType).trim()
return PDP_EN_TO_CN[key] || key
}
if (data.pdp) return String(data.pdp).trim()
return ''
}
function pdpResolveTwoFull(data) {
let f = pdpPrimaryFull(data)
let s = ''
if (data.secondaryType) {
const k = String(data.secondaryType).trim()
s = PDP_EN_TO_CN[k] || k
}
let aEn = ''
let bEn = ''
if (data.scores && typeof data.scores === 'object') {
const ord = pdpOrderedFromScores(data.scores)
aEn = ord[0] || ''
bEn = ord[1] || ''
}
if (!aEn && !bEn && data.percentages && typeof data.percentages === 'object') {
const ord = pdpOrderedFromScores(data.percentages)
aEn = ord[0] || ''
bEn = ord[1] || ''
}
if (!f && aEn) f = PDP_EN_TO_CN[aEn] || aEn
if (!s || s === f) {
if (bEn) {
const cand = PDP_EN_TO_CN[bEn] || bEn
if (cand !== f) s = cand
}
}
return [f, s]
}
/** PDP「孔雀+老虎型」,仅最后一项带「型」 */
function pdpTopTwoLabel(data) {
if (!data || typeof data !== 'object') return ''
const [ff, sf] = pdpResolveTwoFull(data)
if (!ff) return sf || ''
if (!sf || sf === ff) return ff
const short = String(ff).replace(/型$/, '')
return short + '+' + sf
}
/**
* 根据测试类型和原始结果,生成带整数百分比的摘要文案
* @param {object} data - 单条测试结果mbtiResult / discResult / pdpResult
* @param {string} testType - 'mbti' | 'disc' | 'pdp'
* @returns {string}
*/
function formatTestSummary(data, testType) {
if (!data || typeof data !== 'object') return ''
const t = (testType || '').toLowerCase()
if (t === 'mbti') {
const label = data.mbtiType || data.type || data.result || ''
const dims = data.dimensionScores
if (dims && typeof dims === 'object') {
const parts = []
const order = ['EI', 'SN', 'TF', 'JP']
for (const key of order) {
const pct = dims[key] && (dims[key].percentage != null ? dims[key].percentage : dims[key].dominant)
if (pct != null) parts.push(toIntPercent(pct) + '%')
}
if (parts.length) return label + ' (' + parts.join(' ') + ')'
}
return String(label)
}
if (t === 'disc') {
const two = discTopTwoLabel(data)
const desc = data.description && data.description.type
const label = two || ((typeof desc === 'string' && desc) ? desc : ((data.dominantType ? data.dominantType + '型' : '') || (data.disc || '')))
const pct = data.percentages
if (pct && typeof pct === 'object') {
const d = toIntPercent(pct.D != null ? pct.D : pct.d)
const i = toIntPercent(pct.I != null ? pct.I : pct.i)
const s = toIntPercent(pct.S != null ? pct.S : pct.s)
const c = toIntPercent(pct.C != null ? pct.C : pct.c)
return label + ' D:' + d + '% I:' + i + '% S:' + s + '% C:' + c + '%'
}
return label || ''
}
if (t === 'pdp') {
const two = pdpTopTwoLabel(data)
const desc = data.description && data.description.type
const label = two || ((typeof desc === 'string' && desc) ? desc : (data.dominantType || data.pdp || ''))
const pct = data.percentages
if (pct && typeof pct === 'object') {
const names = { Tiger: '老虎', Peacock: '孔雀', Owl: '猫头鹰', Koala: '考拉', Chameleon: '变色龙' }
const parts = []
for (const [key, name] of Object.entries(names)) {
const v = pct[key] != null ? pct[key] : pct[key.toLowerCase()]
if (v != null) parts.push(name + ':' + toIntPercent(v) + '%')
}
if (parts.length) return label + ' ' + parts.join(' ')
}
return label || ''
}
return ''
}
/**
* 仅返回类型标签(无百分比),用于列表、个人中心等
*/
function getTypeOnly(data, testType) {
if (!data || typeof data !== 'object') return ''
const t = (testType || '').toLowerCase()
if (t === 'mbti') return String(data.mbtiType ?? data.type ?? data.result ?? '')
if (t === 'disc') {
const two = discTopTwoLabel(data)
if (two) return two
const desc = data.description?.type
if (typeof desc === 'string' && desc) return desc
if (data.dominantType) return String(data.dominantType) + '型'
return String(data.disc ?? '')
}
if (t === 'pdp') {
const two = pdpTopTwoLabel(data)
if (two) return two
const desc = data.description?.type
if (typeof desc === 'string' && desc) return desc
if (data.dominantType) {
const k = String(data.dominantType).trim()
return PDP_EN_TO_CN[k] || k
}
return String(data.pdp ?? '')
}
return ''
}
module.exports = {
toIntPercent,
formatTestSummary,
getTypeOnly,
discTopTwoLabel,
pdpTopTwoLabel
}
/**
* 测试结果摘要格式化:类型 + 整数百分比(与管理后台一致)
*/
function toIntPercent(value) {
if (value == null) return 0
const n = typeof value === 'number' ? value : Number(value)
return Number.isFinite(n) ? Math.round(n) : 0
}
const PDP_EN_TO_CN = {
Tiger: '老虎型',
Peacock: '孔雀型',
Koala: '无尾熊型',
Owl: '猫头鹰型',
Chameleon: '变色龙型'
}
function pdpOrderedFromScores(scores) {
if (!scores || typeof scores !== 'object') return ['', '']
const pairs = []
for (const k of Object.keys(scores)) {
if (!PDP_EN_TO_CN[k]) continue
pairs.push([k, Number(scores[k]) || 0])
}
pairs.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
return [pairs[0] ? pairs[0][0] : '', pairs[1] ? pairs[1][0] : '']
}
function discOrderedFromScores(scores) {
if (!scores || typeof scores !== 'object') return ['', '']
const allow = { D: 1, I: 1, S: 1, C: 1 }
const pairs = []
for (const k of Object.keys(scores)) {
const u = String(k).trim().toUpperCase().charAt(0)
if (!allow[u]) continue
pairs.push([u, Number(scores[k]) || 0])
}
pairs.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
return [pairs[0] ? pairs[0][0] : '', pairs[1] ? pairs[1][0] : '']
}
/** 与 scores 相同维度DISC 接口常以 percentages 存四维而无 scores */
function discOrderedFromPercentages(pct) {
return discOrderedFromScores(pct)
}
/** DISC「S+I型」仅最后一项带「型」 */
function discPrimaryLetter(data) {
if (!data || typeof data !== 'object') return ''
const dType = data.description && data.description.type
if (typeof dType === 'string' && dType) {
const noXing = String(dType).trim().replace(/型$/, '')
if (noXing.length === 1) {
const u = noXing.toUpperCase()
if (['D', 'I', 'S', 'C'].includes(u)) return u
}
}
if (data.dominantType) {
const u = String(data.dominantType).trim().toUpperCase().charAt(0)
if (['D', 'I', 'S', 'C'].includes(u)) return u
}
if (data.disc) {
const noXing = String(data.disc).trim().replace(/型$/, '')
if (noXing.length === 1) {
const u = noXing.toUpperCase()
if (['D', 'I', 'S', 'C'].includes(u)) return u
}
}
return ''
}
function discResolveTwoLetters(data) {
let f = discPrimaryLetter(data)
let s = ''
if (data.secondaryType) {
const u = String(data.secondaryType).trim().toUpperCase().charAt(0)
if (['D', 'I', 'S', 'C'].includes(u)) s = u
}
let a0 = ''
let b = ''
if (data.scores && typeof data.scores === 'object') {
const ord = discOrderedFromScores(data.scores)
a0 = ord[0] || ''
b = ord[1] || ''
}
if (!a0 && !b && data.percentages && typeof data.percentages === 'object') {
const ord = discOrderedFromPercentages(data.percentages)
a0 = ord[0] || ''
b = ord[1] || ''
}
if (!f && a0) f = a0
if (!s || s === f) {
if (b && b !== f) s = b
else s = ''
}
return [f, s]
}
/** 旧接口/库存「S型 + I型」「S型+I型」→ S+I型 */
function discNormalizeLegacyDualType(desc) {
if (typeof desc !== 'string' || !desc) return ''
const t = desc.replace(/\s+/g, '').replace(/\uFF0B/g, '+')
let m = t.match(/^([DISC])型\+([DISC])型$/i)
if (m) return m[1].toUpperCase() + '+' + m[2].toUpperCase() + '型'
m = t.match(/^([DISC])型$/i)
if (m) return m[1].toUpperCase() + '型'
return ''
}
function discTopTwoLabel(data) {
if (!data || typeof data !== 'object') return ''
const [fL, sL] = discResolveTwoLetters(data)
if (fL || sL) {
if (!fL) return sL ? sL + '型' : ''
if (!sL || sL === fL) return fL + '型'
return fL + '+' + sL + '型'
}
return discNormalizeLegacyDualType(data.description && data.description.type)
}
function pdpPrimaryFull(data) {
if (!data || typeof data !== 'object') return ''
const desc = data.description && data.description.type
if (typeof desc === 'string' && desc) return desc.trim()
if (data.dominantType) {
const key = String(data.dominantType).trim()
return PDP_EN_TO_CN[key] || key
}
if (data.pdp) return String(data.pdp).trim()
return ''
}
function pdpResolveTwoFull(data) {
let f = pdpPrimaryFull(data)
let s = ''
if (data.secondaryType) {
const k = String(data.secondaryType).trim()
s = PDP_EN_TO_CN[k] || k
}
let aEn = ''
let bEn = ''
if (data.scores && typeof data.scores === 'object') {
const ord = pdpOrderedFromScores(data.scores)
aEn = ord[0] || ''
bEn = ord[1] || ''
}
if (!aEn && !bEn && data.percentages && typeof data.percentages === 'object') {
const ord = pdpOrderedFromScores(data.percentages)
aEn = ord[0] || ''
bEn = ord[1] || ''
}
if (!f && aEn) f = PDP_EN_TO_CN[aEn] || aEn
if (!s || s === f) {
if (bEn) {
const cand = PDP_EN_TO_CN[bEn] || bEn
if (cand !== f) s = cand
}
}
return [f, s]
}
/** PDP「孔雀+老虎型」,仅最后一项带「型」 */
function pdpTopTwoLabel(data) {
if (!data || typeof data !== 'object') return ''
const [ff, sf] = pdpResolveTwoFull(data)
if (!ff) return sf || ''
if (!sf || sf === ff) return ff
const short = String(ff).replace(/型$/, '')
return short + '+' + sf
}
/**
* 根据测试类型和原始结果,生成带整数百分比的摘要文案
* @param {object} data - 单条测试结果mbtiResult / discResult / pdpResult
* @param {string} testType - 'mbti' | 'disc' | 'pdp'
* @returns {string}
*/
function formatTestSummary(data, testType) {
if (!data || typeof data !== 'object') return ''
const t = (testType || '').toLowerCase()
if (t === 'mbti') {
const label = data.mbtiType || data.type || data.result || ''
const dims = data.dimensionScores
if (dims && typeof dims === 'object') {
const parts = []
const order = ['EI', 'SN', 'TF', 'JP']
for (const key of order) {
const pct = dims[key] && (dims[key].percentage != null ? dims[key].percentage : dims[key].dominant)
if (pct != null) parts.push(toIntPercent(pct) + '%')
}
if (parts.length) return label + ' (' + parts.join(' ') + ')'
}
return String(label)
}
if (t === 'disc') {
const two = discTopTwoLabel(data)
const desc = data.description && data.description.type
const label = two || ((typeof desc === 'string' && desc) ? desc : ((data.dominantType ? data.dominantType + '型' : '') || (data.disc || '')))
const pct = data.percentages
if (pct && typeof pct === 'object') {
const d = toIntPercent(pct.D != null ? pct.D : pct.d)
const i = toIntPercent(pct.I != null ? pct.I : pct.i)
const s = toIntPercent(pct.S != null ? pct.S : pct.s)
const c = toIntPercent(pct.C != null ? pct.C : pct.c)
return label + ' D:' + d + '% I:' + i + '% S:' + s + '% C:' + c + '%'
}
return label || ''
}
if (t === 'pdp') {
const two = pdpTopTwoLabel(data)
const desc = data.description && data.description.type
const label = two || ((typeof desc === 'string' && desc) ? desc : (data.dominantType || data.pdp || ''))
const pct = data.percentages
if (pct && typeof pct === 'object') {
const names = { Tiger: '老虎', Peacock: '孔雀', Owl: '猫头鹰', Koala: '无尾熊', Chameleon: '变色龙' }
const parts = []
for (const [key, name] of Object.entries(names)) {
const v = pct[key] != null ? pct[key] : pct[key.toLowerCase()]
if (v != null) parts.push(name + ':' + toIntPercent(v) + '%')
}
if (parts.length) return label + ' ' + parts.join(' ')
}
return label || ''
}
return ''
}
/**
* 仅返回类型标签(无百分比),用于列表、个人中心等
*/
function getTypeOnly(data, testType) {
if (!data || typeof data !== 'object') return ''
const t = (testType || '').toLowerCase()
if (t === 'mbti') return String(data.mbtiType ?? data.type ?? data.result ?? '')
if (t === 'disc') {
const two = discTopTwoLabel(data)
if (two) return two
const desc = data.description?.type
if (typeof desc === 'string' && desc) return desc
if (data.dominantType) return String(data.dominantType) + '型'
return String(data.disc ?? '')
}
if (t === 'pdp') {
const two = pdpTopTwoLabel(data)
if (two) return two
const desc = data.description?.type
if (typeof desc === 'string' && desc) return desc
if (data.dominantType) {
const k = String(data.dominantType).trim()
return PDP_EN_TO_CN[k] || k
}
return String(data.pdp ?? '')
}
return ''
}
module.exports = {
toIntPercent,
formatTestSummary,
getTypeOnly,
discTopTwoLabel,
pdpTopTwoLabel
}