feat: 小程序与管理端迭代(神仙AI、了解自己CRM、AI测试入口、报表与分润等)

Made-with: Cursor
This commit is contained in:
卡若
2026-04-17 19:46:48 +08:00
parent cf835ea585
commit 7108f28280
239 changed files with 21061 additions and 2175 deletions

View File

@@ -0,0 +1,69 @@
/**
* DISC 深度洞察:主类型 → 心智模式 / 决策倾向 / 能量来源 / 压力反应
* 支持单字母主类型D/I/S/C与组合如 "S+I"),取首字母作为主导。
*/
// 8 维:心智 / 决策 / 能量 / 压力 / 沟通 / 领导 / 恋爱 / 团队角色
const DISC_INSIGHTS = {
D: { mind: '力量型 · 动能主导', decision: '目标·速决', energy: '挑战·掌控', stress: '压迫他人', comm: '直接', lead: '铁腕推进', love: '保护欲强', team: '业务开拓' },
I: { mind: '活跃型 · 影响主导', decision: '氛围·直觉', energy: '社交·舞台', stress: '注意力分散', comm: '热情表达', lead: '激励型', love: '浪漫热烈', team: '气氛担当' },
S: { mind: '和平型 · 稳定主导', decision: '共识·耐心', energy: '关系·节奏', stress: '回避冲突', comm: '温和倾听', lead: '稳定协调', love: '默默陪伴', team: '人和支持' },
C: { mind: '完美型 · 分析主导', decision: '规则·数据', energy: '独立·细节', stress: '过度自省', comm: '精准严谨', lead: '标准驱动', love: '精选长期', team: '质量把关' }
}
const DISC_TAGS = {
D: ['高执行', '目标感', '控制'],
I: ['影响力', '乐观', '高社交'],
S: ['可靠', '稳定', '协作'],
C: ['严谨', '分析', '系统']
}
const DISC_END_LABELS = {
D: { label: '力量 D', emoji: '🔥', color: '#ef4444', desc: '主导型:行动、速度、结果' },
I: { label: '活跃 I', emoji: '🎉', color: '#f59e0b', desc: '影响型:表达、社交、氛围' },
S: { label: '和平 S', emoji: '🌿', color: '#10b981', desc: '稳定型:耐心、协作、节奏' },
C: { label: '完美 C', emoji: '🎯', color: '#3b82f6', desc: '严谨型:分析、规则、数据' }
}
function toFirstLetter(code) {
if (!code) return ''
return String(code).trim().toUpperCase().charAt(0)
}
function getDiscInsight(code) {
const k = toFirstLetter(code)
return DISC_INSIGHTS[k] || null
}
function getDiscTags(code) {
const k = toFirstLetter(code)
return DISC_TAGS[k] || []
}
function getDiscEndMeta(letter) {
return DISC_END_LABELS[letter] || null
}
function buildDiscDimensions(result) {
const p = (result && result.percentages) || {}
const pi = (result && result.percentagesInt) || {}
return ['D', 'I', 'S', 'C'].map((k) => {
const meta = DISC_END_LABELS[k]
return {
key: k,
label: meta.label,
emoji: meta.emoji,
desc: meta.desc,
dashClass: 'dash-fill--disc-' + k.toLowerCase(),
percentage: pi[k] != null ? pi[k] : (p[k] != null ? Math.round(Number(p[k]) || 0) : 0),
raw: p[k] != null ? Number(p[k]) || 0 : (pi[k] != null ? pi[k] : 0)
}
})
}
module.exports = {
getDiscInsight,
getDiscTags,
getDiscEndMeta,
buildDiscDimensions
}

View File

@@ -0,0 +1,124 @@
/**
* MBTI 深度洞察映射:心智模式 / 决策倾向 / 能量来源 / 压力反应
* 仅用于结果页排版展示,不替代后端详细解读
*/
// 每个 MBTI 8 个维度:心智 / 决策 / 能量 / 压力 / 沟通 / 领导 / 恋爱 / 团队角色
const MBTI_INSIGHTS = {
ISTJ: { mind: '系统守护者', decision: '逻辑·数据驱动', energy: '独处·专注', stress: '偏向沉默', comm: '简洁务实', lead: '按规章推进', love: '稳定承诺', team: '制度执行' },
ISFJ: { mind: '温柔守护者', decision: '情感·他人影响', energy: '独处·关系修复', stress: '偏向内耗', comm: '体贴谦和', lead: '幕后支持', love: '默默付出', team: '后勤保障' },
INFJ: { mind: '远见策划者', decision: '价值·直觉', energy: '独处·深谈', stress: '过度共情', comm: '一对一深度', lead: '愿景感召', love: '理想伴侣', team: '战略策划' },
INTJ: { mind: '战略建筑师', decision: '长远·体系', energy: '独处·深度思考', stress: '批判他人', comm: '逻辑直击', lead: '结果导向', love: '精选长期', team: '战略架构' },
ISTP: { mind: '机巧工匠', decision: '现场·效用', energy: '独立任务', stress: '逃避沟通', comm: '极简', lead: '示范而非说教', love: '保留空间', team: '专家救火' },
ISFP: { mind: '灵感艺术家', decision: '此刻·感受', energy: '自然·艺术', stress: '回避冲突', comm: '温柔细腻', lead: '以身作则', love: '浪漫直觉', team: '美学与关怀' },
INFP: { mind: '理想调停者', decision: '价值·情感', energy: '独处·写作', stress: '自我怀疑', comm: '真诚倾听', lead: '价值驱动', love: '灵魂伴侣', team: '灵感与文化' },
INTP: { mind: '逻辑探索者', decision: '体系·真相', energy: '独处·推演', stress: '拖延回避', comm: '抽象表达', lead: '思想影响', love: '智力共鸣', team: '问题拆解' },
ESTP: { mind: '现场行动派', decision: '快反·结果', energy: '高强度互动', stress: '冲动决策', comm: '直接豪爽', lead: '临场指挥', love: '刺激新鲜', team: '业务开拓' },
ESFP: { mind: '热情表演者', decision: '氛围·人情', energy: '社交·舞台', stress: '注意力漂移', comm: '热情表达', lead: '激励氛围', love: '陪伴热烈', team: '气氛担当' },
ENFP: { mind: '点子制造机', decision: '灵感·意义', energy: '头脑风暴', stress: '摊子铺开', comm: '富有感染力', lead: '赋能他人', love: '热烈真诚', team: '点子发动机' },
ENTP: { mind: '辩论革新者', decision: '可能性·逻辑', energy: '激辩·跨界', stress: '承诺恐惧', comm: '思辨锋利', lead: '创新驱动', love: '智力挑战', team: '方案颠覆者' },
ESTJ: { mind: '制度管理者', decision: '规则·效率', energy: '组织·执行', stress: '固执己见', comm: '直截了当', lead: '纪律严明', love: '务实承诺', team: '运营负责人' },
ESFJ: { mind: '关系协调者', decision: '关系·共识', energy: '服务他人', stress: '情绪透支', comm: '关怀周到', lead: '凝聚团队', love: '家庭优先', team: '人和担当' },
ENFJ: { mind: '魅力引领者', decision: '价值·共赢', energy: '激励他人', stress: '过度付出', comm: '感召力强', lead: '愿景领导', love: '深度连接', team: '人才教练' },
ENTJ: { mind: '战略指挥官', decision: '目标·控制', energy: '掌舵决策', stress: '压迫他人', comm: '高压直接', lead: '铁腕执行', love: '强强联合', team: '全局指挥官' }
}
const MBTI_CATEGORY_TAGS = {
守护者: ['稳健', '高执行', '责任感'],
理想主义者: ['远见', '共情', '追求意义'],
探险家: ['灵活', '务实', '上手即用'],
分析家: ['独立', '体系', '战略']
}
/** 每种 MBTI 对应的职业领域(粗分) */
const MBTI_FIELDS = {
ISTJ: ['金融/审计', '项目管理', '行政/法务'],
ISFJ: ['医疗护理', '教育教学', '人事/行政'],
INFJ: ['心理咨询', '内容创作', '人力资源'],
INTJ: ['战略咨询', '科研/架构', '投资分析'],
ISTP: ['工程/技术', '运动/制造', '应急指挥'],
ISFP: ['设计/艺术', '摄影/音乐', '手作/园艺'],
INFP: ['写作/编辑', '心理咨询', '社会公益'],
INTP: ['程序/数据', '研究/学术', '专业咨询'],
ESTP: ['销售/创业', '现场运营', '体育/冒险'],
ESFP: ['演艺/主播', '销售/公关', '活动策划'],
ENFP: ['市场营销', '新媒体/内容', '品牌/咨询'],
ENTP: ['创投/产品', '律师/咨询', '创业孵化'],
ESTJ: ['业务管理', '项目/运营', '律政/执法'],
ESFJ: ['教育/社群', '医疗/HR', '服务业管理'],
ENFJ: ['培训/教育', '战略 HR', '咨询/布道'],
ENTJ: ['企业管理', '创业/咨询', '投资并购']
}
/** 每种 MBTI 的通用发展建议 */
const MBTI_GROWTH = {
ISTJ: ['尝试跨部门/跨行业轮岗,打破思维惯性', '在流程中保留一小部分"实验预算",平衡稳健与创新', '主动请教外部导师,避免只在同温层交流'],
ISFJ: ['学习温和表达不同意见,照顾自己的边界', '把"帮他人"的精力留出 20% 投入自我成长', '记录自己的成就,避免被低估'],
INFJ: ['把宏大愿景拆成 30 天可落地的小任务', '允许自己"不完美",先完成再完善', '建立能真实表达负面情绪的小圈子'],
INTJ: ['在战略外,刻意练习倾听与共情表达', '定期做 360° 反馈,避免盲区', '允许临时方案,不必所有事都等最优解'],
ISTP: ['把动手经验总结成可复用的方法论', '每周主动对接一次跨职能协作', '用短视频/文档分享技巧,增加影响力'],
ISFP: ['把灵感产品化,建立可重复的作品节奏', '学习基础商业/销售语言,提升溢价能力', '主动展示作品,避免完美主义延迟发布'],
INFP: ['用写作/直播稳定输出价值观与作品', '把爱的事做成可持续的小商业', '允许自己设置边界,减少情绪内耗'],
INTP: ['把研究成果转化成可复用的模型或 SaaS', '刻意练习快速决策,避免永远在调研', '加入高密度协作团队,补上执行力'],
ESTP: ['把短期爆发转成长期节奏,做 3 年目标', '留出固定时间做深度学习,补理论短板', '在胜利后主动复盘,避免只重过程'],
ESFP: ['选一件事精深 3 年,避免永远浅尝', '训练结构化思考,提升提案说服力', '主动承担"最后一公里"落地执行'],
ENFP: ['聚焦 3 件最重要的事,放下另外 17 件', '训练收敛与决策,不只输出想法', '每季度复盘把点子变落地的转化率'],
ENTP: ['选一个长线赛道深耕 5 年以上', '把辩论能量转化为共识工作坊', '把 80% 的新想法直接拒绝,只做最强的'],
ESTJ: ['保留 10% 的"松弛时间",允许过程不完美', '刻意练习聆听不同声音,避免强推', '把业务经验系统化输出为培训/标准'],
ESFJ: ['照顾他人前先照顾自己,设立边界', '建立清晰的绩效标准,减少情绪波动', '把对关系的敏感变成"顾问资产"输出'],
ENFJ: ['合理授权,避免把所有情绪都抱回家', '保留独处时间修复能量', '训练战略视角,补上"冷思考"维度'],
ENTJ: ['训练情绪柔软度,避免让下属恐惧', '保留时间做价值观对齐,不只追目标', '主动授权并培养接班人,防止一个人撑整个团队']
}
/**
* @param {string} code 如 'INTJ'
* @returns {{mind:string,decision:string,energy:string,stress:string}|null}
*/
function getMbtiInsight(code) {
const c = (code || '').toUpperCase()
return MBTI_INSIGHTS[c] || null
}
function getMbtiCategoryTags(category) {
const key = (category || '').trim()
return MBTI_CATEGORY_TAGS[key] || []
}
/**
* 为职业列表补充 emoji 图标,前端 grid 展示
*/
function decorateCareers(list) {
const iconMap = {
CEO: '👔', 管理者: '🧭', 顾问: '🧠', 律师: '⚖️', 战略顾问: '🎯',
科学家: '🔬', 架构师: '🏛️', 程序员: '💻', 研究员: '📚', 分析师: '📊',
数据分析师: '📈', 工程师: '🛠️', 技术员: '🔧', 会计师: '📒', 审计师: '🧾',
项目经理: '📋', 市场营销: '📣', 公关: '🎤', 培训师: '🎓', 记者: '📰',
设计师: '🎨', 艺术家: '🖌️', 摄影师: '📷', 演员: '🎬', 主持人: '🎙️',
企业家: '🚀', 销售: '💼', 律所合伙人: '⚖️', 心理咨询: '💬', 作家: '✒️',
社工: '🤝', 护士: '🩺', 教师: '📖', 人力资源: '👥', 行政: '🗂️',
咨询: '🧭', 产品经理: '📐', 顾问师: '🧠', 飞行员: '✈️', 运动员: '🏅',
自由职业者: '🌐', 协调员: '🔗'
}
return (list || []).map((name) => ({
name: String(name || '').trim(),
icon: iconMap[String(name || '').trim()] || '💡'
}))
}
function getMbtiFields(code) {
const c = (code || '').toUpperCase()
return MBTI_FIELDS[c] || []
}
function getMbtiGrowth(code) {
const c = (code || '').toUpperCase()
return MBTI_GROWTH[c] || []
}
module.exports = {
getMbtiInsight,
getMbtiCategoryTags,
getMbtiFields,
getMbtiGrowth,
decorateCareers
}

View File

@@ -0,0 +1,269 @@
// utils/mbtiLocalQuestions.js
// MBTI 本地 fallback 题库 —— 仅当 /api/test/questions 不可达时使用
// 覆盖四维度E/I · S/N · T/F · J/P各 15 题,共 60 题
// 注意:不替代线上题库,仅保底;结果计算走 pages/test/mbti.js 的 calculateResult
//
// 题目结构:{ id, text, dimension: 'EI'|'SN'|'TF'|'JP', options: [{ text, value }] }
// value 必须是 'E' / 'I' / 'S' / 'N' / 'T' / 'F' / 'J' / 'P' 八个字母之一
const QUESTIONS = [
// ==================== E / I 外向 vs 内向15 题)====================
{ id: 'L-EI-01', text: '参加聚会时,你更享受:', dimension: 'EI', options: [
{ text: '与很多人交谈互动', value: 'E' },
{ text: '和少数几个人深聊', value: 'I' }
]},
{ id: 'L-EI-02', text: '经过忙碌的一天,你更倾向于:', dimension: 'EI', options: [
{ text: '出门找朋友放松', value: 'E' },
{ text: '一个人独处充电', value: 'I' }
]},
{ id: 'L-EI-03', text: '遇到问题时,你先:', dimension: 'EI', options: [
{ text: '找人一起讨论', value: 'E' },
{ text: '一个人默默思考', value: 'I' }
]},
{ id: 'L-EI-04', text: '在新环境里,你通常:', dimension: 'EI', options: [
{ text: '主动跟大家打招呼', value: 'E' },
{ text: '先观察再慢慢融入', value: 'I' }
]},
{ id: 'L-EI-05', text: '你更喜欢的工作方式:', dimension: 'EI', options: [
{ text: '多人协作沟通推进', value: 'E' },
{ text: '独立专注深度工作', value: 'I' }
]},
{ id: 'L-EI-06', text: '面对一群陌生人,你会:', dimension: 'EI', options: [
{ text: '感到兴奋,期待结识', value: 'E' },
{ text: '感到疲惫,想早离场', value: 'I' }
]},
{ id: 'L-EI-07', text: '你表达想法时更习惯:', dimension: 'EI', options: [
{ text: '边说边想,口头表达', value: 'E' },
{ text: '先想好再开口或写下', value: 'I' }
]},
{ id: 'L-EI-08', text: '周末你更想:', dimension: 'EI', options: [
{ text: '约上朋友热闹一场', value: 'E' },
{ text: '一个人看书、发呆', value: 'I' }
]},
{ id: 'L-EI-09', text: '在会议中你更容易:', dimension: 'EI', options: [
{ text: '积极发言分享观点', value: 'E' },
{ text: '认真倾听再发言', value: 'I' }
]},
{ id: 'L-EI-10', text: '你的能量来源主要是:', dimension: 'EI', options: [
{ text: '和他人的互动交流', value: 'E' },
{ text: '独处的安静时光', value: 'I' }
]},
{ id: 'L-EI-11', text: '认识新朋友时你更看重:', dimension: 'EI', options: [
{ text: '广泛的社交圈', value: 'E' },
{ text: '少数深度的友谊', value: 'I' }
]},
{ id: 'L-EI-12', text: '演讲或发言机会出现时你:', dimension: 'EI', options: [
{ text: '愿意尝试,享受舞台', value: 'E' },
{ text: '倾向避免,偏好幕后', value: 'I' }
]},
{ id: 'L-EI-13', text: '你更容易:', dimension: 'EI', options: [
{ text: '主动开启对话', value: 'E' },
{ text: '等别人先开口', value: 'I' }
]},
{ id: 'L-EI-14', text: '一天下来话多了你会:', dimension: 'EI', options: [
{ text: '越讲越来劲', value: 'E' },
{ text: '想找个安静角落', value: 'I' }
]},
{ id: 'L-EI-15', text: '沉默的氛围你觉得:', dimension: 'EI', options: [
{ text: '尴尬,想打破', value: 'E' },
{ text: '舒服,享受宁静', value: 'I' }
]},
// ==================== S / N 感觉 vs 直觉15 题)====================
{ id: 'L-SN-01', text: '你更关注:', dimension: 'SN', options: [
{ text: '眼下具体的事实', value: 'S' },
{ text: '未来的可能性', value: 'N' }
]},
{ id: 'L-SN-02', text: '学习新知识时你偏好:', dimension: 'SN', options: [
{ text: '从实际案例入手', value: 'S' },
{ text: '先理解整体框架', value: 'N' }
]},
{ id: 'L-SN-03', text: '做决定时你更依赖:', dimension: 'SN', options: [
{ text: '过往经验与数据', value: 'S' },
{ text: '直觉与潜在趋势', value: 'N' }
]},
{ id: 'L-SN-04', text: '描述事物时你更倾向:', dimension: 'SN', options: [
{ text: '具体细节', value: 'S' },
{ text: '抽象概念', value: 'N' }
]},
{ id: 'L-SN-05', text: '面对项目,你会先:', dimension: 'SN', options: [
{ text: '列清具体步骤', value: 'S' },
{ text: '构思大方向与创意', value: 'N' }
]},
{ id: 'L-SN-06', text: '你更欣赏的书籍:', dimension: 'SN', options: [
{ text: '写实传记、工具书', value: 'S' },
{ text: '哲学、科幻、象征文学', value: 'N' }
]},
{ id: 'L-SN-07', text: '谈论"未来五年",你会:', dimension: 'SN', options: [
{ text: '列出具体目标与路径', value: 'S' },
{ text: '畅想各种可能性', value: 'N' }
]},
{ id: 'L-SN-08', text: '接到任务你先问:', dimension: 'SN', options: [
{ text: '怎么做、截止时间?', value: 'S' },
{ text: '为什么做、意义是?', value: 'N' }
]},
{ id: 'L-SN-09', text: '你喜欢的工作类型:', dimension: 'SN', options: [
{ text: '流程清晰、步骤明确', value: 'S' },
{ text: '需要创新与想象', value: 'N' }
]},
{ id: 'L-SN-10', text: '看风景时你会:', dimension: 'SN', options: [
{ text: '留意植物、天气、光线', value: 'S' },
{ text: '联想到某种心情或意境', value: 'N' }
]},
{ id: 'L-SN-11', text: '聊天中你更容易:', dimension: 'SN', options: [
{ text: '讲具体发生的事', value: 'S' },
{ text: '延伸到理论或类比', value: 'N' }
]},
{ id: 'L-SN-12', text: '解决问题时你更看重:', dimension: 'SN', options: [
{ text: '验证过的成熟方法', value: 'S' },
{ text: '新颖独特的切入点', value: 'N' }
]},
{ id: 'L-SN-13', text: '你的注意力更多放在:', dimension: 'SN', options: [
{ text: '此刻正在发生的事', value: 'S' },
{ text: '事情背后的模式', value: 'N' }
]},
{ id: 'L-SN-14', text: '你相信:', dimension: 'SN', options: [
{ text: '看得见的才是真的', value: 'S' },
{ text: '灵感与预感常常正确', value: 'N' }
]},
{ id: 'L-SN-15', text: '别人说你的特点:', dimension: 'SN', options: [
{ text: '踏实、可靠、注重细节', value: 'S' },
{ text: '想象力丰富、爱畅想', value: 'N' }
]},
// ==================== T / F 思考 vs 情感15 题)====================
{ id: 'L-TF-01', text: '做决定时你更看重:', dimension: 'TF', options: [
{ text: '逻辑与客观事实', value: 'T' },
{ text: '感受与人际影响', value: 'F' }
]},
{ id: 'L-TF-02', text: '朋友向你倾诉时你会:', dimension: 'TF', options: [
{ text: '分析原因、给出建议', value: 'T' },
{ text: '共情、陪伴感受', value: 'F' }
]},
{ id: 'L-TF-03', text: '批评同事工作时你更倾向:', dimension: 'TF', options: [
{ text: '直接指出问题', value: 'T' },
{ text: '委婉给建议', value: 'F' }
]},
{ id: 'L-TF-04', text: '你认为好决策要:', dimension: 'TF', options: [
{ text: '公正合理', value: 'T' },
{ text: '让人感到被尊重', value: 'F' }
]},
{ id: 'L-TF-05', text: '冲突中你更关注:', dimension: 'TF', options: [
{ text: '谁对谁错', value: 'T' },
{ text: '大家的感受', value: 'F' }
]},
{ id: 'L-TF-06', text: '工作评估你更相信:', dimension: 'TF', options: [
{ text: '数据与绩效', value: 'T' },
{ text: '态度与团队氛围', value: 'F' }
]},
{ id: 'L-TF-07', text: '你评价自己是:', dimension: 'TF', options: [
{ text: '理性、客观', value: 'T' },
{ text: '温暖、善解人意', value: 'F' }
]},
{ id: 'L-TF-08', text: '看电影时你更容易:', dimension: 'TF', options: [
{ text: '分析剧情逻辑', value: 'T' },
{ text: '被角色情绪打动', value: 'F' }
]},
{ id: 'L-TF-09', text: '他人失败时你会说:', dimension: 'TF', options: [
{ text: '看看哪里可以改进', value: 'T' },
{ text: '你辛苦了,别太难受', value: 'F' }
]},
{ id: 'L-TF-10', text: '买东西时你最在意:', dimension: 'TF', options: [
{ text: '性价比', value: 'T' },
{ text: '是否让自己或亲人开心', value: 'F' }
]},
{ id: 'L-TF-11', text: '谈判中你更可能:', dimension: 'TF', options: [
{ text: '坚持立场,据理力争', value: 'T' },
{ text: '寻求双方都舒服的方案', value: 'F' }
]},
{ id: 'L-TF-12', text: '接受反馈时你更在意:', dimension: 'TF', options: [
{ text: '反馈是否准确', value: 'T' },
{ text: '对方是否尊重你', value: 'F' }
]},
{ id: 'L-TF-13', text: '面对选择你先问自己:', dimension: 'TF', options: [
{ text: '哪个更合理?', value: 'T' },
{ text: '哪个让我更安心?', value: 'F' }
]},
{ id: 'L-TF-14', text: '你欣赏的领导:', dimension: 'TF', options: [
{ text: '目标清晰、决策果断', value: 'T' },
{ text: '关心员工、能激励人', value: 'F' }
]},
{ id: 'L-TF-15', text: '朋友犯错时你更倾向:', dimension: 'TF', options: [
{ text: '坦诚指出', value: 'T' },
{ text: '先理解再委婉提醒', value: 'F' }
]},
// ==================== J / P 判断 vs 知觉15 题)====================
{ id: 'L-JP-01', text: '你更喜欢:', dimension: 'JP', options: [
{ text: '计划好再行动', value: 'J' },
{ text: '边走边看,保持灵活', value: 'P' }
]},
{ id: 'L-JP-02', text: '你的桌面/房间通常:', dimension: 'JP', options: [
{ text: '整洁有序', value: 'J' },
{ text: '凌乱但自有章法', value: 'P' }
]},
{ id: 'L-JP-03', text: '旅行你更愿意:', dimension: 'JP', options: [
{ text: '提前订好行程', value: 'J' },
{ text: '随性出发,临时决定', value: 'P' }
]},
{ id: 'L-JP-04', text: '截止日期临近你会:', dimension: 'JP', options: [
{ text: '很早开始,提前完成', value: 'J' },
{ text: '最后冲刺,效率最高', value: 'P' }
]},
{ id: 'L-JP-05', text: '你喜欢的待办清单:', dimension: 'JP', options: [
{ text: '打勾一条一条完成', value: 'J' },
{ text: '灵活选择,心情驱动', value: 'P' }
]},
{ id: 'L-JP-06', text: '改变计划时你:', dimension: 'JP', options: [
{ text: '感到不安,希望稳定', value: 'J' },
{ text: '欢迎变化,觉得有趣', value: 'P' }
]},
{ id: 'L-JP-07', text: '你做决定的速度:', dimension: 'JP', options: [
{ text: '果断,能尽快确定', value: 'J' },
{ text: '偏慢,倾向保留选择', value: 'P' }
]},
{ id: 'L-JP-08', text: '你工作中更倾向:', dimension: 'JP', options: [
{ text: '设定目标并推进完成', value: 'J' },
{ text: '探索各种可能再定', value: 'P' }
]},
{ id: 'L-JP-09', text: '收到邀请你会:', dimension: 'JP', options: [
{ text: '马上确认或婉拒', value: 'J' },
{ text: '拖一阵看情况再定', value: 'P' }
]},
{ id: 'L-JP-10', text: '你更喜欢的节奏:', dimension: 'JP', options: [
{ text: '稳定可控的日常', value: 'J' },
{ text: '充满惊喜的变化', value: 'P' }
]},
{ id: 'L-JP-11', text: '面对突发任务你:', dimension: 'JP', options: [
{ text: '感到打乱、需要调整', value: 'J' },
{ text: '随机应变、不太介意', value: 'P' }
]},
{ id: 'L-JP-12', text: '你喜欢的工作方式:', dimension: 'JP', options: [
{ text: '按流程一步一步', value: 'J' },
{ text: '灵感来了就一口气干', value: 'P' }
]},
{ id: 'L-JP-13', text: '别人评价你:', dimension: 'JP', options: [
{ text: '有条理、守时', value: 'J' },
{ text: '随性、有弹性', value: 'P' }
]},
{ id: 'L-JP-14', text: '面对未完成的事你:', dimension: 'JP', options: [
{ text: '强迫自己收尾', value: 'J' },
{ text: '先放一放,换个状态', value: 'P' }
]},
{ id: 'L-JP-15', text: '你的生活哲学:', dimension: 'JP', options: [
{ text: '计划是成功的一半', value: 'J' },
{ text: '变化是唯一的不变', value: 'P' }
]}
]
/** 返回本地 MBTI 题库60 题) */
function getMbtiLocalQuestions() {
return QUESTIONS.map((q) => ({
id: q.id,
text: q.text,
dimension: q.dimension,
options: (q.options || []).map((o) => ({ ...o }))
}))
}
module.exports = { getMbtiLocalQuestions }

View File

@@ -0,0 +1,67 @@
/**
* PDP 深度洞察:主导动物型 → 心智模式 / 决策倾向 / 能量来源 / 压力反应
*/
// 8 维:心智 / 决策 / 能量 / 压力 / 沟通 / 领导 / 恋爱 / 团队角色
const PDP_INSIGHTS = {
Tiger: { mind: '老虎型 · 支配主导', decision: '速断·结果', energy: '挑战·夺冠', stress: '强硬控场', comm: '命令直接', lead: '果敢铁腕', love: '保护占有', team: '业务指挥' },
Peacock: { mind: '孔雀型 · 表达主导', decision: '氛围·直觉', energy: '舞台·社交', stress: '情绪放大', comm: '热情感染', lead: '激励型', love: '浪漫热烈', team: '气氛担当' },
Koala: { mind: '无尾熊型 · 支持主导', decision: '共识·稳妥', energy: '关系·节奏', stress: '回避冲突', comm: '温和聆听', lead: '幕后协调', love: '默默陪伴', team: '人和黏合' },
Owl: { mind: '猫头鹰型 · 分析主导', decision: '数据·规则', energy: '独立·深研', stress: '过度完美', comm: '精准严谨', lead: '标准驱动', love: '精选理性', team: '专家深研' },
Chameleon: { mind: '变色龙型 · 整合主导', decision: '平衡·权变', energy: '多场景切换', stress: '定位模糊', comm: '灵活变化', lead: '多面手', love: '契合环境', team: '跨界整合' }
}
const PDP_TAGS = {
Tiger: ['目标感', '高执行', '控制'],
Peacock: ['影响力', '乐观', '表达'],
Koala: ['可靠', '耐心', '协作'],
Owl: ['严谨', '分析', '系统'],
Chameleon: ['灵活', '适应', '整合']
}
const PDP_META = {
Tiger: { emoji: '🐅', label: '老虎 · 支配', desc: '行动果断、目标导向' },
Peacock: { emoji: '🦚', label: '孔雀 · 表达', desc: '富有感染力、善于社交' },
Koala: { emoji: '🐨', label: '无尾熊 · 支持', desc: '稳定可靠、照顾团队' },
Owl: { emoji: '🦉', label: '猫头鹰 · 分析', desc: '注重逻辑、追求精准' },
Chameleon: { emoji: '🦎', label: '变色龙 · 整合', desc: '灵活平衡、多面手' }
}
function toKey(code) {
const s = (code || '').trim()
if (!s) return ''
const up = s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()
return PDP_INSIGHTS[up] ? up : ''
}
function getPdpInsight(code) {
const k = toKey(code)
return PDP_INSIGHTS[k] || null
}
function getPdpTags(code) {
const k = toKey(code)
return PDP_TAGS[k] || []
}
function buildPdpDimensions(result) {
const pi = (result && result.percentagesInt) || {}
const p = (result && result.percentages) || {}
return ['Tiger', 'Peacock', 'Koala', 'Owl', 'Chameleon'].map((k) => {
const meta = PDP_META[k]
return {
key: k,
label: meta.label,
emoji: meta.emoji,
desc: meta.desc,
dashClass: 'dash-fill--pdp-' + k.toLowerCase(),
percentage: pi[k] != null ? pi[k] : (p[k] != null ? Math.round(Number(p[k]) || 0) : 0)
}
})
}
module.exports = {
getPdpInsight,
getPdpTags,
buildPdpDimensions
}

View File

@@ -74,6 +74,21 @@ function applyDrawCountAfterShuffle(questions) {
* @param {number|null|undefined} enterpriseId
* @returns {Promise<Array>}
*/
function parseJsonBody(raw) {
if (raw == null) return {}
if (typeof raw === 'object' && !Array.isArray(raw)) return raw
if (typeof raw === 'string') {
const s = raw.trim()
if (!s) return {}
try {
return JSON.parse(s)
} catch (e) {
return { _parseError: true, _rawSnippet: s.slice(0, 120) }
}
}
return {}
}
function fetchQuestionBank(type, enterpriseId) {
const q = [`type=${encodeURIComponent(type)}`]
if (enterpriseId != null && Number(enterpriseId) > 0) {
@@ -84,9 +99,14 @@ function fetchQuestionBank(type, enterpriseId) {
method: 'GET',
needAuth: true
}).then((res) => {
const body = res.data || {}
const body = parseJsonBody(res.data)
if (body._parseError) {
throw new Error('接口返回非 JSON请检查小程序 apiBase 与后台域名(可设 Storage key: apiBaseOverride')
}
if (body.code !== 200 || body.data == null) {
throw new Error(body.message || '拉取题库失败')
const msg = body.message || '拉取题库失败'
const hint = body.code === 401 || body.code === 403 ? `${msg}(请重新进入小程序或下拉刷新)` : msg
throw new Error(hint)
}
const list = body.data.list
if (!Array.isArray(list)) {
@@ -96,18 +116,47 @@ function fetchQuestionBank(type, enterpriseId) {
})
}
/**
* 仅 mbti 类型支持本地 fallback 题库60 题)
* 网络异常isNetworkError或 5xx 且线上拉题失败时启用,保证测试可完成
*/
function loadLocalFallback(type) {
if (type !== 'mbti') return null
try {
const { getMbtiLocalQuestions } = require('./mbtiLocalQuestions.js')
const list = getMbtiLocalQuestions()
return Array.isArray(list) && list.length ? list : null
} catch (e) {
return null
}
}
/**
* @param {'mbti'|'sbti'|'disc'|'pdp'} type
* @param {{ enterpriseId?: number|null }} opts
* @returns {Promise<Array>} 乱序后的题目;接口无题或失败则 reject
* @param {{ enterpriseId?: number|null, allowLocalFallback?: boolean }} opts
* @returns {Promise<Array>} 乱序后的题目;接口无题或失败则 rejectmbti 可降级本地)
*/
function loadQuestions(type, opts = {}) {
const enterpriseId = resolveEnterpriseIdForQuestionBank(opts)
const allowLocal = opts.allowLocalFallback !== false // 默认允许
return fetchQuestionBank(type, enterpriseId).then((list) => {
if (!list.length) {
throw new Error('暂无启用题目')
}
return applyDrawCountAfterShuffle(shuffleQuestions(list))
}).catch((err) => {
// 网络异常 / 5xx / 401登录失败/ 超时均降级4xx 业务错误不降级
const status = err && err.statusCode
const canFallback = err && (
err.isNetworkError ||
(status >= 500 && status < 600) ||
status === 401
)
if (!allowLocal || !canFallback) throw err
const local = loadLocalFallback(type)
if (!local) throw err
console.warn('[questionBank] 后端不可达,降级本地 fallback 题库:', type, err && err.message)
return applyDrawCountAfterShuffle(shuffleQuestions(local))
})
}

View File

@@ -1,109 +1,149 @@
/**
* 统一请求封装:自动拼接 baseURL、携带 token、处理 401
* 使用前需已执行 App(),否则 getApp() 在 require 时可能未就绪,这里在请求时再取 app
*/
function getAppSafe() {
try {
return getApp()
} catch (e) {
return null
}
}
function getToken() {
const app = getAppSafe()
return (app && app.globalData && app.globalData.token) || wx.getStorageSync('token') || ''
}
function getApiBase() {
const app = getAppSafe()
return (app && app.globalData && app.globalData.apiBase) || ''
}
/**
* 清除登录态401 或主动退出时调用)
*/
function clearLoginState() {
const app = getAppSafe()
if (app && app.globalData) {
app.globalData.token = null
app.globalData.userInfo = null
app.globalData.openId = null
}
try {
wx.removeStorageSync('token')
wx.removeStorageSync('userInfo')
} catch (e) {}
}
/**
* 发起请求
* @param {Object} options - 同 wx.requesturl 可为相对路径(自动加 apiBase
* @param {boolean} options.needAuth - 是否携带 Authorization默认 true
* @param {boolean} options.allow401 - 401 时是否静默清除登录态而不 fail默认 true
*/
function request(options) {
const apiBase = getApiBase()
const url = options.url
const fullUrl = url.startsWith('http') ? url : `${apiBase.replace(/\/$/, '')}${url.startsWith('/') ? '' : '/'}${url}`
const needAuth = options.needAuth !== false
const allow401 = options.allow401 !== false
const header = {
'Content-Type': 'application/json',
...(options.header || {})
}
if (needAuth) {
const token = getToken()
if (token) header['Authorization'] = `Bearer ${token}`
}
const success = options.success
const fail = options.fail
const complete = options.complete
return wx.request({
...options,
url: fullUrl,
header,
success(res) {
if (res.statusCode === 401 && allow401) {
clearLoginState()
}
if (success) success(res)
},
fail(err) {
if (fail) fail(err)
},
complete(res) {
if (complete) complete(res)
}
})
}
/**
* Promise 版 request便于 async/await
*/
function requestPromise(options) {
return new Promise((resolve, reject) => {
request({
...options,
success(res) {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(res)
} else {
reject(new Error(res.data && res.data.message ? res.data.message : '请求失败'))
}
},
fail: reject
})
})
}
module.exports = {
request,
requestPromise,
getToken,
getApiBase,
clearLoginState
}
/**
* 统一请求封装:自动拼接 baseURL、携带 token、处理 401
* 使用前需已执行 App(),否则 getApp() 在 require 时可能未就绪,这里在请求时再取 app
*/
function getAppSafe() {
try {
return getApp()
} catch (e) {
return null
}
}
function getToken() {
const app = getAppSafe()
return (app && app.globalData && app.globalData.token) || wx.getStorageSync('token') || ''
}
function getApiBase() {
const app = getAppSafe()
return (app && app.globalData && app.globalData.apiBase) || ''
}
/**
* 清除登录态401 或主动退出时调用)
*/
function clearLoginState() {
const app = getAppSafe()
if (app && app.globalData) {
app.globalData.token = null
app.globalData.userInfo = null
app.globalData.openId = null
}
try {
wx.removeStorageSync('token')
wx.removeStorageSync('userInfo')
} catch (e) {}
}
/**
* 发起请求
* @param {Object} options - 同 wx.requesturl 可为相对路径(自动加 apiBase
* @param {boolean} options.needAuth - 是否携带 Authorization默认 true
* @param {boolean} options.allow401 - 401 时是否静默清除登录态而不 fail默认 true
*/
function request(options) {
const apiBase = getApiBase()
const url = options.url
const fullUrl = url.startsWith('http') ? url : `${apiBase.replace(/\/$/, '')}${url.startsWith('/') ? '' : '/'}${url}`
const needAuth = options.needAuth !== false
const allow401 = options.allow401 !== false
const header = {
'Content-Type': 'application/json',
...(options.header || {})
}
if (needAuth) {
const token = getToken()
if (token) header['Authorization'] = `Bearer ${token}`
}
const success = options.success
const fail = options.fail
const complete = options.complete
return wx.request({
...options,
url: fullUrl,
header,
success(res) {
if (res.statusCode === 401 && allow401) {
clearLoginState()
}
if (success) success(res)
},
fail(err) {
if (fail) fail(err)
},
complete(res) {
if (complete) complete(res)
}
})
}
/**
* Promise 版 request便于 async/await
*/
function requestPromiseOnce(options) {
return new Promise((resolve, reject) => {
request({
timeout: options.timeout || 15000,
...options,
success(res) {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(res)
} else {
let msg = '请求失败'
const d = res.data
if (d && typeof d === 'object' && d.message) msg = d.message
else if (typeof d === 'string') {
try {
const o = JSON.parse(d)
if (o && o.message) msg = o.message
} catch (e) {
if (res.statusCode === 401) msg = '未登录或登录已过期'
}
}
const err = new Error(msg)
err.statusCode = res.statusCode
reject(err)
}
},
fail(err) {
// wx.request fail网络不可达 / CONNECTION_CLOSED / TLS / 超时
const e = new Error((err && err.errMsg) || '网络异常')
e.isNetworkError = true
reject(e)
}
})
})
}
/**
* 带指数退避重试(默认 2 次):仅对"网络异常"或 5xx 重试4xx 不重试
* 用法requestPromise({ url, ...opts, retry: 2, retryDelayMs: 400 })
*/
function requestPromise(options) {
const retry = options.retry == null ? 2 : Number(options.retry) || 0
const retryDelayMs = options.retryDelayMs == null ? 400 : Number(options.retryDelayMs) || 0
let attempt = 0
const run = () => requestPromiseOnce(options).catch((err) => {
const retriable = err && (err.isNetworkError || (err.statusCode >= 500 && err.statusCode < 600))
if (!retriable || attempt >= retry) {
return Promise.reject(err)
}
attempt += 1
const wait = retryDelayMs * Math.pow(2, attempt - 1)
return new Promise((r) => setTimeout(r, wait)).then(run)
})
return run()
}
module.exports = {
request,
requestPromise,
requestPromiseOnce,
getToken,
getApiBase,
clearLoginState
}

View File

@@ -0,0 +1,88 @@
/**
* 结果页「按步骤解锁」状态:
* Step 1 · 看全文(需完善资料或付费)
* Step 2 · 分享朋友圈Step 1 完成后启用)
* Step 3 · AI 拍照测试Step 2 完成后启用)
*
* 状态持久化到本地 storage
* mbti_journey_unlocks = { sharedMoment: ts, faceCamera: ts }
* 不依赖后端。
*/
const KEY = 'mbti_journey_unlocks'
function read() {
try {
const v = wx.getStorageSync(KEY)
if (v && typeof v === 'object') return v
} catch (e) {}
return {}
}
function write(obj) {
try {
wx.setStorageSync(KEY, obj || {})
} catch (e) {}
}
/**
* Step 1 是否已解锁
* 条件资料完善profileGate===false免费或已付费
*/
function isStep1Unlocked({ profileGate, payRequired, isPaid }) {
if (profileGate) return false
if (payRequired && !isPaid) return false
return true
}
function isStep2Unlocked(ctx) {
if (!isStep1Unlocked(ctx)) return false
return !!read().sharedMoment
}
function isStep3Unlocked(ctx) {
if (!isStep2Unlocked(ctx)) return false
return !!read().faceCamera
}
function markShared() {
const v = read()
v.sharedMoment = Date.now()
write(v)
}
function markCamera() {
const v = read()
v.faceCamera = Date.now()
write(v)
}
/**
* 统一封装:返回 { step1, step2, step3, activeStep }
* @param {{profileGate:boolean, payRequired:boolean, isPaid:boolean}} ctx
*/
function computeJourney(ctx) {
const s1 = isStep1Unlocked(ctx)
const s2 = s1 && !!read().sharedMoment
const s3 = s2 && !!read().faceCamera
let activeStep = 1
if (!s1) activeStep = 1
else if (!s2) activeStep = 2
else if (!s3) activeStep = 3
else activeStep = 0
return {
step1Unlocked: s1,
step2Unlocked: s2,
step3Unlocked: s3,
activeStep
}
}
module.exports = {
computeJourney,
markShared,
markCamera,
isStep1Unlocked,
isStep2Unlocked,
isStep3Unlocked
}

View File

@@ -0,0 +1,58 @@
/**
* 结果页资料门禁:未完善头像+昵称+手机时,客户端展示约 30% 预览(与后端 locked 互补)
*/
const { isReportProfileComplete } = require('./phoneAuth.js')
/** @param {WechatMiniprogram.Page.TrivialInstance} page */
function setProfileGateOnPage(page) {
const fromShare = !!(page.data && page.data.fromShare)
const gate = !fromShare && !isReportProfileComplete()
page.setData({ profileGate: gate })
}
/**
* 截取约 ratio 比例的纯文本(最少 minChars尾部省略号
* @param {string} str
* @param {number} ratio 0~1
* @param {number} minChars
*/
function slicePreviewText(str, ratio, minChars) {
const s = (str && String(str).trim()) || ''
const r = typeof ratio === 'number' && ratio > 0 && ratio < 1 ? ratio : 0.3
const minC = minChars != null ? minChars : 36
if (!s) return ''
const n = Math.max(minC, Math.floor(s.length * r))
return s.length <= n ? s : s.slice(0, n) + '…'
}
/**
* @param {string[]} list
* @param {number} ratio
*/
function slicePreviewList(list, ratio) {
const arr = Array.isArray(list) ? list : []
const r = typeof ratio === 'number' && ratio > 0 && ratio < 1 ? ratio : 0.3
if (!arr.length) return []
const take = Math.max(1, Math.ceil(arr.length * r))
return arr.slice(0, take)
}
function openTimelineShareHint() {
try {
wx.showShareMenu({ withShareTicket: true, menus: ['shareAppMessage', 'shareTimeline'] })
} catch (e) {}
wx.showModal({
title: '分享到朋友圈',
content: '已为你打开分享能力:请先点右上角「···」,选择「分享到朋友圈」。好友点链接即可测试。',
showCancel: false,
confirmText: '知道了'
})
}
module.exports = {
isReportProfileComplete,
setProfileGateOnPage,
slicePreviewText,
slicePreviewList,
openTimelineShareHint
}

View File

@@ -0,0 +1,36 @@
// utils/resultSectionScrollSync.js
// 四测结果页共享scroll 节流 + 计算当前 activeSection回写 chip 高亮
// 用法:
// const scrollSync = require('../../utils/resultSectionScrollSync')
// <scroll-view ... bindscroll="onScroll" />
// onScroll(e) { scrollSync.onScroll(this, e) }
const THROTTLE_MS = 140
// chip 条 + 安全间距,单位 pxboundingClientRect 返回 px
const OFFSET_TOP = 70
function onScroll(page, _e) {
if (!page || !page.data || !Array.isArray(page.data.sectionNav) || !page.data.sectionNav.length) return
const now = Date.now()
if (page._scrollThrottleAt && now - page._scrollThrottleAt < THROTTLE_MS) return
page._scrollThrottleAt = now
const ids = page.data.sectionNav.map((item) => item.id).filter(Boolean)
if (!ids.length) return
const query = wx.createSelectorQuery().in(page)
ids.forEach((id) => query.select('#' + id).boundingClientRect())
query.exec((res) => {
let current = ids[0]
for (let i = 0; i < ids.length; i++) {
const r = res[i]
if (r && typeof r.top === 'number' && r.top - OFFSET_TOP <= 0) {
current = ids[i]
}
}
if (current && current !== page.data.activeSection) {
page.setData({ activeSection: current })
}
})
}
module.exports = { onScroll }

View File

@@ -0,0 +1,91 @@
/**
* SBTI 展示辅助:为十五维度补 emoji、简短英文代号、进度百分比
*/
const DIM_ICONS = {
S1: '🪞', S2: '🧭', S3: '🚀',
E1: '💌', E2: '💞', E3: '🌱',
A1: '🤝', A2: '🎯', A3: '🧠',
C1: '📚', C2: '🕊️', C3: '🌟',
So1: '👥', So2: '🌞', So3: '🧗'
}
const DIM_COLORS = {
S: 'sbti-dim-color-s',
E: 'sbti-dim-color-e',
A: 'sbti-dim-color-a',
C: 'sbti-dim-color-c',
So: 'sbti-dim-color-so'
}
function pickColorClass(dim) {
if (!dim) return DIM_COLORS.S
if (dim.toUpperCase().startsWith('SO')) return DIM_COLORS.So
const ch = dim.charAt(0).toUpperCase()
return DIM_COLORS[ch] || DIM_COLORS.S
}
function decorateSbtiDims(list) {
return (Array.isArray(list) ? list : []).map((item) => {
const dim = item.dim || item.code || ''
const level = Number(item.level) || 0
const percent = Math.max(0, Math.min(100, Math.round((level / 6) * 100)))
return {
...item,
icon: DIM_ICONS[dim] || '🎯',
colorClass: pickColorClass(dim),
percent
}
})
}
const GROUP_TITLES = {
S: '自我认同S 系)',
E: '亲密关系E 系)',
A: '外界交互A 系)',
C: '生活方式C 系)',
So: '社会参与So 系)'
}
/**
* 把装饰后的 15 维列表按首字母分组S / E / A / C / So
* @param {Array<object>} decoratedList
* @returns {Array<{ key: string, title: string, items: Array }>}
*/
function groupSbtiDims(decoratedList) {
const buckets = { S: [], E: [], A: [], C: [], So: [] }
;(Array.isArray(decoratedList) ? decoratedList : []).forEach((item) => {
const dim = (item.dim || item.code || '').toUpperCase()
if (dim.startsWith('SO')) buckets.So.push(item)
else if (dim.startsWith('S')) buckets.S.push(item)
else if (dim.startsWith('E')) buckets.E.push(item)
else if (dim.startsWith('A')) buckets.A.push(item)
else if (dim.startsWith('C')) buckets.C.push(item)
})
return Object.keys(buckets)
.filter((k) => buckets[k].length > 0)
.map((k) => ({ key: k, title: GROUP_TITLES[k] || k, items: buckets[k] }))
}
/**
* 把长段落按中英文句号/分号切分为短段
* @param {string} text
* @param {number} maxCount
* @returns {string[]}
*/
function splitSbtiDesc(text, maxCount) {
const s = (text || '').trim()
if (!s) return []
const parts = s
.replace(/\r/g, '')
.split(/(?:。|||!||\n)+/)
.map((t) => t.trim())
.filter((t) => t.length > 0)
const limit = Number.isFinite(maxCount) && maxCount > 0 ? maxCount : 100
return parts.slice(0, limit).map((t) => (t.endsWith('。') || t.endsWith('') || t.endsWith('?') || t.endsWith('')) ? t : t + '。')
}
module.exports = {
decorateSbtiDims,
groupSbtiDims,
splitSbtiDesc
}