feat: 同步今日小程序与后台迭代版本

集中提交今日 API、管理端、小程序与部署文档调整,确保 Gitea 主分支与本地最新开发版本一致。

Made-with: Cursor
This commit is contained in:
卡若
2026-04-20 11:07:55 +08:00
parent 7108f28280
commit ce55c48c64
180 changed files with 11659 additions and 6078 deletions

View File

@@ -1,5 +1,6 @@
// app.js - MBTI小程序主入口
const { request } = require('./utils/request.js')
const { isAuditHideAiMode } = require('./utils/miniprogramAuditGate.js')
// 全局注入:所有页面 onShow 自动上报 page_viewonShareAppMessage 自动上报 share
const _OrigPage = Page
@@ -39,7 +40,16 @@ App({
siteTitle: '神仙团队AI性格测试',
textConfig: null, // 从 /api/config/runtime 动态加载analyzingTitle, startButtonText, reportTitle, aiAnalysisText 等
maintenanceMode: undefined, // 审核模式undefined=未加载,等 getRuntimeConfig 后再决定,避免 tabBar 闪烁
/** 小程序提审:隐藏神仙 AI Tab/入口,由 runtime.miniprogramAuditMode 写入 */
miniprogramAuditMode: false,
reviewMode: undefined, // 面相审核开关,与 camera/index 一致,由 runtime.reviewMode 写入
// GET /api/config/runtime 内嵌的快捷问句(网关拦截独立接口时兜底)
runtimeAiQuickQuestions: null,
/** 仅当 /api/config/runtime 返回 data.aiChat 时写入用于区分「runtime 已发版 / 对话接口未发版」 */
aiChatRuntime: null,
// GET /api/config/runtime 内嵌的深度服务类目(与 /api/config/deep-pricing 同源)
deepPricingPersonal: null,
deepPricingEnterprise: null,
// 当前使用范围personal 个人版 / enterprise 企业版(影响定价与 enterpriseId 写入)
appScope: 'personal',
// 扫码进入企业页时 scene 解析出的企业IDe_123提交测试/分析时优先使用
@@ -119,35 +129,53 @@ App({
return new Promise((resolve) => {
const base = (this.globalData.apiBase || '').replace(/\/$/, '')
if (!base) { resolve(null); return }
wx.request({
url: base + '/api/mp/tabbar',
method: 'GET',
timeout: 6000,
success: (res) => {
if (res && res.statusCode === 200 && res.data && res.data.code === 200 && res.data.data) {
const cfg = res.data.data
if (Array.isArray(cfg.items) && cfg.items.length >= 2) {
this.globalData.tabBar = cfg
try { wx.setStorageSync('tabBarConfig', cfg) } catch (e) {}
try {
const pages = getCurrentPages()
if (pages && pages.length > 0) {
const top = pages[pages.length - 1]
if (top && typeof top.getTabBar === 'function') {
const tb = top.getTabBar()
if (tb && typeof tb.refreshFromConfig === 'function') tb.refreshFromConfig()
}
}
} catch (e) {}
}
const apply = (res) => {
if (res && res.statusCode === 200 && res.data && res.data.code === 200 && res.data.data) {
const cfg = res.data.data
if (Array.isArray(cfg.items) && cfg.items.length >= 2) {
this.globalData.tabBar = cfg
try { wx.setStorageSync('tabBarConfig', cfg) } catch (e) {}
this.refreshCustomTabBar()
return true
}
}
return false
}
// 线上部分网关对 /api/mp/tabbar 返回 404与 runtime 相同前缀的 config 常已放行
const urls = [base + '/api/config/mp-tabbar', base + '/api/mp/tabbar']
const tryNext = (i) => {
if (i >= urls.length) {
resolve(null)
},
fail: () => resolve(null)
})
return
}
wx.request({
url: urls[i],
method: 'GET',
timeout: 6000,
success: (res) => {
if (apply(res)) resolve(null)
else tryNext(i + 1)
},
fail: () => tryNext(i + 1)
})
}
tryNext(0)
})
},
/** 审核开关或 Tab 配置变化后刷新自定义 tabBar与 getRuntimeConfig / loadTabBarConfig 共用) */
refreshCustomTabBar() {
try {
const pages = getCurrentPages()
if (!pages || pages.length === 0) return
const top = pages[pages.length - 1]
if (top && typeof top.getTabBar === 'function') {
const tb = top.getTabBar()
if (tb && typeof tb.refreshFromConfig === 'function') tb.refreshFromConfig()
}
} catch (e) {}
},
/**
* runtime 写入 globalData 后,同步当前栈顶页面与 tabBar避免首屏用旧 null 权限)
*/
@@ -164,16 +192,21 @@ App({
if (typeof top.getTabBar === 'function') {
const tb = top.getTabBar()
if (tb && typeof tb.updateSelected === 'function') tb.updateSelected()
if (tb && typeof tb.refreshFromConfig === 'function') tb.refreshFromConfig()
else if (tb && typeof tb.updateSelected === 'function') tb.updateSelected()
}
const route = top.route || ''
if (isAuditHideAiMode(gd) && route.indexOf('pages/ai-chat/') === 0) {
wx.switchTab({ url: '/pages/index/index' })
return
}
if (route === 'pages/index/index' && typeof top.setData === 'function') {
top.setData({
reviewMode: audit,
permFace,
siteTitle: audit ? String(gd.siteTitle || '神仙团队性格测试').replace(/AI/gi, '') : (gd.siteTitle || '神仙团队性格测试'),
startButtonText: (audit || (ep && ep.face === false)) ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonText) || '拍摄'),
startButtonText: (audit || (ep && ep.face === false)) ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonText) || '30秒测出你的性格'),
aiAnalysisText: audit ? '分析' : ((gd.textConfig && gd.textConfig.aiAnalysisText) || '分析')
})
} else if (route === 'pages/profile/index' && typeof top.setData === 'function') {
@@ -292,6 +325,7 @@ App({
wx.request({
url,
method: 'POST',
timeout: 25000,
header: { 'Content-Type': 'application/json' },
data: loginData,
success: (response) => {
@@ -308,6 +342,10 @@ App({
wx.setStorageSync('token', token)
}
if (user) {
try {
const { syncPhoneLoginStorageWithUser } = require('./utils/phoneAuth.js')
syncPhoneLoginStorageWithUser(user)
} catch (e) {}
this.globalData.userInfo = user
this.globalData.openId = user.openid || user.openId || null
wx.setStorageSync('userInfo', user)
@@ -336,7 +374,9 @@ App({
if (storedToken) {
this.globalData.token = storedToken
this.globalData.userInfo = storedUser || null
this.globalData.openId = storedUser ? storedUser.id : null
this.globalData.openId = storedUser
? (storedUser.openid || storedUser.openId || null)
: null
resolve(true)
} else {
resolve(false)
@@ -359,6 +399,18 @@ App({
*/
ensureLogin() {
if (this.globalData.token) return Promise.resolve(true)
try {
const st = wx.getStorageSync('token')
if (st) {
this.globalData.token = st
const su = wx.getStorageSync('userInfo')
if (su) {
this.globalData.userInfo = su
this.globalData.openId = su.openid || su.openId || this.globalData.openId
}
return Promise.resolve(true)
}
} catch (e) {}
return new Promise((resolve) => {
let settled = false
const timer = setTimeout(() => {
@@ -372,12 +424,35 @@ App({
if (settled) return
settled = true
clearTimeout(timer)
if (!ok) {
try {
const st = wx.getStorageSync('token')
if (st) {
this.globalData.token = st
const su = wx.getStorageSync('userInfo')
if (su) {
this.globalData.userInfo = su
this.globalData.openId = su.openid || su.openId || this.globalData.openId
}
resolve(true)
return
}
} catch (e) {}
}
resolve(!!ok)
})
.catch(() => {
if (settled) return
settled = true
clearTimeout(timer)
try {
const st = wx.getStorageSync('token')
if (st) {
this.globalData.token = st
resolve(true)
return
}
} catch (e) {}
resolve(false)
})
})
@@ -415,6 +490,10 @@ App({
/** 清除登录态(退出登录时调用) */
logout() {
try {
const { clearPhoneLoginSession } = require('./utils/phoneAuth.js')
clearPhoneLoginSession()
} catch (e) {}
this.globalData.token = null
this.globalData.userInfo = null
this.globalData.openId = null
@@ -582,6 +661,11 @@ App({
if (data.siteTitle) this.globalData.siteTitle = data.siteTitle
if (data.textConfig) this.globalData.textConfig = data.textConfig
if (data.maintenanceMode !== undefined) this.globalData.maintenanceMode = !!data.maintenanceMode
if (data.miniprogramAuditMode !== undefined) {
this.globalData.miniprogramAuditMode = !!data.miniprogramAuditMode
} else {
this.globalData.miniprogramAuditMode = false
}
if (data.reviewMode !== undefined) {
this.globalData.reviewMode = !!data.reviewMode
} else if (data.maintenanceMode !== undefined) {
@@ -597,6 +681,33 @@ App({
} else {
this.globalData.enterprisePermissions = null
}
if (data.tabBar && Array.isArray(data.tabBar.items) && data.tabBar.items.length >= 2) {
this.globalData.tabBar = data.tabBar
try {
wx.setStorageSync('tabBarConfig', data.tabBar)
} catch (e) {}
}
this.refreshCustomTabBar()
if (data.aiQuickQuestions && typeof data.aiQuickQuestions === 'object') {
this.globalData.runtimeAiQuickQuestions = data.aiQuickQuestions
} else {
this.globalData.runtimeAiQuickQuestions = null
}
if (data.aiChat && typeof data.aiChat === 'object') {
this.globalData.aiChatRuntime = data.aiChat
} else {
this.globalData.aiChatRuntime = null
}
if (data.deepPricingPersonal && Array.isArray(data.deepPricingPersonal.categories)) {
this.globalData.deepPricingPersonal = data.deepPricingPersonal.categories
} else {
this.globalData.deepPricingPersonal = null
}
if (data.deepPricingEnterprise && Array.isArray(data.deepPricingEnterprise.categories)) {
this.globalData.deepPricingEnterprise = data.deepPricingEnterprise.categories
} else {
this.globalData.deepPricingEnterprise = null
}
resolve(data)
} else {
reject(new Error(res.data && res.data.message ? res.data.message : '获取配置失败'))

View File

@@ -66,14 +66,23 @@
}
]
},
"networkTimeout": {
"request": 120000,
"downloadFile": 120000
},
"window": {
"backgroundTextStyle": "light",
"backgroundTextStyle": "dark",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTitleText": "神仙团队性格测试",
"navigationBarTextStyle": "black",
"backgroundColor": "#F5F5F5"
"backgroundColor": "#FFFFFF"
},
"style": "v2",
"sitemapLocation": "sitemap.json",
"lazyCodeLoading": "requiredComponents"
"navigateToMiniProgramAppIdList": [
"wxb8bbb2b10dec74aa"
],
"usingComponents": {
"result-section-nav": "/components/result-section-nav/index"
}
}

View File

@@ -1,5 +1,7 @@
/* app.wxss - 全局样式 */
@import "./styles/result-page-dashboard.wxss";
/* 主题颜色 */
page {
--primary-color: #FF6B8A;
@@ -180,8 +182,9 @@ page {
在 tab 页外层 view 加 .tabbar-pad 即可避免被遮挡;
也可直接写 style="padding-bottom: var(--tabbar-pad)" */
page {
--tabbar-height: 140rpx;
--tabbar-pad: calc(var(--tabbar-height) + env(safe-area-inset-bottom) + 20rpx);
/* 与实际自定义 tab 栏(含文案行 + 中间浮钮视觉)对齐,略放大避免遮挡 */
--tabbar-height: 168rpx;
--tabbar-pad: calc(var(--tabbar-height) + env(safe-area-inset-bottom) + 36rpx);
}
.tabbar-pad {

View File

@@ -2,10 +2,12 @@
// 数据来源app.globalData.tabBar.itemsGET /api/mp/tabbar
// 兜底:若配置缺失,使用默认 4 项首页·拍摄·神仙AI·我
const { isAuditHideAiMode } = require('../utils/miniprogramAuditGate.js')
const DEFAULT_ITEMS = [
{ pagePath: 'pages/index/index', text: '首页', iconKey: 'home', iconUrl: '', highlight: false },
{ pagePath: 'pages/index/camera', text: '拍摄', iconKey: 'camera', iconUrl: '', highlight: true },
{ pagePath: 'pages/ai-chat/index', text: '神仙AI', iconKey: 'ai', iconUrl: '/images/shenxian-oldman-circle.png', highlight: false },
{ pagePath: 'pages/ai-chat/index', text: '神仙AI', iconKey: 'ai', iconUrl: '/images/shenxian-ai-logo.png', highlight: false },
{ pagePath: 'pages/profile/index', text: '我', iconKey: 'profile', iconUrl: '', highlight: false }
]
@@ -39,16 +41,23 @@ Component({
const items = rawItems.map((it) => ({
...it,
// 仅神仙AI使用指定 logo其他图标保持后台配置
iconUrl: it.iconKey === 'ai' ? '/images/shenxian-oldman-circle.png' : (it.iconUrl || '')
iconUrl: it.iconKey === 'ai' ? '/images/shenxian-ai-logo.png' : (it.iconUrl || '')
}))
const reviewMode = !!(gd.reviewMode || gd.maintenanceMode)
const hideAiTab = isAuditHideAiMode(gd)
const ep = gd.enterprisePermissions
const faceOff = !!(ep && ep.face === false)
const hideMiddleFab = reviewMode || faceOff
// 提审或面相审核:去掉神仙 AI Tab与 miniprogramAuditGate 一致)
// 审核模式 / face 关闭时:隐藏所有 highlight=true 的 Tab
const effectiveItems = items.filter(it => !(hideMiddleFab && it.highlight))
const effectiveItems = items.filter((it) => {
const path = (it.pagePath || '').replace(/^\//, '')
if (hideAiTab && (it.iconKey === 'ai' || /ai-chat/.test(path))) return false
if (hideMiddleFab && it.highlight) return false
return true
})
this.setData({
items: effectiveItems,

View File

@@ -8,7 +8,10 @@ Page({
hasMore: true
},
onShow() {
this.setData({ list: [], page: 1, hasMore: true }, () => this.fetchPage())
const { ensureRuntimeThenGate } = require('../../utils/miniprogramAuditGate.js')
ensureRuntimeThenGate(() => {
this.setData({ list: [], page: 1, hasMore: true }, () => this.fetchPage())
})
},
fetchPage() {
const { page, pageSize, list, hasMore } = this.data

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,6 @@
"navigationBarTitleText": "神仙 AI",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"backgroundColor": "#F5F3FF",
"backgroundColor": "#ffffff",
"usingComponents": {}
}

View File

@@ -1,72 +1,75 @@
<!-- 神仙 AI · 对话页 -->
<!-- 神仙 AI · 对话页(精选推荐嵌入第 2 轮及以后的助手气泡 · MBTI 去测进测评列表) -->
<view class="ai-page">
<!-- 推荐文章:超管开启后显示;默认折叠(由后台 sectionExpandedDefault 控制首次是否展开) -->
<view class="soul-articles-wrap" wx:if="{{articlesDisplayEnabled}}">
<view class="articles-fold-bar" bindtap="onToggleArticlesFold">
<view class="articles-fold-left">
<text class="articles-fold-title">精选推荐</text>
<text class="articles-fold-pill">Soul</text>
<text class="articles-fold-count" wx:if="{{!articlesBlockExpanded}}">· {{articles.length}} 篇</text>
</view>
<text class="articles-fold-chevron">{{articlesBlockExpanded ? '▼' : '▶'}}</text>
</view>
<view class="soul-articles" wx:if="{{articlesBlockExpanded && articles.length > 0}}">
<view class="articles-list">
<view class="article-card" wx:for="{{articles}}" wx:key="id" bindtap="onTapArticle" data-id="{{item.id}}" data-url="{{item.url}}" data-title="{{item.title}}">
<view class="article-cover-wrap">
<image class="article-cover" src="{{item.cover || defaultCover}}" mode="aspectFill" />
</view>
<view class="article-body">
<text class="article-title">{{item.title}}</text>
<view class="article-meta">
<text class="article-pill">{{item.tag || 'MBTI'}}</text>
<text class="article-time" wx:if="{{item.publishedAt}}">{{item.publishedAt}}</text>
</view>
</view>
<scroll-view class="chat-scroll" scroll-y="true" scroll-into-view="{{scrollTarget}}" scroll-with-animation="true" enhanced show-scrollbar="{{false}}">
<!-- MBTI 引导:去测 → 详细性格测试列表(不直跳拍摄) -->
<view class="mbti-remind-card" wx:if="{{messages.length === 0}}">
<image class="mbti-remind-avatar" src="/images/shenxian-ai-logo.png" mode="aspectFill" />
<view class="mbti-remind-body">
<view class="mbti-remind-title">我是神仙 AI</view>
<view class="mbti-remind-sub" wx:if="{{mbtiType}}">
已了解你的 <text class="mbti-badge">{{mbtiType}}</text>,继续聊~
</view>
<view class="mbti-remind-sub" wx:else>先做一下 MBTI 测评,我能帮你聊得更懂你哦~</view>
<view class="mbti-remind-actions">
<text class="mbti-remind-tag">MBTI</text>
<view class="mbti-remind-btn" hover-class="mbti-remind-btn--press" bindtap="onGoMbtiTest">去测 MBTI</view>
</view>
</view>
</view>
</view>
<!-- MBTI 提醒:去测 → 详细性格测试列表 -->
<view class="mbti-remind-card">
<view class="mbti-remind-row">
<image class="mbti-remind-avatar" src="/images/shenxian-oldman-circle.png" mode="aspectFill" />
<view class="mbti-remind-texts">
<view class="mbti-remind-title">我是神仙 AI</view>
<view class="mbti-remind-sub">{{mbtiType ? ('已了解你的 ' + mbtiType + ',继续聊~') : '先做一下 MBTI 测评,我能帮你聊得更懂你哦~'}}</view>
</view>
</view>
<view class="mbti-remind-actions">
<text class="mbti-remind-tag">MBTI</text>
<view class="mbti-remind-btn" hover-class="mbti-remind-btn--press" bindtap="onGoMbtiTest">去测 MBTI</view>
</view>
</view>
<!-- 聊天区 -->
<scroll-view class="chat-scroll" scroll-y="true" scroll-into-view="{{scrollTarget}}" scroll-with-animation="true" enhanced show-scrollbar="{{false}}">
<!-- 消息气泡 -->
<view class="msg-wrap" wx:for="{{messages}}" wx:key="id" id="msg-{{item.id}}">
<view wx:if="{{item.role === 'assistant'}}" class="msg msg-ai">
<image class="avatar-ai" src="/images/shenxian-oldman-circle.png" mode="aspectFill" />
<image class="avatar-ai" src="/images/shenxian-ai-logo.png" mode="aspectFill" />
<view class="bubble bubble-ai {{item.isDegraded ? 'bubble-degraded' : ''}}">
<text selectable="true">{{item.content}}</text>
<view class="bubble-ai-inner">
<text class="bubble-ai-text" user-select="true">{{item.content}}</text>
</view>
<!-- 精选推荐:周期抽检 + 意图与正文相关;功能卡点击跳转,文章卡走原文/外链 -->
<view
wx:if="{{item.inlineArticle}}"
class="inline-reco"
catchtap="onTapArticle"
data-id="{{item.inlineArticle.id}}"
data-url="{{item.inlineArticle.url}}"
data-title="{{item.inlineArticle.title}}"
data-source-id="{{item.inlineArticle.sourceId}}"
data-nav-path="{{item.inlineArticle.navPath}}"
>
<view class="inline-reco-icons" wx:if="{{item.inlineArticle.icons && item.inlineArticle.icons.length}}">
<text class="inline-reco-ic" wx:for="{{item.inlineArticle.icons}}" wx:for-item="ic" wx:key="*this">{{ic}}</text>
</view>
<view class="inline-reco-bar">
<text class="inline-reco-label">精选推荐</text>
<text class="inline-reco-hint">{{item.inlineArticle.readCountLabel || '阅读'}}</text>
</view>
<view class="inline-reco-card">
<image class="inline-reco-cover" src="{{item.inlineArticle.cover || defaultCover}}" mode="aspectFill" />
<view class="inline-reco-body">
<text class="inline-reco-title">{{item.inlineArticle.title}}</text>
<view class="inline-reco-meta">
<text class="inline-reco-tag">{{item.inlineArticle.tag || 'MBTI'}}</text>
<text class="inline-reco-time">{{item.inlineArticle.publishedAt}}</text>
</view>
</view>
</view>
</view>
<view class="bubble-meta" wx:if="{{item.isDegraded}}">(降级兜底)</view>
</view>
</view>
<view wx:else class="msg msg-me">
<view class="bubble bubble-me">
<text selectable="true">{{item.content}}</text>
<text user-select="true">{{item.content}}</text>
</view>
</view>
</view>
<view class="typing" wx:if="{{sending}}" id="msg-typing">
<image class="avatar-ai" src="/images/shenxian-oldman-circle.png" mode="aspectFill" />
<view class="bubble bubble-ai"><text>神仙在思考…</text></view>
<image class="avatar-ai" src="/images/shenxian-ai-logo.png" mode="aspectFill" />
<view class="bubble bubble-ai">
<view class="bubble-ai-inner"><text class="bubble-ai-text">神仙AI在思考…</text></view>
</view>
</view>
<!-- 深度报告 CTA对话 >=10 条且还没付费时显示 -->
<view class="report-cta" wx:if="{{showReportCta}}" bindtap="onTapReportCta">
<view class="report-cta-text">✨ 聊了这么多,来份专属 AI 深度画像报告¥9.9</view>
<view class="report-cta-btn">立即生成</view>
@@ -75,39 +78,58 @@
<view class="scroll-bottom" id="bottom"></view>
</scroll-view>
<!-- 底部快捷问法(常驻 -->
<scroll-view class="quick-bottom-scroll" scroll-x="true" show-scrollbar="{{false}}" wx:if="{{quickQuestions.length > 0}}">
<view class="quick-bottom-list">
<view class="quick-bottom-item" wx:for="{{quickQuestions}}" wx:for-item="q" wx:key="*this" bindtap="onTapQuick" data-q="{{q}}">
{{q}}
</view>
<!-- 与 Tab 之间的白底垫层,避免聊天记录从输入条/底栏缝隙透出pointer-events:none 不挡操作 -->
<view class="ai-bottom-solid" aria-hidden="true"></view>
<!-- 底部:快捷语(横滑)+ 输入区,固定在 Tab 之上 -->
<view class="composer-wrap">
<view class="quick-chips-outer" wx:if="{{quickQuestions.length > 0}}">
<scroll-view
class="quick-chips-scroll"
scroll-x="{{true}}"
enhanced="{{true}}"
show-scrollbar="{{false}}"
>
<view class="quick-chips-row">
<view
class="quick-chip"
wx:for="{{quickQuestions}}"
wx:for-item="q"
wx:key="*this"
data-q="{{q}}"
bindtap="onTapQuick"
>{{q}}</view>
</view>
</scroll-view>
<view class="quick-chips-edge-fade" aria-hidden="true"></view>
</view>
</scroll-view>
<view class="input-bar">
<view class="resume-entry" bindtap="onTapUploadResume" hover-class="resume-entry--press">
<text class="resume-entry-icon">📎</text>
<text class="resume-entry-txt">简历</text>
</view>
<input
class="input"
placeholder="问你想了解的自己"
placeholder-class="input-ph"
value="{{draft}}"
bindinput="onInput"
bindconfirm="onSend"
confirm-type="send"
confirm-hold="true"
adjust-position="{{true}}"
maxlength="800"
disabled="{{sending}}"
/>
<!-- 输入区:输入框 + 发送 -->
<view class="input-bar">
<input
class="input"
placeholder="问我你想了解的自己"
placeholder-class="input-ph"
value="{{draft}}"
bindinput="onInput"
bindconfirm="onSend"
confirm-type="send"
confirm-hold="true"
adjust-position="{{true}}"
maxlength="800"
disabled="{{sending}}"
/>
<view
class="send-btn {{draft && !sending ? 'send-btn--active' : ''}} {{sending || !draft ? 'send-btn--disabled' : ''}}"
hover-class="{{draft && !sending ? 'send-btn--press' : ''}}"
hover-start-time="0"
hover-stay-time="80"
bindtap="onSend"
>
<text class="send-btn__label">发送</text>
<button
class="send-icon-btn {{draft ? 'send-icon-btn--active' : ''}}"
bindtap="onSend"
disabled="{{sending || !draft}}"
hover-class="send-icon-btn--hover"
>
<view class="send-icon-plane"></view>
</button>
</view>
</view>
</view>

View File

@@ -1,95 +1,42 @@
/* 神仙 AI · 对话页样式 */
page {
height: 100%;
background: linear-gradient(180deg, #F5F3FF 0%, #FFFFFF 40%);
background-color: #ffffff;
}
.ai-page {
min-height: 100vh;
display: flex;
flex-direction: column;
/* 给底部 tabBar + 输入栏预留空间;使用全局变量避免不同机型遮挡 */
padding-bottom: calc(var(--tabbar-pad, 160rpx) + 150rpx + env(safe-area-inset-bottom));
background-color: #ffffff;
/* TabBar + 底部 composer快捷语 + 输入行) */
padding-bottom: calc(var(--tabbar-pad, 170rpx) + 200rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
/* 推荐区块:折叠条 */
.soul-articles-wrap {
margin: 0 24rpx 12rpx;
}
.articles-fold-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18rpx 20rpx;
background: #ffffff;
border-radius: 16rpx;
border: 1rpx solid #e9d5ff;
box-shadow: 0 2rpx 10rpx rgba(124, 58, 237, 0.06);
}
.articles-fold-left {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10rpx;
min-width: 0;
}
.articles-fold-title {
font-size: 28rpx;
font-weight: 700;
color: #4c1d95;
}
.articles-fold-pill {
font-size: 20rpx;
color: #6d28d9;
background: #ede9fe;
padding: 4rpx 14rpx;
border-radius: 100rpx;
font-weight: 600;
}
.articles-fold-count {
font-size: 22rpx;
color: #9ca3af;
}
.articles-fold-chevron {
font-size: 22rpx;
color: #7c3aed;
flex-shrink: 0;
margin-left: 12rpx;
}
/* MBTI 测评提醒卡 */
/* MBTI 引导卡 */
.mbti-remind-card {
margin: 0 24rpx 14rpx;
padding: 20rpx 22rpx;
background: linear-gradient(135deg, #faf5ff 0%, #f3e8ff 100%);
border-radius: 20rpx;
margin: 8rpx 0 16rpx;
padding: 22rpx 24rpx;
background: linear-gradient(135deg, #f5f3ff 0%, #ede9fe 100%);
border: 1rpx solid #e9d5ff;
box-shadow: 0 4rpx 14rpx rgba(124, 58, 237, 0.08);
}
.mbti-remind-row {
border-radius: 24rpx;
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 14rpx;
margin-bottom: 16rpx;
gap: 18rpx;
}
.mbti-remind-avatar {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
border: 2rpx solid #e9d5ff;
flex-shrink: 0;
border: 2rpx solid #e9d5ff;
background: #fff;
}
.mbti-remind-texts {
.mbti-remind-body {
flex: 1;
min-width: 0;
}
@@ -98,213 +45,61 @@ page {
font-size: 30rpx;
font-weight: 700;
color: #1f1b4d;
margin-bottom: 6rpx;
margin-bottom: 8rpx;
}
.mbti-remind-sub {
font-size: 24rpx;
color: #6b5f8a;
font-size: 26rpx;
color: #4b5563;
line-height: 1.45;
margin-bottom: 16rpx;
}
.mbti-badge {
color: #7c3aed;
font-weight: 700;
}
.mbti-remind-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
flex-wrap: wrap;
}
.mbti-remind-tag {
font-size: 22rpx;
font-weight: 600;
color: #5b21b6;
background: rgba(255, 255, 255, 0.85);
padding: 8rpx 20rpx;
color: #6d28d9;
background: #fff;
padding: 6rpx 18rpx;
border-radius: 100rpx;
border: 1rpx solid #ddd6fe;
}
.mbti-remind-btn {
flex-shrink: 0;
padding: 14rpx 36rpx;
font-size: 26rpx;
font-weight: 700;
color: #ffffff;
border-radius: 100rpx;
background: linear-gradient(135deg, #9333ea 0%, #7c3aed 100%);
box-shadow: 0 4rpx 14rpx rgba(124, 58, 237, 0.35);
font-weight: 600;
color: #fff;
padding: 12rpx 36rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
box-shadow: 0 6rpx 20rpx rgba(124, 58, 237, 0.35);
}
.mbti-remind-btn--press {
opacity: 0.88;
transform: scale(0.98);
}
/* 顶部推荐文章列表 */
.soul-articles {
padding: 12rpx 0 0;
}
.articles-list {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.article-card {
display: flex;
align-items: stretch;
width: 100%;
background: #ffffff;
border-radius: 18rpx;
box-shadow: 0 6rpx 20rpx rgba(124, 58, 237, 0.1);
border: 1rpx solid rgba(124, 58, 237, 0.08);
overflow: hidden;
}
.article-cover-wrap {
width: 200rpx;
min-width: 200rpx;
padding: 10rpx;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(145deg, #faf5ff 0%, #f3e8ff 100%);
}
.article-cover {
width: 100%;
height: 120rpx;
border-radius: 14rpx;
background: #ede9fe;
box-shadow: 0 2rpx 8rpx rgba(124, 58, 237, 0.12);
}
.article-body {
flex: 1;
min-width: 0;
padding: 10rpx 12rpx;
}
.article-title {
font-size: 22rpx;
color: #1F1B4D;
line-height: 1.4;
font-weight: 600;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
white-space: normal;
}
.article-meta {
margin-top: 6rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.article-pill {
font-size: 20rpx;
color: #5b21b6;
background: linear-gradient(135deg, #ede9fe 0%, #f5f3ff 100%);
padding: 4rpx 14rpx;
border-radius: 100rpx;
font-weight: 600;
border: 1rpx solid #ddd6fe;
}
.article-time {
font-size: 18rpx;
color: #9CA3AF;
}
/* 聊天区flex:1 撑开,不再用 100vh 硬算 */
/* 聊天区:不透明白底,避免页面渐变从气泡后透出 */
.chat-scroll {
flex: 1;
min-height: 0;
padding: 8rpx 24rpx 160rpx;
margin: 0 16rpx;
padding: 16rpx 20rpx 32rpx;
box-sizing: border-box;
}
/* 欢迎卡:紧凑化,不再占整屏 */
.welcome {
margin: 8rpx 0 20rpx;
padding: 24rpx 28rpx;
background: linear-gradient(135deg, #F5F3FF 0%, #EDE9FE 100%);
border: 1rpx solid #EDE9FE;
background-color: #ffffff;
border-radius: 24rpx;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.welcome-head {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 8rpx;
}
.welcome-logo {
width: 44rpx;
height: 44rpx;
border-radius: 50%;
border: 2rpx solid #E9D5FF;
box-shadow: 0 2rpx 10rpx rgba(124, 58, 237, 0.2);
}
.welcome-title {
font-size: 30rpx;
font-weight: 700;
color: #1F1B4D;
}
.welcome-sub {
font-size: 24rpx;
color: #6B6894;
margin-bottom: 16rpx;
line-height: 1.5;
}
.mbti-badge {
display: inline-block;
margin: 0 4rpx;
padding: 2rpx 14rpx;
background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
color: #ffffff;
border-radius: 100rpx;
font-weight: 600;
font-size: 22rpx;
}
.quick-title {
font-size: 24rpx;
color: #4C1D95;
font-weight: 600;
margin-bottom: 10rpx;
}
.quick-list {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.quick-item {
background: #ffffff;
border-radius: 100rpx;
padding: 14rpx 22rpx;
font-size: 24rpx;
color: #4C1D95;
border: 1rpx solid #DDD6FE;
}
.quick-item:active {
background: #EDE9FE;
transform: scale(0.98);
border: 1rpx solid rgba(229, 231, 235, 0.9);
}
/* 消息气泡 */
@@ -347,10 +142,30 @@ page {
}
.bubble-ai {
background: #ffffff;
background-color: #ffffff;
color: #1F1B4D;
border-top-left-radius: 4rpx;
box-shadow: 0 2rpx 10rpx rgba(31, 27, 77, 0.06);
box-shadow: 0 2rpx 12rpx rgba(31, 27, 77, 0.08);
border: 1rpx solid rgba(243, 244, 246, 0.98);
/* 避免开发者工具/部分机型上 text 与 scroll-view 叠层导致「透底」 */
isolation: isolate;
overflow: hidden;
}
.bubble-ai-inner {
background-color: #ffffff !important;
border-radius: inherit;
}
.bubble-ai-text {
display: block;
background-color: #ffffff !important;
color: inherit;
}
/* 强制不透底(部分基础库上 text 在 scroll-view 内会透出下层背景) */
.bubble-ai:not(.bubble-degraded) {
background-color: #ffffff !important;
}
.bubble-me {
@@ -365,6 +180,96 @@ page {
border: 1rpx solid #FED7AA;
}
.bubble-degraded .bubble-ai-inner,
.bubble-degraded .bubble-ai-text {
background-color: #FFF7ED;
color: #C2410C;
}
/* 助手气泡内嵌:精选推荐(第 2 轮及以后) */
.inline-reco-icons {
display: flex;
flex-direction: row;
align-items: center;
gap: 10rpx;
margin-bottom: 10rpx;
padding: 0 4rpx;
}
.inline-reco-ic {
font-size: 34rpx;
line-height: 1;
}
.inline-reco {
margin-top: 16rpx;
padding-top: 14rpx;
border-top: 1rpx solid rgba(124, 58, 237, 0.12);
}
.inline-reco-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10rpx;
}
.inline-reco-label {
font-size: 22rpx;
font-weight: 700;
color: #7c3aed;
}
.inline-reco-hint {
font-size: 22rpx;
color: #9ca3af;
}
.inline-reco-card {
display: flex;
align-items: stretch;
border-radius: 14rpx;
overflow: hidden;
background: #faf5ff;
border: 1rpx solid rgba(124, 58, 237, 0.15);
}
.inline-reco-cover {
width: 148rpx;
min-width: 148rpx;
height: 100rpx;
background: linear-gradient(135deg, #a78bfa 0%, #7c3aed 100%);
}
.inline-reco-body {
flex: 1;
min-width: 0;
padding: 10rpx 12rpx;
display: flex;
flex-direction: column;
justify-content: center;
}
.inline-reco-title {
font-size: 24rpx;
font-weight: 600;
color: #1f1b4d;
line-height: 1.35;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.inline-reco-meta {
margin-top: 6rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.inline-reco-tag {
font-size: 18rpx;
color: #7c3aed;
background: #ede9fe;
padding: 2rpx 10rpx;
border-radius: 100rpx;
}
.inline-reco-time {
font-size: 18rpx;
color: #9ca3af;
}
.bubble-meta {
margin-top: 8rpx;
font-size: 20rpx;
@@ -398,62 +303,160 @@ page {
font-weight: 600;
flex: 1;
padding-right: 12rpx;
line-height: 1.4;
display: flex;
align-items: center;
}
.report-cta-btn {
padding: 10rpx 22rpx;
flex-shrink: 0;
min-height: 56rpx;
padding: 12rpx 22rpx;
box-sizing: border-box;
border-radius: 100rpx;
background: #78350F;
color: #FEF3C7;
font-size: 24rpx;
}
.quick-bottom-scroll {
position: fixed;
left: 0;
right: 0;
bottom: calc(var(--tabbar-height, 132rpx) + env(safe-area-inset-bottom) + 84rpx);
padding: 0 16rpx 4rpx;
z-index: 21;
}
.quick-bottom-list {
white-space: nowrap;
}
.quick-bottom-item {
display: inline-block;
margin-right: 10rpx;
padding: 10rpx 16rpx;
border-radius: 999rpx;
background: #F5F3FF;
color: #5B21B6;
font-size: 22rpx;
border: 1rpx solid #DDD6FE;
}
/* ==== 输入区:紧贴自定义 tabBar 上方 ==== */
.input-bar {
position: fixed;
left: 0;
right: 0;
bottom: calc(var(--tabbar-height, 132rpx) + env(safe-area-inset-bottom));
background: #ffffff;
padding: 12rpx 16rpx 12rpx 20rpx;
font-weight: 600;
line-height: 1.25;
display: flex;
align-items: center;
gap: 10rpx;
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.04);
border-top: 1rpx solid #F3F4F6;
z-index: 20;
justify-content: center;
text-align: center;
}
/* 底栏与输入区背后的不透明白层(低于自定义 TabBar 9999高于页面滚动内容 */
.ai-bottom-solid {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 9990;
height: calc(var(--tabbar-height, 168rpx) + env(safe-area-inset-bottom) + 260rpx);
background-color: #ffffff;
pointer-events: none;
}
/* ==== 底部 composer快捷语 + 输入(整块贴在 Tab 上沿)==== */
.composer-wrap {
position: fixed;
left: 0;
right: 0;
bottom: calc(var(--tabbar-height, 168rpx) + env(safe-area-inset-bottom));
background-color: #ffffff;
z-index: 9998;
box-shadow: 0 -6rpx 24rpx rgba(15, 23, 42, 0.08);
border-top: 1rpx solid #e5e7eb;
}
/* 横滑快捷语:右侧极淡渐变,暗示可横滑(无文字) */
.quick-chips-outer {
position: relative;
width: 100%;
background-color: #ffffff;
}
.quick-chips-edge-fade {
pointer-events: none;
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 56rpx;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.75) 45%,
#ffffff 100%
);
}
.quick-chips-scroll {
width: 100%;
white-space: nowrap;
height: 72rpx;
padding: 10rpx 0 6rpx;
box-sizing: border-box;
background-color: #ffffff;
}
.quick-chips-row {
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 12rpx;
padding: 0 20rpx;
height: 56rpx;
}
.quick-chip {
flex-shrink: 0;
max-width: 520rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 24rpx;
color: #374151;
line-height: 1.3;
padding: 12rpx 24rpx;
background: #f3f4f6;
border-radius: 999rpx;
border: 1rpx solid #e5e7eb;
}
.quick-chip:active {
opacity: 0.95;
background: #ffffff;
}
.input-bar {
position: relative;
left: auto;
right: auto;
bottom: auto;
background-color: #ffffff;
padding: 8rpx 20rpx 14rpx;
display: flex;
align-items: center;
gap: 12rpx;
border-top: 1rpx solid #f3f4f6;
box-shadow: none;
}
.resume-entry {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 64rpx;
padding: 4rpx 8rpx 0;
box-sizing: border-box;
}
.resume-entry--press {
opacity: 0.85;
}
.resume-entry-icon {
font-size: 32rpx;
line-height: 1;
}
.resume-entry-txt {
font-size: 20rpx;
color: #6b7280;
margin-top: 2rpx;
line-height: 1.2;
}
.input {
flex: 1;
min-width: 0;
background: #F3F4F6;
border-radius: 100rpx;
padding: 12rpx 20rpx;
font-size: 27rpx;
min-height: 62rpx;
padding: 16rpx 28rpx;
font-size: 28rpx;
min-height: 72rpx;
color: #1F1B4D;
}
@@ -462,46 +465,47 @@ page {
font-size: 26rpx;
}
/* 发送:独立按钮块(非原生 button样式可控 */
.send-btn {
flex-shrink: 0;
display: flex;
/* 发送:仅图标,小圆钮 */
.send-icon-btn {
width: 72rpx !important;
height: 72rpx !important;
min-width: 72rpx !important;
padding: 0 !important;
margin: 0 !important;
border-radius: 50% !important;
background: #e5e7eb !important;
display: flex !important;
align-items: center;
justify-content: center;
padding: 0 28rpx;
min-width: 108rpx;
height: 64rpx;
border-radius: 16rpx;
margin-left: 16rpx;
margin-right: 4rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #f3f4f6 0%, #e5e7eb 100%);
border: 2rpx solid #d1d5db;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.06);
line-height: 1;
}
.send-btn__label {
font-size: 28rpx;
font-weight: 600;
color: #6b7280;
.send-icon-btn::after {
border: none !important;
}
.send-btn--active {
background: linear-gradient(180deg, #9333ea 0%, #7c3aed 100%);
border-color: #6d28d9;
box-shadow: 0 4rpx 14rpx rgba(124, 58, 237, 0.35);
.send-icon-btn[disabled] {
opacity: 0.55;
}
.send-btn--active .send-btn__label {
color: #ffffff;
.send-icon-btn--active {
background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%) !important;
}
.send-btn--disabled {
opacity: 0.5;
.send-icon-btn--active .send-icon-plane {
filter: brightness(0) invert(1);
}
.send-btn--press {
transform: scale(0.97);
.send-icon-btn--hover {
opacity: 0.92;
}
.send-icon-plane {
width: 36rpx;
height: 36rpx;
background-repeat: no-repeat;
background-position: center;
background-size: 36rpx 36rpx;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'><line x1='22' y1='2' x2='11' y2='13'/><polygon points='22 2 15 22 11 13 2 9 22 2' fill='%236b7280' stroke='%236b7280'/></svg>");
}

View File

@@ -1,6 +1,8 @@
// 神仙 AI · 深度画像报告页
const { request } = require('../../utils/request.js')
const analytics = require('../../utils/analytics.js')
const { wxPay } = require('../../utils/payment.js')
const { getEnterpriseIdForApiPayload } = require('../../utils/enterpriseContext.js')
Page({
data: {
@@ -12,11 +14,14 @@ Page({
},
onLoad(options) {
const cid = parseInt(options && options.cid, 10) || 0
const rid = parseInt(options && options.rid, 10) || 0
this.setData({ cid, reportId: rid })
analytics.track('page_view', { pagePath: 'pages/ai-chat/report' })
this.initReport()
const { ensureRuntimeThenGate } = require('../../utils/miniprogramAuditGate.js')
ensureRuntimeThenGate(() => {
const cid = parseInt(options && options.cid, 10) || 0
const rid = parseInt(options && options.rid, 10) || 0
this.setData({ cid, reportId: rid })
analytics.track('page_view', { pagePath: 'pages/ai-chat/report' })
this.initReport()
})
},
onUnload() {
@@ -77,76 +82,66 @@ Page({
this.loadReport(this.data.reportId)
},
/** 付费解锁:先尝试 dev 模式,生产环境走 payment/create 下单 */
/** 付费解锁:与全站 payment.js 一致orderId + openId + 支付后轮询查单触发 markPaid */
onTapPay() {
if (this.data.paying) return
this.setData({ paying: true })
analytics.track('ai_report_pay_tap', { reportId: this.data.reportId })
const report = this.data.report || {}
const orderSn = report.orderSn || ''
const orderSn = String(report.orderSn || '').trim()
if (!orderSn) {
wx.showToast({ title: '订单初始化中,请稍后再试', icon: 'none' })
return
}
// 1) 尝试调用现有 /api/payment/create 下单
request({
url: '/api/payment/create',
method: 'POST',
data: {
productType: 'ai_deep_report',
productId: this.data.reportId,
amount: report.priceFen || 990,
orderSn
},
success: (res) => {
const body = (res && res.data) || {}
if (body.code === 200 && body.data && body.data.timeStamp) {
const p = body.data
wx.requestPayment({
timeStamp: p.timeStamp,
nonceStr: p.nonceStr,
package: p.package,
signType: p.signType || 'RSA',
paySign: p.paySign,
success: () => {
wx.showToast({ title: '支付成功', icon: 'success' })
analytics.track('ai_report_pay_success', { reportId: this.data.reportId })
setTimeout(() => this.loadReport(this.data.reportId), 1500)
this.setData({ paying: false })
},
fail: () => {
wx.showToast({ title: '已取消支付', icon: 'none' })
this.setData({ paying: false })
}
})
} else {
// 2) 下单失败(比如接口不支持该 productType→ 兜底 dev markPaid仅超管可用
this._fallbackDevMarkPaid()
}
},
fail: () => this._fallbackDevMarkPaid()
})
},
_fallbackDevMarkPaid() {
// 仅在配置了 dev 模式或超管账号时生效
request({
url: `/api/ai/report/${this.data.reportId}/mark-paid-dev`,
method: 'POST',
success: (res) => {
const body = (res && res.data) || {}
if (body.code === 200) {
wx.showToast({ title: '解锁成功', icon: 'success' })
this.setData({ paying: false })
setTimeout(() => this.loadReport(this.data.reportId), 800)
} else {
wx.showToast({ title: body.message || '支付接口未开通', icon: 'none' })
this.setData({ paying: false })
}
},
fail: () => {
wx.showToast({ title: '支付通道异常,稍后再试', icon: 'none' })
this.setData({ paying: false })
const runPay = () => {
const appInst = getApp()
const oid =
(appInst && appInst.globalData && appInst.globalData.openId) ||
(() => {
try {
const u = wx.getStorageSync('userInfo')
return u ? (u.openid || u.openId || '') : ''
} catch (e) {
return ''
}
})()
if (!oid) {
wx.showToast({ title: '缺少微信支付授权,请重新登录后再试', icon: 'none' })
return
}
})
this.setData({ paying: true })
analytics.track('ai_report_pay_tap', { reportId: this.data.reportId })
const eidRaw = getEnterpriseIdForApiPayload()
const enterpriseId = eidRaw != null && Number(eidRaw) > 0 ? Number(eidRaw) : 0
const amountFen = Number(report.priceFen) > 0 ? Number(report.priceFen) : 990
wxPay({
orderId: orderSn,
amount: amountFen,
description: '神仙 AI 深度画像报告',
productType: 'ai_deep_report',
enterpriseId,
success: () => {
this.setData({ paying: false })
analytics.track('ai_report_pay_success', { reportId: this.data.reportId })
setTimeout(() => this.loadReport(this.data.reportId), 800)
},
fail: () => {
this.setData({ paying: false })
}
})
}
const app = getApp()
if (app && typeof app.ensureLogin === 'function') {
app.ensureLogin().then((ok) => {
if (ok) runPay()
else wx.showToast({ title: '请先登录后再购买', icon: 'none' })
})
return
}
runPay()
},
onRetry() {

View File

@@ -15,7 +15,7 @@
<view class="unlock-row">
<view class="price">¥{{report.priceYuan || '9.9'}}</view>
<button class="unlock-btn" bindtap="onTapPay" disabled="{{paying}}">
{{paying ? '处理中…' : '立即解锁'}}
{{paying ? '处理中…' : '深度详细分析报告'}}
</button>
</view>
</view>
@@ -40,7 +40,7 @@
<!-- 已完成 -->
<view class="section" wx:elif="{{report.status === 'done'}}">
<view class="report-body">
<text selectable="true" user-select="true">{{report.content}}</text>
<text user-select="true">{{report.content}}</text>
</view>
<view class="done-actions">
<button class="share-btn" open-type="share">📤 分享给好友</button>

View File

@@ -56,15 +56,21 @@ page { background: #F5F3FF; }
.price { font-size: 42rpx; font-weight: 700; color: #7c3aed; }
.unlock-btn {
flex: 1;
max-width: 380rpx;
max-width: 400rpx;
min-height: 82rpx;
padding: 12rpx 20rpx;
box-sizing: border-box;
background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
color: #fff;
border: none;
border-radius: 100rpx;
height: 82rpx;
line-height: 82rpx;
font-size: 28rpx;
font-size: 26rpx;
font-weight: 600;
line-height: 1.35;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.unlock-btn::after { border: none; }

View File

@@ -1,9 +1,10 @@
// pages/ai-test/index.js — AI 测评方式选择
const app = getApp()
const { isAuditHideAiMode } = require('../../utils/miniprogramAuditGate.js')
Page({
data: {
permFace: true
permFace: true,
showAiChat: true
},
onLoad(options) {
@@ -15,26 +16,42 @@ Page({
} catch (e) {}
app.ensureLogin().catch(() => {})
this._syncPerms()
try { require('../../utils/analytics').track('page_view', { pagePath: 'pages/ai-test/index' }) } catch (e) {}
try {
require('../../utils/analytics').track('page_view', { pagePath: 'pages/ai-test/index' })
} catch (e) {}
},
onShow() {
this._syncPerms()
const app = getApp()
if (app.getRuntimeConfig) {
app.getRuntimeConfig().finally(() => this._syncPerms())
} else {
this._syncPerms()
}
},
_syncPerms() {
const p = app.globalData.enterprisePermissions
const permFace = !p || p.face !== false
this.setData({ permFace })
const showAiChat = !isAuditHideAiMode(getApp().globalData)
this.setData({ permFace, showAiChat })
},
goAiChat() {
try { require('../../utils/analytics').track('tap_ai_test_entry', { target: 'ai_chat' }) } catch (e) {}
if (isAuditHideAiMode(getApp().globalData)) {
wx.showToast({ title: '功能升级中', icon: 'none' })
return
}
try {
require('../../utils/analytics').track('tap_ai_test_entry', { target: 'ai_chat' })
} catch (e) {}
wx.navigateTo({ url: '/pages/ai-chat/index?src=ai_test' })
},
goFaceCamera() {
try { require('../../utils/analytics').track('tap_ai_test_entry', { target: 'face_camera' }) } catch (e) {}
try {
require('../../utils/analytics').track('tap_ai_test_entry', { target: 'face_camera' })
} catch (e) {}
wx.switchTab({
url: '/pages/index/camera',
fail: () => {
@@ -46,7 +63,7 @@ Page({
onShareAppMessage() {
const { getSharePath } = require('../../utils/share')
return {
title: 'AI 性格测试 · 对话与拍照解读',
title: 'AI 性格测试 · 对话与拍照',
path: getSharePath('/pages/ai-test/index')
}
},
@@ -54,7 +71,7 @@ Page({
onShareTimeline() {
const { buildShareQuery } = require('../../utils/share')
return {
title: 'AI 性格测试 · 对话与拍照解读',
title: 'AI 性格测试 · 对话与拍照',
query: buildShareQuery()
}
}

View File

@@ -1,17 +1,16 @@
<!-- AI 测评聚合:对话 / 拍照 -->
<view class="container">
<view class="card intro-card">
<text class="intro-title">选择 AI 测评方式</text>
<text class="intro-desc">与神仙 AI 对话获得解读;或通过拍照完成面相与性格分析(与首页流程一致)</text>
<text class="intro-desc">与神仙 AI 对话获得解读;或通过拍照完成面相与性格分析</text>
</view>
<view class="card entry-card" bindtap="goAiChat">
<view class="card entry-card" bindtap="goAiChat" wx:if="{{showAiChat}}">
<view class="entry-icon-wrap chat">
<text class="entry-emoji">💬</text>
</view>
<view class="entry-content">
<text class="entry-name">神仙 AI 对话</text>
<text class="entry-brief">结合测评画像 · 问答式深度解读与建议</text>
<text class="entry-brief">结合测评画像 · 问答式深度解读</text>
</view>
<text class="entry-arrow">→</text>
</view>
@@ -22,7 +21,7 @@
</view>
<view class="entry-content">
<text class="entry-name">AI 拍照分析</text>
<text class="entry-brief">上传正面与侧面照 · 面相 / 骨相与性格报告</text>
<text class="entry-brief">上传照 · 面相 / 骨相与性格报告</text>
</view>
<text class="entry-arrow">→</text>
</view>

View File

@@ -1,4 +1,3 @@
/* pages/ai-test/index.wxss - 与 test-select 列表风格一致 */
.container {
min-height: 100vh;
background: linear-gradient(180deg, #fff5f5 0%, #f5f5f5 50%, #ffffff 100%);
@@ -38,13 +37,12 @@
display: flex;
align-items: center;
padding: 28rpx 32rpx;
transition: opacity 0.2s;
border-radius: 16rpx;
border: 2rpx solid transparent;
}
.entry-card:active {
opacity: 0.9;
opacity: 0.92;
background: linear-gradient(135deg, rgba(230, 57, 70, 0.04) 0%, rgba(255, 107, 157, 0.04) 100%);
border-color: rgba(230, 57, 70, 0.15);
}

View File

@@ -2,6 +2,7 @@
"navigationBarTitleText": "拍摄",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black",
"disableScroll": true,
"usingComponents": {
"custom-tab-bar": "/custom-tab-bar/index"
}

View File

@@ -1,27 +1,36 @@
/* pages/index/camera.wxss - 一屏内展示,为底部自定义 tabBar(含中间浮起圆钮)预留空间 */
/* pages/index/camera.wxss - 严格一屏tabBar 为 fixedtabbar-pad 只作内边距,不可再叠 100vh 子容器 */
page {
height: 100%;
background-color: #fff;
}
.camera-page-root {
min-height: 100vh;
min-height: 100dvh;
box-sizing: border-box;
position: relative;
height: 100vh;
height: 100dvh;
max-height: 100vh;
max-height: 100dvh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.container {
width: 100%;
height: 100vh;
height: 100dvh;
flex: 1;
min-height: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
background-color: #fff;
/* 预留底部空间,避免与自定义 tabBar约 100rpx 高 + 中间圆钮上浮约 56rpx重叠 */
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
}
.progress-section {
flex-shrink: 0;
margin: 16rpx 24rpx 0;
padding: 20rpx 32rpx 16rpx;
margin: 8rpx 20rpx 0;
padding: 12rpx 20rpx 10rpx;
border-radius: 10rpx;
background: linear-gradient(to right, #fff5f5, #ffe5e8);
}
@@ -30,24 +39,24 @@
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10rpx;
margin-bottom: 6rpx;
}
.step-text {
font-size: 26rpx;
font-size: 24rpx;
font-weight: 600;
color: #e63946;
}
.photo-count {
font-size: 22rpx;
font-size: 20rpx;
color: #999;
}
.progress-bars {
display: flex;
gap: 12rpx;
margin-bottom: 12rpx;
gap: 10rpx;
margin-bottom: 8rpx;
}
.progress-bar {
@@ -66,8 +75,8 @@
.instruction-card {
background: rgba(255, 255, 255, 0.9);
border-radius: 12rpx;
padding: 14rpx 18rpx;
border-radius: 10rpx;
padding: 10rpx 14rpx;
border: 1rpx solid rgba(230, 57, 70, 0.1);
}
@@ -78,13 +87,13 @@
}
.step-number {
width: 40rpx;
height: 40rpx;
width: 36rpx;
height: 36rpx;
flex-shrink: 0;
border-radius: 50%;
background-color: #e63946;
color: #fff;
font-size: 24rpx;
font-size: 22rpx;
font-weight: bold;
display: flex;
align-items: center;
@@ -100,35 +109,34 @@
}
.angle-text {
font-size: 26rpx;
font-size: 24rpx;
font-weight: 600;
color: #c41d2a;
}
.tip-text {
font-size: 22rpx;
font-size: 20rpx;
color: #666;
line-height: 1.3;
line-height: 1.25;
}
.camera-container {
flex: 1;
min-height: 0;
padding: 16rpx 24rpx;
padding: 8rpx 20rpx;
box-sizing: border-box;
display: flex;
align-items: center;
align-items: stretch;
justify-content: center;
}
.camera-preview {
width: 100%;
max-width: 100%;
max-height: 100%;
aspect-ratio: 1;
border-radius: 32rpx;
height: 100%;
min-height: 0;
border-radius: 24rpx;
overflow: hidden;
border: 6rpx solid #e5e5e5;
border: 4rpx solid #e5e5e5;
background-color: #000;
position: relative;
}
@@ -186,7 +194,7 @@
.button-container {
flex-shrink: 0;
padding: 16rpx 24rpx 0;
padding: 10rpx 20rpx 8rpx;
display: flex;
justify-content: center;
align-items: center;
@@ -197,7 +205,7 @@
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
gap: 12rpx;
}
.capture-actions .capture-button {
@@ -206,8 +214,8 @@
.album-button {
width: 100%;
padding: 20rpx;
border-radius: 50rpx;
padding: 16rpx;
border-radius: 48rpx;
text-align: center;
background: #fff;
border: 2rpx solid #e63946;
@@ -219,7 +227,7 @@
}
.album-button-text {
font-size: 28rpx;
font-size: 26rpx;
font-weight: 600;
color: #e63946;
letter-spacing: 1rpx;
@@ -228,16 +236,16 @@
.capture-button {
width: 100%;
max-width: 100%;
padding: 22rpx;
padding: 18rpx;
background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%);
border-radius: 50rpx;
border-radius: 48rpx;
text-align: center;
box-sizing: border-box;
box-shadow: 0 8rpx 24rpx rgba(230, 57, 70, 0.3);
}
.button-text {
font-size: 30rpx;
font-size: 28rpx;
font-weight: 700;
color: #fff;
letter-spacing: 1rpx;
@@ -245,14 +253,14 @@
.action-buttons {
display: flex;
gap: 20rpx;
gap: 16rpx;
width: 100%;
}
.action-button {
flex: 1;
padding: 28rpx 24rpx;
border-radius: 50rpx;
padding: 20rpx 20rpx;
border-radius: 48rpx;
text-align: center;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
@@ -279,7 +287,7 @@
}
.action-button-text {
font-size: 28rpx;
font-size: 26rpx;
font-weight: 700;
letter-spacing: 1rpx;
}
@@ -294,9 +302,11 @@
/* 审核模式引导(替代白屏) */
.review-mode-panel {
flex: 1;
min-height: 0;
justify-content: center;
align-items: center;
padding: 48rpx 40rpx;
padding: 32rpx 32rpx;
background: linear-gradient(180deg, #f8fafc 0%, #fff 40%);
}

View File

@@ -8,7 +8,7 @@ Page({
navbarHeight: 88,
showEnterpriseEntry: false,
siteTitle: '神仙团队性格测试',
startButtonText: '开始性格测试',
startButtonText: '30秒测出你的性格',
aiAnalysisText: '分析',
reviewMode: true,
permFace: true
@@ -33,7 +33,7 @@ Page({
navbarHeight: navbarHeightRpx,
showEnterpriseEntry: userInfo.hasEnterprise === true,
siteTitle: rm0 ? (gd.siteTitle || '神仙团队性格测试').replace(/AI/gi, '') : (gd.siteTitle || '神仙团队性格测试'),
startButtonText: rm0 ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonText) || '拍摄'),
startButtonText: rm0 ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonText) || '30秒测出你的性格'),
aiAnalysisText: rm0 ? '分析' : ((gd.textConfig && gd.textConfig.aiAnalysisText) || '分析'),
reviewMode: rm0,
permFace: pf
@@ -53,7 +53,7 @@ Page({
if (cfg.textConfig) {
getApp().globalData.textConfig = cfg.textConfig
this.setData({
startButtonText: rm ? '开始性格测试' : (cfg.textConfig.startButtonText || '拍摄'),
startButtonText: rm ? '开始性格测试' : (cfg.textConfig.startButtonText || '30秒测出你的性格'),
aiAnalysisText: rm ? '分析' : (cfg.textConfig.aiAnalysisText || '分析')
})
}
@@ -120,7 +120,7 @@ Page({
const ep3 = g.enterprisePermissions
this.setData({
siteTitle: rm ? (g.siteTitle || '神仙团队性格测试').replace(/AI/gi, '') : (g.siteTitle || '神仙团队性格测试'),
startButtonText: (rm || (ep3 && ep3.face === false)) ? '开始性格测试' : ((g.textConfig && g.textConfig.startButtonText) || '拍摄'),
startButtonText: (rm || (ep3 && ep3.face === false)) ? '开始性格测试' : ((g.textConfig && g.textConfig.startButtonText) || '30秒测出你的性格'),
aiAnalysisText: rm ? '分析' : ((g.textConfig && g.textConfig.aiAnalysisText) || '分析'),
reviewMode: rm,
permFace: !ep3 || ep3.face !== false
@@ -145,11 +145,20 @@ Page({
}
},
// 主按钮统一进入「详细性格测试」列表(问卷 / AI 对话 / 拍照等分项选择),不再直跳拍摄 Tab
// 主按钮:面相/拍摄链路直进「拍摄Tab(与底栏拍摄一致);审核模式或企业关闭面相时进测试列表
startCamera() {
try { getApp().globalData.appScope = 'personal' } catch (e) {}
try { require('../../utils/analytics').track('tap_home_test_select', {}) } catch (e) {}
wx.navigateTo({ url: '/pages/test-select/index' })
const g = getApp().globalData
const rm = !!(g.reviewMode || g.maintenanceMode)
const ep = g.enterprisePermissions
const faceOff = ep && ep.face === false
if (rm || faceOff) {
try { require('../../utils/analytics').track('tap_home_test_select', {}) } catch (e) {}
wx.navigateTo({ url: '/pages/test-select/index' })
return
}
try { require('../../utils/analytics').track('tap_home_camera_tab', {}) } catch (e) {}
wx.switchTab({ url: '/pages/index/camera' })
},
// 上传照片(个人版入口:强制本次链路为个人定价)

View File

@@ -74,7 +74,7 @@
</view>
<view class="start-button" bindtap="startCamera">
<text class="button-text">{{startButtonText || '开始性格测试'}}</text>
<text class="button-text">{{startButtonText || '30秒测出你的性格'}}</text>
</view>
<!-- 自定义底部导航 -->

View File

@@ -9,8 +9,12 @@
display: flex;
flex-direction: column;
box-sizing: border-box;
/* 底栏 + 中间浮钮留白,避免与主按钮叠在一起 */
padding-bottom: calc(168rpx + env(safe-area-inset-bottom) + 78rpx);
/* 底栏留白由 .tabbar-pad + 全局 --tabbar-pad 统一控制 */
}
/* 首页:中间拍摄图整体上移,减轻与「测试流程」挤压;底栏略加垫避免中间拍摄钮挡主按钮 */
.container.tabbar-pad {
padding-bottom: calc(var(--tabbar-pad) + 32rpx) !important;
}
/* 背景装饰圆形(与企业版一致) */
@@ -98,17 +102,19 @@
.top-image-section {
width: 100%;
padding: 15rpx 40rpx 20rpx;
padding: 8rpx 40rpx 10rpx;
flex-shrink: 0;
box-sizing: border-box;
position: relative;
z-index: 1;
margin-top: 0;
margin-top: -20rpx;
}
.image-container {
position: relative;
width: 100%;
transform: translateY(-26rpx);
margin-bottom: -26rpx;
}
.image-wrapper {
@@ -163,7 +169,7 @@
}
.process-section {
padding: 15rpx 40rpx 20rpx;
padding: 8rpx 40rpx 20rpx;
flex-shrink: 0;
position: relative;
z-index: 1;
@@ -229,7 +235,7 @@
}
.start-button {
margin: 12rpx 40rpx 24rpx;
margin: 16rpx 40rpx 0;
background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%);
border-radius: 50rpx;
padding: 24rpx;

View File

@@ -1,7 +1,13 @@
// pages/index/result.js - AI分析结果页按旧版模板重构
const app = getApp()
const payment = require('../../utils/payment')
const { hasPhone, bindPhoneByCode, isProfileComplete } = require('../../utils/phoneAuth.js')
const {
hasPhone,
bindPhoneByCode,
isProfileComplete,
needsResultProfileGate,
navigateToCompleteProfileAfterPhoneIfNeeded
} = require('../../utils/phoneAuth.js')
const { mbtiDescriptions } = require('../../utils/descriptions')
const { triggerTestResultCompleted } = require('../../utils/pushHook')
@@ -100,6 +106,10 @@ Page({
// 是否已在本地拥有手机号(决定是否还需要弹出微信手机号授权)
hasPhone: false,
isProfileComplete: false,
/** fs=1 等分享落地:不挡手机登录门禁 */
fromShare: false,
/** 非分享且本会话未 getPhoneNumber 成功:遮挡完整报告 */
phoneLoginGate: false,
/** 企业后台关闭 SBTI 时不展示人脸报告中的 SBTI与 enterprisePermissions.sbti 一致) */
showSbtiFace: true,
analyzingTitle: '正在分析中',
@@ -145,7 +155,8 @@ Page({
// 带记录 id只读库展示禁止落入 startAnalysis 造成「二次分析」
if (idStr && (faceType === 'ai' || faceType === 'face')) {
this.setData({ testResultId: idStr })
const fromShareFs = options && (String(options.fs) === '1' || options.from === 'share')
this.setData({ testResultId: idStr, fromShare: !!fromShareFs })
this.loadFaceRecordById(idStr, st, faceType)
return
}
@@ -234,6 +245,15 @@ Page({
})
},
_syncPhoneLoginGate() {
if (this.data.hasError) {
this.setData({ phoneLoginGate: false })
return
}
const gate = needsResultProfileGate(!!this.data.fromShare)
this.setData({ phoneLoginGate: gate })
},
onShow() {
const ep = app.globalData.enterprisePermissions
const showSbtiFace = !ep || ep.sbti !== false
@@ -242,6 +262,7 @@ Page({
isProfileComplete: isProfileComplete(),
showSbtiFace
})
if (this.data.showResult) this._syncPhoneLoginGate()
const tc = app.globalData.textConfig
if (tc) {
this.setData({
@@ -293,6 +314,7 @@ Page({
wx.request({
url: `${app.globalData.apiBase}/api/analyze`,
method: 'POST',
timeout: 120000,
header: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${wx.getStorageSync('token') || ''}`
@@ -401,6 +423,7 @@ Page({
}
this.setData(updates)
this._syncPhoneLoginGate()
// 优先使用后端 /api/analyze 返回的价格信息,避免二次请求
if (apiData._payment) {
@@ -558,7 +581,29 @@ Page({
bindPhoneByCode(code)
.then(() => {
this.setData({ hasPhone: true, isProfileComplete: isProfileComplete() })
this._syncPhoneLoginGate()
wx.showToast({ title: '已绑定手机号', icon: 'success' })
navigateToCompleteProfileAfterPhoneIfNeeded()
})
.catch(() => {})
},
/** 结果页主流程:微信手机号快捷登录后解锁完整报告区 */
onPhoneLoginForFaceResult(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: true, isProfileComplete: isProfileComplete() })
this._syncPhoneLoginGate()
navigateToCompleteProfileAfterPhoneIfNeeded()
})
.catch(() => {})
},
@@ -585,6 +630,7 @@ Page({
bindPhoneByCode(code)
.then(() => {
this.setData({ hasPhone: true })
this._syncPhoneLoginGate()
this.unlockFullReport()
})
.catch(() => {
@@ -649,7 +695,8 @@ Page({
isAnalyzing: false,
showResult: true,
hasError: true,
errorMessage: message || '分析失败,请稍后重试'
errorMessage: message || '分析失败,请稍后重试',
phoneLoginGate: false
})
},

View File

@@ -44,8 +44,76 @@
<text class="status-text">{{hasError ? '分析出现异常' : '分析完成,以下是您的个性化报告'}}</text>
</view>
<!-- 错误内容 -->
<view class="error-content" wx:if="{{hasError}}">
<text class="error-title">错误信息</text>
<text class="error-message">{{errorMessage}}</text>
<view class="retry-btn" bindtap="retake">
<text class="retry-text">重新分析</text>
</view>
</view>
<!-- 资料未齐:摘要 + 授权手机 / 完善资料(分享落地 fs=1 不挡) -->
<block wx:elif="{{phoneLoginGate}}">
<view class="report-gate-wrap">
<view class="card personality-card">
<text class="card-title">性格类型分析</text>
<view class="personality-type">
<text class="type-code">{{result.mbti}}</text>
<text class="type-name">{{result.title}}</text>
<text class="type-desc">{{result.summary}}</text>
</view>
<view class="type-details type-details--wrap">
<view class="type-box pdp-box">
<text class="type-emoji" wx:if="{{result.pdpEmoji}}">{{result.pdpEmoji}}</text>
<text class="type-label">PDP主性格</text>
<text class="type-value">{{result.pdp || '--'}}</text>
<text class="type-sub" wx:if="{{result.pdpAux}}">辅助: {{result.pdpAux}}</text>
</view>
<view class="type-box disc-box">
<text class="type-emoji type-emoji--disc">◎</text>
<text class="type-label">DISC类型</text>
<text class="type-value">{{result.disc || '--'}}型</text>
<text class="type-sub" wx:if="{{result.discAux}}">辅助: {{result.discAux}}</text>
</view>
<view
class="type-box sbti-box"
wx:if="{{showSbtiFace && (result.sbtiCode || result.sbtiCn)}}"
>
<text class="type-emoji">✨</text>
<text class="type-label">SBTI解读</text>
<text class="type-value">{{result.sbtiCode}}{{result.sbtiCn}}</text>
<text class="type-desc-small">{{result.sbtiMainTypeDesc}}</text>
</view>
</view>
</view>
</view>
<view class="report-gate-cta-wrap phone-login-gate-cta">
<view class="report-gate-panel">
<text class="paywall-fake-title">查看完整报告</text>
<text class="paywall-tip">授权手机号并设置头像、昵称后即可查看完整报告(与下方按钮为同一条流程)。</text>
<button
wx:if="{{!hasPhone}}"
class="paywall-btn paywall-btn-inline paywall-btn--compact"
open-type="getPhoneNumber"
bindgetphonenumber="onPhoneLoginForFaceResult"
>
<text class="paywall-btn-main">登录</text>
</button>
<button
wx:else
class="paywall-btn paywall-btn-inline paywall-btn--compact"
bindtap="goToCompleteProfile"
>
<text class="paywall-btn-main">完善资料 · 查看完整报告</text>
</button>
</view>
</view>
</block>
<block wx:else>
<!-- 支付成功后:引导手机号 → 完善资料(不挡报告阅读) -->
<view class="post-pay-guide" wx:if="{{!hasError && payInfo.isPaid && (!hasPhone || !isProfileComplete)}}">
<view class="post-pay-guide" wx:if="{{payInfo.isPaid && (!hasPhone || !isProfileComplete)}}">
<text class="post-pay-guide-title">再完成两步,报告更好归属到您的账号</text>
<text class="post-pay-guide-desc">① 授权手机号 ② 完善头像与昵称(「我的」- 个人资料)</text>
<view class="post-pay-guide-actions">
@@ -59,17 +127,8 @@
</view>
</view>
<!-- 错误内容 -->
<view class="error-content" wx:if="{{hasError}}">
<text class="error-title">错误信息</text>
<text class="error-message">{{errorMessage}}</text>
<view class="retry-btn" bindtap="retake">
<text class="retry-text">重新分析</text>
</view>
</view>
<!-- 报告预览区:未解锁时整区加盖高遮罩,避免概述/盖洛普等全文外露 -->
<view class="report-gate-wrap" wx:if="{{!hasError}}">
<view class="report-gate-wrap">
<view
class="card personality-card {{(payInfo.requiresPayment && !payInfo.isPaid) ? 'personality-card--locked' : ''}}"
>
@@ -184,7 +243,7 @@
</view>
<!-- ========== 详细分析区(解锁后可见) ========== -->
<block wx:if="{{!hasError && (!payInfo.requiresPayment || payInfo.isPaid)}}">
<block wx:if="{{!payInfo.requiresPayment || payInfo.isPaid}}">
<!-- 生活场景深度分析AI 或本地兜底) -->
<view class="card scene-detail-card" wx:if="{{result.careerDevelopment}}">
@@ -370,10 +429,11 @@
</view>
</view>
</block>
</view>
<!-- 固定底部操作栏:三按钮常驻 -->
<view class="result-action-bar" wx:if="{{!isAnalyzing && showResult && !hasError}}">
<view class="result-action-bar" wx:if="{{!isAnalyzing && showResult && !hasError && !phoneLoginGate}}">
<view class="result-action-bar__btn result-action-bar__btn--test" bindtap="goToQuestionnaireFromFace">
<text class="result-action-bar__ic">🧠</text>
<text class="result-action-bar__label">详细性格测试</text>

View File

@@ -2,8 +2,8 @@
.container {
min-height: 100vh;
background-color: #f5f5f5;
/* 底部固定操作栏占用 ~180rpx加 safe-area 余量 */
padding: 0 24rpx calc(200rpx + env(safe-area-inset-bottom));
/* 底部三按钮栏min-height+内边距+阴影,略多留空避免末段卡片被挡 */
padding: 0 24rpx calc(260rpx + env(safe-area-inset-bottom));
}
/* ========== 分析中模态框 ========== */

View File

@@ -1,6 +1,18 @@
// pages/match-job/index.js - 匹配工作(简历 + 性格 + AI 综合分析)
// pages/match-job/index.js - 匹配工作(与「我的」同源拉取性格画像 + 简历 + AI 综合分析,含面相
const app = getApp()
const { request } = require('../../utils/request')
const { getTypeOnly } = require('../../utils/resultFormat')
/** 与 profile 页 /api/test/recent 解析一致 */
function summaryFromRecentRecord(rec, testType) {
if (!rec) return ''
const meta = rec.resultMeta
if (meta && typeof meta === 'object') {
const t = getTypeOnly(meta, testType)
if (t) return t
}
return String(rec.resultText || '').trim()
}
const DEFAULT_MBTI_JOB = {
ISTJ: '适合财务、审计、行政、项目经理等结构化岗位。',
@@ -41,10 +53,12 @@ Page({
discType: '',
sbtiType: '',
faceType: '',
hasFaceReport: false,
mbtiJobHint: '',
pdpJobHint: '',
discJobHint: '',
hasAnyTest: false,
showRuleDetail: false,
resumeUrl: '',
resumeFileName: '',
uploading: false,
@@ -54,68 +68,67 @@ Page({
},
onLoad() {
try { require('../../utils/analytics').track('page_view', { path: 'pages/match-job/index' }) } catch (e) {}
app.ensureLogin && app.ensureLogin().then(() => {
this.loadPersonalityTags()
this.loadDefaultResume()
})
try {
require('../../utils/analytics').track('page_view', { path: 'pages/match-job/index' })
} catch (e) {}
app.ensureLogin &&
app.ensureLogin().then(() => {
this.loadPortraitFromRecent()
this.loadDefaultResume()
})
},
onShow() {
this.loadPersonalityTags()
this.loadPortraitFromRecent()
},
loadPersonalityTags() {
toggleRuleDetail() {
this.setData({ showRuleDetail: !this.data.showRuleDetail })
},
loadPortraitFromRecent() {
request({
url: '/api/test/recent',
url: '/api/test/recent?scope=all',
method: 'GET',
needAuth: true,
success: (res) => {
const d = (res && res.data && res.data.code === 200 && res.data.data) || {}
const mbti = this._pickType(d.mbti, 'mbti')
const pdp = this._pickType(d.pdp, 'pdp')
const disc = this._pickType(d.disc, 'disc')
const sbti = this._pickType(d.sbti, 'sbti')
const face = this._pickType(d.face, 'face')
const hasAnyTest = !!(mbti || pdp || disc || sbti || face)
const payload = res && res.data
if (!payload || payload.code !== 200 || !payload.data) return
const { records = {} } = payload.data
const r = records
const mbti = summaryFromRecentRecord(r.mbti, 'mbti')
const pdp = summaryFromRecentRecord(r.pdp, 'pdp')
const disc = summaryFromRecentRecord(r.disc, 'disc')
const sbti = summaryFromRecentRecord(r.sbti, 'sbti')
const faceText = r.ai ? String(r.ai.resultText || '').trim() : ''
const hasFaceReport = !!r.ai
const hasAnyTest = !!(r.mbti || r.sbti || r.disc || r.pdp || r.ai)
this.setData({
mbtiType: mbti,
pdpType: pdp,
discType: disc,
sbtiType: sbti,
faceType: face,
faceType: faceText,
hasFaceReport,
hasAnyTest,
mbtiJobHint: mbti ? (DEFAULT_MBTI_JOB[mbti] || '结合所属类别的典型岗位方向。') : '',
pdpJobHint: pdp ? (DEFAULT_PDP_JOB[pdp] || '') : '',
discJobHint: disc ? (DEFAULT_DISC_JOB[(disc || '').charAt(0).toUpperCase()] || '') : ''
mbtiJobHint: mbti ? DEFAULT_MBTI_JOB[mbti] || '结合所属类别的典型岗位方向。' : '',
pdpJobHint: pdp ? DEFAULT_PDP_JOB[pdp] || '' : '',
discJobHint: disc ? DEFAULT_DISC_JOB[(disc || '').charAt(0).toUpperCase()] || '' : ''
})
},
fail: () => {}
})
},
_pickType(rec, type) {
if (!rec) return ''
try {
const rd = typeof rec.resultData === 'string' ? JSON.parse(rec.resultData) : (rec.resultData || {})
if (type === 'mbti') return rd.mbtiType || rd.mbti || ''
if (type === 'pdp') return (rd.description && rd.description.type) || rd.dominantType || ''
if (type === 'disc') return rd.dominantType || rd.disc || ''
if (type === 'sbti') return rd.sbtiType || (rd.finalType && rd.finalType.code) || ''
if (type === 'face') return rd.mbti || rd.faceType || ''
} catch (e) {}
return ''
},
loadDefaultResume() {
// 取默认简历(若有)
request({
url: '/api/enterprise/resume-uploads?pageSize=1',
method: 'GET',
needAuth: true,
success: (res) => {
const list = (res && res.data && res.data.code === 200 && res.data.data && res.data.data.list) || []
const def = list.find((r) => r.isDefault) || list[0]
const list =
(res && res.data && res.data.code === 200 && res.data.data && res.data.data.list) || []
const def = list.find((x) => x.isDefault) || list[0]
if (def) {
this.setData({ resumeUrl: def.url || '', resumeFileName: def.fileName || '' })
}
@@ -124,8 +137,18 @@ Page({
})
},
goToTestSelect() {
wx.navigateTo({ url: '/pages/test-select/index' })
},
goCamera() {
wx.switchTab({ url: '/pages/index/camera' })
},
chooseResume() {
try { require('../../utils/analytics').track('tap_match_upload_resume', {}) } catch (e) {}
try {
require('../../utils/analytics').track('tap_match_upload_resume', {})
} catch (e) {}
wx.chooseMessageFile({
count: 1,
type: 'file',
@@ -143,7 +166,7 @@ Page({
_doUpload(filePath, fileName) {
this.setData({ uploading: true })
const apiBase = (app.globalData && app.globalData.apiBase) ? app.globalData.apiBase.replace(/\/$/, '') : ''
const apiBase = app.globalData && app.globalData.apiBase ? app.globalData.apiBase.replace(/\/$/, '') : ''
const token = (app.globalData && app.globalData.token) || wx.getStorageSync('token') || ''
wx.showLoading({ title: '上传中...', mask: true })
wx.uploadFile({
@@ -157,7 +180,6 @@ Page({
const data = JSON.parse(res.data)
if (data.code === 200 && data.data && data.data.url) {
const url = data.data.url
// 记录一条简历上传(方便历史页看)
request({
url: '/api/enterprise/resume-uploads',
method: 'POST',
@@ -186,7 +208,7 @@ Page({
previewResume() {
const url = this.data.resumeUrl
if (!url) return
const apiBase = (app.globalData && app.globalData.apiBase) ? app.globalData.apiBase.replace(/\/$/, '') : ''
const apiBase = app.globalData && app.globalData.apiBase ? app.globalData.apiBase.replace(/\/$/, '') : ''
const full = url.startsWith('http') ? url : apiBase + url
wx.downloadFile({
url: full,
@@ -203,7 +225,7 @@ Page({
runAnalyze() {
if (!this.data.hasAnyTest) {
wx.showToast({ title: '请先完成至少一项性格测评', icon: 'none' })
wx.showToast({ title: '请先完成问卷或面相拍摄', icon: 'none' })
return
}
if (!this.data.resumeUrl) {
@@ -211,13 +233,21 @@ Page({
return
}
this.setData({ analyzing: true })
try { require('../../utils/analytics').track('tap_match_analyze', { mbti: this.data.mbtiType, pdp: this.data.pdpType }) } catch (e) {}
try {
require('../../utils/analytics').track('tap_match_analyze', {
mbti: this.data.mbtiType,
pdp: this.data.pdpType,
face: this.data.hasFaceReport
})
} catch (e) {}
wx.showLoading({ title: 'AI 分析中...', mask: true })
const fileUrl = this.data.resumeUrl
request({
url: '/api/resume/analyze',
method: 'POST',
needAuth: true,
data: { resumeUrl: this.data.resumeUrl },
timeout: 120000,
data: { fileUrl, resumeUrl: fileUrl },
success: (res) => {
wx.hideLoading()
if (res.statusCode === 200 && res.data && res.data.code === 200) {
@@ -239,26 +269,41 @@ Page({
},
_normalizeAnalyzeResult(d) {
const fit = Array.isArray(d.fitRoles)
? d.fitRoles
: (d.portrait && Array.isArray(d.portrait.bestFit) ? d.portrait.bestFit : [])
const strengths = Array.isArray(d.strengths)
? d.strengths
: (d.portrait && Array.isArray(d.portrait.coreStrengths) ? d.portrait.coreStrengths : [])
const weaknesses = Array.isArray(d.weaknesses)
? d.weaknesses
: (d.portrait && Array.isArray(d.portrait.coreRisks) ? d.portrait.coreRisks : [])
const portrait = d.portrait && typeof d.portrait === 'object' ? d.portrait : {}
const hrView = d.hrView && typeof d.hrView === 'object' ? d.hrView : {}
const roleRec = hrView.roleRecommend && typeof hrView.roleRecommend === 'object' ? hrView.roleRecommend : {}
let fit = Array.isArray(d.fitRoles) ? d.fitRoles : []
if (!fit.length && Array.isArray(roleRec.bestFit)) fit = roleRec.bestFit
if (!fit.length && Array.isArray(portrait.bestFit)) fit = portrait.bestFit
let strengths = Array.isArray(d.strengths) ? d.strengths : []
if (!strengths.length && Array.isArray(portrait.coreStrengths)) strengths = portrait.coreStrengths
if (!strengths.length && Array.isArray(d.resumeHighlights)) strengths = d.resumeHighlights.slice(0, 8)
let weaknesses = Array.isArray(d.weaknesses) ? d.weaknesses : []
if (!weaknesses.length && Array.isArray(portrait.coreRisks)) weaknesses = portrait.coreRisks
const summary =
d.summary ||
d.overview ||
portrait.workStyle ||
(typeof d.content === 'string' ? d.content : '') ||
''
const advice = d.advice || d.nextStep || (hrView.lifecycle && hrView.lifecycle.growth) || ''
let score = typeof d.score === 'number' ? d.score : null
if (score == null && d.matchScore != null) score = Number(d.matchScore)
if (score == null && typeof d.hrScore === 'number') score = d.hrScore
return {
score: typeof d.score === 'number' ? d.score : (d.matchScore || null),
score,
fitRoles: fit.slice(0, 5),
summary: d.summary || (d.portrait && d.portrait.workStyle) || '',
summary,
strengths: strengths.slice(0, 8),
weaknesses: weaknesses.slice(0, 6),
advice: d.advice || d.nextStep || ''
advice: typeof advice === 'string' ? advice : ''
}
},
goToTestSelect() {
wx.navigateTo({ url: '/pages/test-select/index' })
}
})

View File

@@ -1,30 +1,28 @@
<!--pages/match-job/index.wxml 匹配工作:上传简历 + 性格规则 + AI 综合分析-->
<!--pages/match-job/index.wxml 匹配工作:性格画像同「我的」+ 简历 + 面相 + AI 综合分析(紧凑一屏优先)-->
<view class="match-page">
<scroll-view class="match-scroll" scroll-y>
<view class="match-container">
<!-- Hero -->
<view class="match-hero">
<text class="match-hero__eyebrow">MATCH JOB · AI 智能匹配</text>
<text class="match-hero__title">简历 + 性格 = 更准的岗位推荐</text>
<text class="match-hero__desc">上传你的简历,结合 MBTI / PDP / DISC / SBTI / 面相的性格画像,由 AI 综合分析你最匹配的工作方向。</text>
<text class="match-hero__eyebrow">MATCH JOB · AI</text>
<text class="match-hero__title">简历 + 性格画像</text>
<text class="match-hero__desc">上传简历;性格数据与「我的」页一致(含面相)。点击底部按钮生成岗位匹配建议。</text>
</view>
<!-- 步骤说明 -->
<view class="match-steps">
<view class="match-step">
<view class="match-step__no">1</view>
<view class="match-step__body">
<text class="match-step__title">完成性格测评</text>
<text class="match-step__desc">至少完成一项MBTI / PDP / DISC / SBTI / 面相)</text>
<text class="match-step__title">性格 / 面相</text>
<text class="match-step__desc">问卷或底部「拍摄」面相,至少一项</text>
</view>
<text class="match-step__state {{hasAnyTest ? 'match-step__state--done' : ''}}">{{hasAnyTest ? '已完成' : '未开始'}}</text>
<text class="match-step__state {{hasAnyTest ? 'match-step__state--done' : ''}}">{{hasAnyTest ? '已就绪' : '未完成'}}</text>
</view>
<view class="match-step">
<view class="match-step__no">2</view>
<view class="match-step__body">
<text class="match-step__title">上传最新简历</text>
<text class="match-step__desc">支持 PDF / Word / 图片,最多 10MB</text>
<text class="match-step__title">简历文件</text>
<text class="match-step__desc">PDF / Word / 图片,10MB</text>
</view>
<text class="match-step__state {{resumeUrl ? 'match-step__state--done' : ''}}">{{resumeUrl ? '已上传' : '待上传'}}</text>
</view>
@@ -32,17 +30,16 @@
<view class="match-step__no">3</view>
<view class="match-step__body">
<text class="match-step__title">AI 综合分析</text>
<text class="match-step__desc">生成岗位匹配度、发展方向与改进建议</text>
<text class="match-step__desc">结合简历与测评/面相</text>
</view>
<text class="match-step__state {{analyzed ? 'match-step__state--done' : ''}}">{{analyzed ? '已生成' : '未生成'}}</text>
</view>
</view>
<!-- 我的性格标签 -->
<view class="match-card">
<view class="match-card match-card--tight">
<view class="match-card__head">
<text class="match-card__title">我的性格画像</text>
<text class="match-card__sub" wx:if="{{!hasAnyTest}}" bindtap="goToTestSelect">去做测评 </text>
<text class="match-card__sub" bindtap="goToTestSelect">问卷 </text>
</view>
<view class="match-tags">
<view class="match-tag match-tag--mbti" wx:if="{{mbtiType}}">MBTI · {{mbtiType}}</view>
@@ -50,54 +47,58 @@
<view class="match-tag match-tag--disc" wx:if="{{discType}}">DISC · {{discType}}</view>
<view class="match-tag match-tag--sbti" wx:if="{{sbtiType}}">SBTI · {{sbtiType}}</view>
<view class="match-tag match-tag--face" wx:if="{{faceType}}">面相 · {{faceType}}</view>
<view class="match-tag match-tag--empty" wx:if="{{!hasAnyTest}}">暂无测评结果</view>
<view class="match-tag match-tag--empty" wx:if="{{!hasAnyTest}}">暂无数据</view>
</view>
<view class="match-face-cta" wx:if="{{!hasFaceReport}}">
<text class="match-face-cta__hint">面相分析可补充视觉维度,与简历一起参与 AI 综合</text>
<view class="match-face-cta__btn" bindtap="goCamera">去拍摄面相</view>
</view>
</view>
<!-- 性格→工作规则 -->
<view class="match-card" wx:if="{{hasAnyTest}}">
<view class="match-card__head">
<text class="match-card__title">性格 → 工作匹配规则</text>
<view class="match-card match-card--tight" wx:if="{{hasAnyTest}}">
<view class="match-card__head match-card__head--tap" bindtap="toggleRuleDetail">
<text class="match-card__title">性格 → 岗位参考</text>
<text class="match-card__sub">{{showRuleDetail ? '收起' : '展开'}}</text>
</view>
<view class="match-rule-row" wx:if="{{mbtiType}}">
<text class="match-rule-row__key">MBTI {{mbtiType}}</text>
<text class="match-rule-row__val">{{mbtiJobHint || '擅长的岗位范围与协作偏好'}}</text>
</view>
<view class="match-rule-row" wx:if="{{pdpType}}">
<text class="match-rule-row__key">PDP {{pdpType}}</text>
<text class="match-rule-row__val">{{pdpJobHint || '典型的行为风格与团队角色'}}</text>
</view>
<view class="match-rule-row" wx:if="{{discType}}">
<text class="match-rule-row__key">DISC {{discType}}</text>
<text class="match-rule-row__val">{{discJobHint || '沟通与决策的优势方向'}}</text>
</view>
<view class="match-rule-hint">
<text>以上为通用规则AI 综合分析会结合你的简历给出个性化建议。</text>
<view wx:if="{{showRuleDetail}}">
<view class="match-rule-row" wx:if="{{mbtiType}}">
<text class="match-rule-row__key">MBTI</text>
<text class="match-rule-row__val">{{mbtiJobHint || '—'}}</text>
</view>
<view class="match-rule-row" wx:if="{{pdpType}}">
<text class="match-rule-row__key">PDP</text>
<text class="match-rule-row__val">{{pdpJobHint || '—'}}</text>
</view>
<view class="match-rule-row" wx:if="{{discType}}">
<text class="match-rule-row__key">DISC</text>
<text class="match-rule-row__val">{{discJobHint || '—'}}</text>
</view>
<view class="match-rule-hint">
<text>以下为通用参考,最终以 AI 结合简历的输出为准。</text>
</view>
</view>
</view>
<!-- 上传简历 -->
<view class="match-card">
<view class="match-card match-card--tight">
<view class="match-card__head">
<text class="match-card__title">简历</text>
<text class="match-card__sub" wx:if="{{resumeUrl}}" bindtap="previewResume">预览 </text>
</view>
<view class="match-upload" bindtap="chooseResume">
<view class="match-upload__icon">{{resumeUrl ? '📄' : '📤'}}</view>
<text class="match-upload__title">{{resumeUrl ? (resumeFileName || '已上传简历') : '点击上传 / 替换简历'}}</text>
<text class="match-upload__sub">{{resumeUrl ? '可替换上传最新版本' : '支持 PDF / Word / 图片,最多 10MB'}}</text>
<text class="match-upload__title">{{resumeUrl ? (resumeFileName || '已上传') : '点击上传 / 替换'}}</text>
<text class="match-upload__sub">{{resumeUrl ? '可替换新版本' : 'PDF / Word / 图片'}}</text>
</view>
</view>
<!-- AI 综合分析结果 -->
<view class="match-card" wx:if="{{analyzeResult}}">
<view class="match-card match-card--tight" wx:if="{{analyzeResult}}">
<view class="match-card__head">
<text class="match-card__title">AI 综合分析</text>
<text class="match-card__sub" wx:if="{{analyzeResult.score != null}}">匹配度 {{analyzeResult.score}}%</text>
<text class="match-card__sub" wx:if="{{analyzeResult.score != null}}">{{analyzeResult.score}}%</text>
</view>
<view class="match-fit-card" wx:if="{{analyzeResult.fitRoles.length}}">
<text class="match-fit-card__title">最匹配的岗位方向</text>
<text class="match-fit-card__title">岗位方向</text>
<view class="match-fit-list">
<view class="match-fit-item" wx:for="{{analyzeResult.fitRoles}}" wx:key="*this">
<text class="match-fit-item__no">★</text>
@@ -107,33 +108,32 @@
</view>
<view class="match-block" wx:if="{{analyzeResult.summary}}">
<text class="match-block__label">岗位匹配度说明</text>
<text class="match-block__label">摘要</text>
<text class="match-block__text">{{analyzeResult.summary}}</text>
</view>
<view class="match-block" wx:if="{{analyzeResult.strengths.length}}">
<text class="match-block__label">简历亮点</text>
<text class="match-block__label">亮点</text>
<view class="match-chip-row">
<text class="match-chip match-chip--good" wx:for="{{analyzeResult.strengths}}" wx:key="*this">{{item}}</text>
</view>
</view>
<view class="match-block" wx:if="{{analyzeResult.weaknesses.length}}">
<text class="match-block__label">可改进点</text>
<text class="match-block__label">注意</text>
<view class="match-chip-row">
<text class="match-chip match-chip--warn" wx:for="{{analyzeResult.weaknesses}}" wx:key="*this">{{item}}</text>
</view>
</view>
<view class="match-block" wx:if="{{analyzeResult.advice}}">
<text class="match-block__label">下一步建议</text>
<text class="match-block__label">建议</text>
<text class="match-block__text">{{analyzeResult.advice}}</text>
</view>
</view>
</view>
</scroll-view>
<!-- 底部主按钮 -->
<view class="match-foot">
<button
class="match-foot__btn {{(!hasAnyTest || !resumeUrl || analyzing) ? 'match-foot__btn--disabled' : ''}}"
@@ -141,10 +141,10 @@
disabled="{{!hasAnyTest || !resumeUrl || analyzing}}"
>
<text wx:if="{{analyzing}}">分析中…</text>
<text wx:elif="{{!hasAnyTest}}">请先完成性格测评</text>
<text wx:elif="{{!hasAnyTest}}">请先完成问卷或面相</text>
<text wx:elif="{{!resumeUrl}}">请先上传简历</text>
<text wx:else>立即 AI 综合分析</text>
</button>
<text class="match-foot__tip">分析结果保存在「我的订单」页可随时回看</text>
<text class="match-foot__tip">结果可在「我的订单」回看</text>
</view>
</view>

View File

@@ -1,68 +1,73 @@
/* pages/match-job/index.wxss */
/* pages/match-job/index.wxss - 紧凑布局,减少首屏滚动 */
page {
background: #f5f7fb;
}
.match-page {
width: 100%;
min-height: 100vh;
height: 100vh;
height: 100dvh;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
}
.match-scroll {
flex: 1;
min-height: 0;
width: 100%;
height: calc(100vh - 180rpx);
}
.match-container {
padding: 32rpx;
padding-bottom: calc(32rpx + 200rpx + env(safe-area-inset-bottom));
padding: 20rpx 24rpx;
padding-bottom: calc(24rpx + 200rpx + env(safe-area-inset-bottom));
}
.match-hero {
background: linear-gradient(140deg, #2563eb 0%, #6366f1 60%, #8b5cf6 100%);
color: #fff;
border-radius: 28rpx;
padding: 40rpx 36rpx;
box-shadow: 0 20rpx 48rpx rgba(37, 99, 235, 0.22);
margin-bottom: 28rpx;
border-radius: 20rpx;
padding: 24rpx 28rpx;
box-shadow: 0 12rpx 32rpx rgba(37, 99, 235, 0.2);
margin-bottom: 16rpx;
}
.match-hero__eyebrow {
display: block;
font-size: 22rpx;
letter-spacing: 0.2em;
opacity: 0.82;
margin-bottom: 12rpx;
font-size: 20rpx;
letter-spacing: 0.15em;
opacity: 0.85;
margin-bottom: 8rpx;
}
.match-hero__title {
display: block;
font-size: 44rpx;
font-size: 34rpx;
font-weight: 800;
margin-bottom: 12rpx;
margin-bottom: 8rpx;
}
.match-hero__desc {
display: block;
font-size: 26rpx;
line-height: 1.65;
font-size: 24rpx;
line-height: 1.5;
opacity: 0.92;
}
.match-steps {
background: #fff;
border-radius: 24rpx;
box-shadow: 0 4rpx 18rpx rgba(15, 23, 42, 0.05);
padding: 18rpx 24rpx;
margin-bottom: 24rpx;
border-radius: 18rpx;
box-shadow: 0 4rpx 14rpx rgba(15, 23, 42, 0.05);
padding: 8rpx 18rpx;
margin-bottom: 16rpx;
}
.match-step {
display: flex;
align-items: center;
gap: 18rpx;
padding: 18rpx 0;
gap: 14rpx;
padding: 12rpx 0;
border-bottom: 1rpx solid #f1f5f9;
}
@@ -71,13 +76,13 @@ page {
}
.match-step__no {
width: 56rpx;
height: 56rpx;
border-radius: 18rpx;
width: 48rpx;
height: 48rpx;
border-radius: 14rpx;
background: #eef2ff;
color: #4338ca;
font-weight: 800;
font-size: 26rpx;
font-size: 24rpx;
display: flex;
align-items: center;
justify-content: center;
@@ -87,25 +92,25 @@ page {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rpx;
gap: 2rpx;
min-width: 0;
}
.match-step__title {
font-size: 28rpx;
font-size: 26rpx;
font-weight: 700;
color: #111827;
}
.match-step__desc {
font-size: 22rpx;
font-size: 20rpx;
color: #64748b;
}
.match-step__state {
font-size: 22rpx;
font-size: 20rpx;
color: #94a3b8;
padding: 6rpx 16rpx;
padding: 4rpx 12rpx;
border-radius: 999rpx;
background: #f1f5f9;
}
@@ -117,21 +122,30 @@ page {
.match-card {
background: #fff;
border-radius: 24rpx;
box-shadow: 0 4rpx 18rpx rgba(15, 23, 42, 0.05);
padding: 28rpx 28rpx;
margin-bottom: 24rpx;
border-radius: 18rpx;
box-shadow: 0 4rpx 14rpx rgba(15, 23, 42, 0.05);
padding: 20rpx 22rpx;
margin-bottom: 16rpx;
}
.match-card--tight {
padding: 18rpx 20rpx;
margin-bottom: 14rpx;
}
.match-card__head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 16rpx;
margin-bottom: 12rpx;
}
.match-card__head--tap {
padding: 4rpx 0;
}
.match-card__title {
font-size: 30rpx;
font-size: 28rpx;
font-weight: 700;
color: #111827;
}
@@ -145,13 +159,13 @@ page {
.match-tags {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
gap: 10rpx;
}
.match-tag {
padding: 10rpx 20rpx;
border-radius: 14rpx;
font-size: 24rpx;
padding: 8rpx 16rpx;
border-radius: 12rpx;
font-size: 22rpx;
font-weight: 600;
background: #eef2ff;
color: #4338ca;
@@ -164,10 +178,34 @@ page {
.match-tag--face { background: #fce7f3; color: #be185d; }
.match-tag--empty { background: #f1f5f9; color: #94a3b8; font-weight: 400; }
.match-face-cta {
margin-top: 14rpx;
padding-top: 14rpx;
border-top: 1rpx solid #f1f5f9;
}
.match-face-cta__hint {
display: block;
font-size: 22rpx;
color: #64748b;
line-height: 1.45;
margin-bottom: 12rpx;
}
.match-face-cta__btn {
display: inline-block;
padding: 14rpx 28rpx;
border-radius: 999rpx;
font-size: 24rpx;
font-weight: 600;
color: #fff;
background: linear-gradient(135deg, #e63946 0%, #ff6b9d 100%);
}
.match-rule-row {
display: flex;
gap: 18rpx;
padding: 16rpx 0;
gap: 14rpx;
padding: 12rpx 0;
border-bottom: 1rpx dashed #e5e7eb;
}
@@ -177,88 +215,86 @@ page {
.match-rule-row__key {
flex: 0 0 auto;
font-size: 26rpx;
font-size: 24rpx;
font-weight: 700;
color: #4338ca;
min-width: 160rpx;
min-width: 100rpx;
}
.match-rule-row__val {
flex: 1;
font-size: 24rpx;
font-size: 22rpx;
color: #475569;
line-height: 1.7;
line-height: 1.55;
}
.match-rule-hint {
margin-top: 14rpx;
padding: 12rpx 16rpx;
margin-top: 8rpx;
padding: 10rpx 14rpx;
background: #f1f5f9;
border-radius: 12rpx;
font-size: 22rpx;
border-radius: 10rpx;
font-size: 20rpx;
color: #64748b;
}
.match-upload {
border: 2rpx dashed #c7d2fe;
border-radius: 18rpx;
padding: 36rpx 20rpx;
border-radius: 16rpx;
padding: 28rpx 16rpx;
display: flex;
flex-direction: column;
align-items: center;
background: #f9fbff;
transition: background 0.15s ease;
}
.match-upload__icon {
font-size: 56rpx;
margin-bottom: 12rpx;
font-size: 48rpx;
margin-bottom: 8rpx;
}
.match-upload__title {
font-size: 28rpx;
font-size: 26rpx;
font-weight: 700;
color: #1e40af;
margin-bottom: 6rpx;
margin-bottom: 4rpx;
}
.match-upload__sub {
font-size: 22rpx;
font-size: 20rpx;
color: #94a3b8;
}
.match-fit-card {
background: linear-gradient(135deg, #fff7ed 0%, #fde68a 100%);
border-radius: 18rpx;
padding: 22rpx 22rpx;
margin-bottom: 18rpx;
border-radius: 14rpx;
padding: 18rpx;
margin-bottom: 14rpx;
}
.match-fit-card__title {
display: block;
font-size: 24rpx;
font-size: 22rpx;
color: #9a3412;
font-weight: 700;
margin-bottom: 10rpx;
letter-spacing: 0.05em;
margin-bottom: 8rpx;
}
.match-fit-list {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
gap: 8rpx;
}
.match-fit-item {
padding: 10rpx 18rpx;
border-radius: 12rpx;
padding: 8rpx 14rpx;
border-radius: 10rpx;
background: #fff;
color: #c2410c;
font-weight: 600;
font-size: 24rpx;
font-size: 22rpx;
display: inline-flex;
align-items: center;
gap: 6rpx;
gap: 4rpx;
}
.match-fit-item__no {
@@ -266,7 +302,7 @@ page {
}
.match-block {
margin-bottom: 20rpx;
margin-bottom: 16rpx;
}
.match-block:last-child {
@@ -275,29 +311,29 @@ page {
.match-block__label {
display: block;
font-size: 24rpx;
font-size: 22rpx;
font-weight: 700;
color: #1e293b;
margin-bottom: 10rpx;
margin-bottom: 8rpx;
}
.match-block__text {
display: block;
font-size: 26rpx;
font-size: 24rpx;
color: #475569;
line-height: 1.75;
line-height: 1.65;
}
.match-chip-row {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
gap: 8rpx;
}
.match-chip {
padding: 8rpx 16rpx;
border-radius: 10rpx;
font-size: 22rpx;
padding: 6rpx 14rpx;
border-radius: 8rpx;
font-size: 20rpx;
}
.match-chip--good {
@@ -313,28 +349,24 @@ page {
}
.match-foot {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
padding: 20rpx 32rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
flex-shrink: 0;
padding: 16rpx 28rpx;
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 -8rpx 28rpx rgba(15, 23, 42, 0.08);
box-shadow: 0 -6rpx 20rpx rgba(15, 23, 42, 0.06);
}
.match-foot__btn {
width: 100% !important;
min-height: 96rpx;
line-height: 96rpx;
min-height: 88rpx;
line-height: 88rpx;
border-radius: 999rpx;
font-size: 30rpx;
font-size: 28rpx;
font-weight: 700;
color: #fff;
background: linear-gradient(135deg, #2563eb 0%, #6366f1 70%, #8b5cf6 100%);
border: none;
box-shadow: 0 10rpx 28rpx rgba(37, 99, 235, 0.32);
box-shadow: 0 8rpx 22rpx rgba(37, 99, 235, 0.28);
}
.match-foot__btn::after {
@@ -349,7 +381,7 @@ page {
.match-foot__tip {
display: block;
font-size: 20rpx;
font-size: 18rpx;
color: #94a3b8;
text-align: center;
margin-top: 6rpx;

View File

@@ -2,6 +2,11 @@
const app = getApp()
const { getTypeOnly } = require('../../utils/resultFormat')
const { request } = require('../../utils/request')
const { buildRecoMiniProgramPath } = require('../../utils/recoMiniProgram.js')
const { isAuditHideAiMode } = require('../../utils/miniprogramAuditGate.js')
/** 与超管「小程序 · 推荐文章展示」中「区块标题」一致;接口无字段时的兜底 */
const PROFILE_RECO_LABEL_FALLBACK = '我的由来'
/** recent 单条:优先用 resultMeta 与结果页一致的「双项」文案 */
function summaryFromRecentRecord(rec, testType) {
@@ -34,6 +39,10 @@ Page({
pdpTime: '',
aiTime: '',
reviewMode: false,
/** 超管「小程序提审模式」:与 reviewMode 一样隐藏推广/部分营销入口 */
mpAuditMode: false,
/** 仅 miniprogramAuditMode隐藏「了解自己」深度套餐入口虚拟商品合规 */
showDeepPricingEntry: true,
/** 最近记录的数据库 ID用于跳转时传参 */
mbtiResultId: null,
sbtiResultId: null,
@@ -66,15 +75,16 @@ Page({
showLatestTestRow: false,
/** 用户卡片下性格标签:在「当前权限下无任何问卷结果」时显示灰色提示 */
showEmptyPersonalityTags: true,
/** 我的页底部 Soul 推荐条(超管可配) */
profileRecoShow: false,
profileRecoSectionLabel: '',
profileRecoArticle: null
profileRecoArticle: null,
/** 与神仙 AI 精选推荐同源:配置了 AppID 时优先跳转目标小程序 */
profileRecoJump: null
},
_computeShowLatestTestRow(d) {
const rm = !!(d.reviewMode)
if (rm) {
const hideFace = !!(d.reviewMode || d.mpAuditMode)
if (hideFace) {
return !!(d.permMbti || d.permSbti || d.permPdp || d.permDisc)
}
return !!(d.permMbti || d.permSbti || d.permPdp || d.permDisc || d.permFace)
@@ -94,7 +104,8 @@ Page({
this.runLoginThenLoad()
},
_syncPerms(overrides = {}) {
const p = app.globalData.enterprisePermissions
const gd = app.globalData || {}
const p = gd.enterprisePermissions
const next = {
permFace: !p || p.face !== false,
permMbti: !p || p.mbti !== false,
@@ -102,6 +113,7 @@ Page({
permPdp: !p || p.pdp !== false,
permDisc: !p || p.disc !== false,
permDistribution: !p || p.distribution !== false,
showDeepPricingEntry: !gd.miniprogramAuditMode,
...overrides
}
const d = { ...this.data, ...next }
@@ -114,7 +126,10 @@ Page({
onShow() {
const gd = app.globalData
this._syncPerms({ reviewMode: !!(gd.reviewMode || gd.maintenanceMode) })
this._syncPerms({
reviewMode: !!(gd.reviewMode || gd.maintenanceMode),
mpAuditMode: isAuditHideAiMode(gd)
})
// 如果是从其他页面返回,且已经登录,则只刷新数据而不重新执行登录流程
if (this.data.hasLogin) {
this.loadData()
@@ -173,7 +188,10 @@ Page({
loadData() {
const gd = app.globalData
this._syncPerms({ reviewMode: !!(gd.reviewMode || gd.maintenanceMode) })
this._syncPerms({
reviewMode: !!(gd.reviewMode || gd.maintenanceMode),
mpAuditMode: isAuditHideAiMode(gd)
})
const userInfo = app.globalData.userInfo || wx.getStorageSync('userInfo')
const token = app.globalData.token || wx.getStorageSync('token')
@@ -209,41 +227,105 @@ Page({
this._loadPromoStats()
this._loadProfileRecoTeaser()
} else {
this.setData({ profileRecoShow: false, profileRecoArticle: null, profileRecoSectionLabel: '' })
this.setData({
profileRecoShow: false,
profileRecoArticle: null,
profileRecoSectionLabel: '',
profileRecoJump: null
})
}
},
/** 拉取「我的」页推荐条:与超管 Soul 文章推荐位第 1 篇一致 */
_parseRecoJumpFromDisplay(d) {
if (!d || typeof d !== 'object') return null
const appId = ((d.recoJumpMiniAppId || '') + '').trim()
if (!appId || !/^wx[0-9a-f]{16}$/i.test(appId)) return null
const envRaw = ((d.recoJumpMiniEnvVersion || 'release') + '').trim().toLowerCase()
const env = envRaw === 'trial' || envRaw === 'develop' ? envRaw : 'release'
let path = ((d.recoJumpMiniPath || 'pages/index/index') + '').trim().replace(/^\//, '')
if (!path) path = 'pages/index/index'
return { appId: appId.toLowerCase(), path, envVersion: env }
},
_applyProfileTeaserPayload(d) {
const art = d.article && d.article.url ? d.article : null
const show = !!(d.enabled && art)
this.setData({
profileRecoShow: show,
profileRecoSectionLabel: (d.sectionLabel && String(d.sectionLabel).trim()) || PROFILE_RECO_LABEL_FALLBACK,
profileRecoArticle: show ? art : null,
profileRecoJump: show ? this._parseRecoJumpFromDisplay(d) : null
})
},
_loadProfileRecoTeaser() {
const t = Date.now()
const finishFail = () =>
this.setData({
profileRecoShow: false,
profileRecoArticle: null,
profileRecoSectionLabel: '',
profileRecoJump: null
})
const tryLegacyPath = () => {
request({
url: `/api/ai/articles/profile-teaser?_t=${Date.now()}`,
method: 'GET',
needAuth: false,
success: (res) => {
const payload = res && res.data
if (!payload || payload.code !== 200 || !payload.data) {
finishFail()
return
}
this._applyProfileTeaserPayload(payload.data)
},
fail: finishFail
})
}
// 优先走 recommended?usage=profile与已放行路径一致部署新 API 后与 profile-teaser 结构相同
request({
url: `/api/ai/articles/profile-teaser?_t=${t}`,
url: `/api/ai/articles/recommended?usage=profile&_t=${t}`,
method: 'GET',
needAuth: false,
success: (res) => {
const payload = res && res.data
if (!payload || payload.code !== 200 || !payload.data) {
this.setData({ profileRecoShow: false, profileRecoArticle: null })
tryLegacyPath()
return
}
const d = payload.data
const art = d.article && d.article.url ? d.article : null
const show = !!(d.enabled && art)
this.setData({
profileRecoShow: show,
profileRecoSectionLabel: (d.sectionLabel && String(d.sectionLabel).trim()) || '推荐阅读',
profileRecoArticle: show ? art : null
})
if (Object.prototype.hasOwnProperty.call(d, 'article')) {
this._applyProfileTeaserPayload(d)
return
}
if (Array.isArray(d.list) && d.list.length) {
const disp = d.display || {}
const first = d.list[0]
if (disp.enabled !== false && first && first.url) {
this.setData({
profileRecoShow: true,
profileRecoSectionLabel: (disp.profileSectionLabel && String(disp.profileSectionLabel).trim()) || PROFILE_RECO_LABEL_FALLBACK,
profileRecoArticle: first,
profileRecoJump: this._parseRecoJumpFromDisplay(disp)
})
return
}
}
tryLegacyPath()
},
fail: () => {
this.setData({ profileRecoShow: false, profileRecoArticle: null })
}
fail: tryLegacyPath
})
},
onTapProfileReco(e) {
const { id, url, title } = (e && e.currentTarget && e.currentTarget.dataset) || {}
if (!url) return
const ds = (e && e.currentTarget && e.currentTarget.dataset) || {}
const id = ds.id
const url = ds.url
const title = ds.title
const sourceId = ds.sourceId
try {
require('../../utils/analytics').track('tap_ai_article', {
articleId: id,
@@ -261,6 +343,39 @@ Page({
fail() {}
})
}
const jump = this.data.profileRecoJump
if (jump && jump.appId) {
const path = buildRecoMiniProgramPath(jump.path, {
id,
title,
sourceId,
fromTag: 'mbti_profile_reco'
})
const env = jump.envVersion === 'trial' || jump.envVersion === 'develop' ? jump.envVersion : 'release'
wx.navigateToMiniProgram({
appId: jump.appId,
path,
envVersion: env,
fail: (err) => {
if (url) {
const enc = encodeURIComponent(url)
wx.navigateTo({
url: `/pages/webview/index?url=${enc}`,
fail: () => {
wx.setClipboardData({
data: url,
success: () => wx.showToast({ title: '已复制链接', icon: 'none' })
})
}
})
} else {
wx.showToast({ title: (err && err.errMsg) || '无法打开目标小程序', icon: 'none' })
}
}
})
return
}
if (!url) return
const enc = encodeURIComponent(url)
wx.navigateTo({
url: `/pages/webview/index?url=${enc}`,
@@ -436,6 +551,10 @@ Page({
},
/** 深度服务统一入口(页内 Tab个人 / 团队与企业) */
goToDeepService() {
if (app.globalData && app.globalData.miniprogramAuditMode) {
wx.showToast({ title: '版本审核中暂不可用', icon: 'none' })
return
}
try { require('../../utils/analytics').track('tap_deep_service', {}) } catch (e) {}
wx.navigateTo({ url: '/pages/purchase/index' })
},
@@ -447,9 +566,27 @@ Page({
try { require('../../utils/analytics').track('tap_my_orders', {}) } catch (e) {}
wx.navigateTo({ url: '/pages/order/index' })
},
goToPurchase() { wx.navigateTo({ url: '/pages/purchase/index' }) },
goToPurchasePersonal() { wx.navigateTo({ url: '/pages/purchase/index?tab=personal' }) },
goToPurchaseEnterprise() { wx.navigateTo({ url: '/pages/purchase/index?tab=enterprise' }) },
goToPurchase() {
if (app.globalData && app.globalData.miniprogramAuditMode) {
wx.showToast({ title: '版本审核中暂不可用', icon: 'none' })
return
}
wx.navigateTo({ url: '/pages/purchase/index' })
},
goToPurchasePersonal() {
if (app.globalData && app.globalData.miniprogramAuditMode) {
wx.showToast({ title: '版本审核中暂不可用', icon: 'none' })
return
}
wx.navigateTo({ url: '/pages/purchase/index?tab=personal' })
},
goToPurchaseEnterprise() {
if (app.globalData && app.globalData.miniprogramAuditMode) {
wx.showToast({ title: '版本审核中暂不可用', icon: 'none' })
return
}
wx.navigateTo({ url: '/pages/purchase/index?tab=enterprise' })
},
goToEnterprise() { wx.navigateTo({ url: '/pages/enterprise/index' }) },
goToPromoWithdrawals() {
try { require('../../utils/analytics').track('tap_promo_withdrawals', { from: 'profile' }) } catch (e) {}
@@ -526,7 +663,10 @@ Page({
onShareAppMessage() {
const { getSharePathByScope } = require('../../utils/share')
const rm = !!app.globalData.reviewMode
const gd = app.globalData || {}
if (gd.miniprogramAuditMode || gd.reviewMode || gd.maintenanceMode) {
return { title: '性格测试', path: '/pages/index/index' }
}
return {
title: '神仙团队性格测试 - 发现你的MBTI类型',
path: getSharePathByScope('/pages/index/index')
@@ -535,7 +675,10 @@ Page({
onShareTimeline() {
const { buildShareQuery } = require('../../utils/share')
const rm = !!app.globalData.reviewMode
const gd = app.globalData || {}
if (gd.miniprogramAuditMode || gd.reviewMode || gd.maintenanceMode) {
return { title: '性格测试', query: '' }
}
return {
title: '神仙团队性格测试 - 发现你的MBTI类型',
query: buildShareQuery()

View File

@@ -70,7 +70,7 @@
</view>
<view
class="user-stat"
wx:if="{{promoDistributionEnabled && !reviewMode && permDistribution}}"
wx:if="{{promoDistributionEnabled && !reviewMode && !mpAuditMode && permDistribution}}"
bindtap="goToPromo"
>
<text class="user-stat__v">{{promoTotalInvite || 0}}</text>
@@ -79,7 +79,7 @@
<!-- 第三格:可提现 → 分销中心 -->
<view
class="user-stat user-stat--highlight"
wx:if="{{promoDistributionEnabled && !reviewMode && permDistribution}}"
wx:if="{{promoDistributionEnabled && !reviewMode && !mpAuditMode && permDistribution}}"
bindtap="goToPromo"
>
<text class="user-stat__v">¥{{promoWithdrawable || '0.00'}}</text>
@@ -88,7 +88,7 @@
<!-- 第四格:累计 → 提现记录 -->
<view
class="user-stat user-stat--sub"
wx:if="{{promoDistributionEnabled && !reviewMode && permDistribution}}"
wx:if="{{promoDistributionEnabled && !reviewMode && !mpAuditMode && permDistribution}}"
bindtap="goToPromoWithdrawals"
>
<text class="user-stat__v user-stat__v--sm">¥{{promoTotalEarned || '0.00'}}</text>
@@ -144,7 +144,7 @@
<text class="card-value">{{discType || '未测评'}}</text>
<text class="card-time">{{discTime || '—'}}</text>
</view>
<view class="result-card card-teal {{(gallupPreview || aiType) ? '' : 'result-card--placeholder'}}" bindtap="viewGallup" wx:if="{{!reviewMode && permFace}}">
<view class="result-card card-teal {{(gallupPreview || aiType) ? '' : 'result-card--placeholder'}}" bindtap="viewGallup" wx:if="{{!reviewMode && !mpAuditMode && permFace}}">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-teal">
<text class="card-icon">⭐</text>
@@ -153,7 +153,7 @@
<text class="card-value card-value--small">{{gallupPreview || (aiType ? '见面相报告' : '未测评')}}</text>
<text class="card-time">{{aiTime || '—'}}</text>
</view>
<view class="result-card card-rose {{aiType ? '' : 'result-card--placeholder'}}" bindtap="viewAI" wx:if="{{!reviewMode && permFace}}">
<view class="result-card card-rose {{aiType ? '' : 'result-card--placeholder'}}" bindtap="viewAI" wx:if="{{!reviewMode && !mpAuditMode && permFace}}">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-rose">
<text class="card-icon">👁️</text>
@@ -178,7 +178,7 @@
<view class="quick-grid__ic quick-grid__ic--emerald">🧾</view>
<text class="quick-grid__label">我的订单</text>
</view>
<view class="quick-grid__item" bindtap="goToDeepService">
<view class="quick-grid__item" wx:if="{{showDeepPricingEntry}}" bindtap="goToDeepService">
<view class="quick-grid__ic quick-grid__ic--indigo">💎</view>
<text class="quick-grid__label">了解自己</text>
</view>
@@ -189,7 +189,7 @@
</view>
</view>
<!-- Soul 引流:后台「当前推荐第 1 篇 + 可配区块标题(灰字可点) -->
<!-- Soul 引流:超管「我的页展示首条」+ 当前推荐第 1 篇 -->
<view class="section px-section profile-reco-wrap" wx:if="{{hasLogin && profileRecoShow}}">
<view
class="profile-reco-card"
@@ -198,6 +198,7 @@
data-id="{{profileRecoArticle.id}}"
data-url="{{profileRecoArticle.url}}"
data-title="{{profileRecoArticle.title}}"
data-source-id="{{profileRecoArticle.sourceId}}"
>
<text class="profile-reco-label">{{profileRecoSectionLabel}}</text>
<view class="profile-reco-row">

View File

@@ -6,8 +6,7 @@ page {
.page {
min-height: 100vh;
background-color: #F9FAFB;
/* 自定义 tabBar 加高 + 中间浮钮上移,多留底边距 */
padding-bottom: calc(168rpx + env(safe-area-inset-bottom));
/* 底栏避让由全局 .tabbar-pad / --tabbar-pad 统一控制 */
}
custom-tab-bar {
@@ -843,7 +842,6 @@ custom-tab-bar {
line-height: 1.2;
}
/* ===== 我的页 · Soul 推荐条(灰字、可读) ===== */
.profile-reco-wrap {
margin-top: 8rpx;
}
@@ -862,7 +860,6 @@ custom-tab-bar {
display: block;
font-size: 22rpx;
color: #9ca3af;
letter-spacing: 0.02em;
margin-bottom: 10rpx;
}
.profile-reco-row {

View File

@@ -22,7 +22,7 @@ Page({
testCommissionRate: '',
testCommissionAmount: '',
testNoPayment: false,
withdrawMinYuan: '1.00',
withdrawMinYuan: '0.01',
withdrawMaxYuan: '',
withdrawFeePct: 0,
requireWithdrawAudit: true,
@@ -79,7 +79,7 @@ Page({
testCommissionRate: d.testCommissionRate,
testCommissionAmount: d.testCommissionAmount,
testNoPayment: d.testNoPayment,
withdrawMinYuan: d.withdrawMinYuan != null ? d.withdrawMinYuan : '1.00',
withdrawMinYuan: d.withdrawMinYuan != null ? d.withdrawMinYuan : '0.01',
withdrawMaxYuan: d.withdrawMaxYuan != null && d.withdrawMaxYuan !== '' ? d.withdrawMaxYuan : '',
withdrawFeePct: d.withdrawFeePct != null ? d.withdrawFeePct : 0,
requireWithdrawAudit: d.requireWithdrawAudit !== false,
@@ -150,8 +150,8 @@ Page({
/** 申请提现:打开自定义金额弹框 */
handleWithdraw() {
const balance = parseFloat(this.data.balance)
if (balance < 1) {
wx.showToast({ title: '余额不足1元暂无提现', icon: 'none' })
if (!balance || balance <= 0) {
wx.showToast({ title: '暂无提现余额', icon: 'none' })
return
}
const pct = this.data.withdrawFeePct || 0
@@ -198,28 +198,29 @@ Page({
confirmWithdraw() {
const balance = parseFloat(this.data.balance)
const val = parseFloat(this.data.withdrawAmountInput)
const minYuan = parseFloat(this.data.withdrawMinYuan) || 1
const cfgMin = parseFloat(this.data.withdrawMinYuan)
const minYuan = !isNaN(cfgMin) && cfgMin > 0 ? cfgMin : 0.01
const pct = this.data.withdrawFeePct || 0
if (isNaN(val)) {
this.setData({ withdrawError: '请输入正确的金额' })
return
}
if (val < 1) {
this.setData({ withdrawError: '单次提现金额至少 1 元' })
if (val + 1e-9 < minYuan) {
this.setData({ withdrawError: `单次提现金额至少 ¥${minYuan.toFixed(2)}` })
return
}
if (val > balance) {
if (val > balance + 1e-9) {
this.setData({ withdrawError: '不可超过当前可提现金额' })
return
}
const actualYuan = val - val * pct / 100
if (actualYuan < minYuan) {
this.setData({ withdrawError: `实际到账金额不得低于最低提现金额 ¥${minYuan.toFixed(2)}` })
if (actualYuan + 1e-9 < minYuan) {
this.setData({ withdrawError: `实际到账金额不得低于 ¥${minYuan.toFixed(2)}` })
return
}
const amountFen = Math.floor(val * 100)
const amountFen = Math.max(1, Math.round(val * 100))
this.setData({ withdrawError: '' })
try { require('../../utils/analytics').track('tap_promo_withdraw', { amountFen }) } catch (e) {}

View File

@@ -1,219 +1,219 @@
<!--pages/promo/index.wxml-->
<view class="container">
<!-- 顶部统计卡片 -->
<view class="hero-card">
<view class="hero-header-row">
<view class="hero-left">
<view class="wallet-icon">
<text class="emoji">🧧</text>
</view>
<view class="title-text">
<text class="label">可提现金额</text>
<view class="badge">
<view class="dot"></view>
<text class="badge-text">{{commissionRate}}% 高额返利</text>
</view>
</view>
</view>
<view class="hero-right">
<text class="amount">¥{{balance}}</text>
<text class="amount-sub">累计: ¥{{totalEarned}} | 待审核: ¥{{pendingAmount}}</text>
</view>
</view>
<button class="withdraw-btn {{balance > 0 ? '' : 'disabled'}}" bindtap="handleWithdraw">
<text>{{balance > 0 ? '立即提现' : '暂无提现'}}</text>
</button>
<view class="record-link" bindtap="goToWithdrawHistory">
<text>查看提现记录</text>
<text class="arrow"></text>
</view>
</view>
<!-- 自定义提现金额弹框 -->
<view class="withdraw-mask" wx:if="{{showWithdrawDialog}}">
<view class="withdraw-dialog">
<view class="dialog-title">申请提现</view>
<view class="dialog-sub">可提现 ¥{{balance}},请输入本次提现金额</view>
<view class="amount-input-row">
<text class="currency">¥</text>
<input
class="amount-input"
type="digit"
focus="true"
value="{{withdrawAmountInput}}"
placeholder="至少 1.00 元"
bindinput="onWithdrawInput"
/>
</view>
<view class="amount-hint">
<text>本次最高可提 ¥{{balance}}</text>
</view>
<view class="fee-actual-row" wx:if="{{withdrawFeePct > 0}}">
<text>手续费 ¥{{withdrawFeeYuan}}</text>
<text class="actual">实际到账 ¥{{withdrawActualYuan}}</text>
</view>
<view class="error-text" wx:if="{{withdrawError}}">
<text>{{withdrawError}}</text>
</view>
<view class="dialog-actions">
<view class="btn cancel" bindtap="closeWithdrawDialog">取消</view>
<view class="btn confirm" bindtap="confirmWithdraw">确定</view>
</view>
</view>
</view>
<!-- 四格统计 -->
<view class="stats-grid">
<view class="stat-item">
<text class="stat-val">{{bindingCount}}</text>
<text class="stat-label">绑定中</text>
</view>
<view class="stat-item">
<text class="stat-val">{{paidCount}}</text>
<text class="stat-label">已付款</text>
</view>
<view class="stat-item">
<text class="stat-val highlight">{{expiringCount}}</text>
<text class="stat-label">即将过期</text>
</view>
<view class="stat-item">
<text class="stat-val">{{totalInvite}}</text>
<text class="stat-label">总邀请</text>
</view>
</view>
<!-- 收益规则 -->
<view class="section rule-section">
<view class="section-header">
<view class="header-icon red">
<text class="emoji-sm">🛡️</text>
</view>
<text class="section-title">推广收益规则</text>
</view>
<view class="rule-list">
<view class="rule-item">
<text class="rule-bullet">✨</text>
<text class="rule-text">用户绑定有效期为 <text class="highlight">{{bindingDays}}天</text>,期满自动解除</text>
</view>
<view class="rule-item">
<text class="rule-bullet">💰</text>
<text class="rule-text">单笔提现:最低 <text class="highlight">¥{{withdrawMinYuan}}</text></text>
<text class="rule-text" wx:if="{{withdrawMaxYuan}}">最高 <text class="highlight">¥{{withdrawMaxYuan}}</text></text>
<text class="rule-text" wx:else>不设上限</text>
</view>
<view class="rule-item" wx:if="{{withdrawFeePct > 0}}">
<text class="rule-bullet">📋</text>
<text class="rule-text">提现手续费:<text class="highlight">{{withdrawFeePct}}%</text></text>
</view>
</view>
</view>
<!-- 绑定用户列表 -->
<view class="section user-section">
<view class="section-header border-b">
<view class="header-left">
<text class="emoji-sm">👥</text>
<text class="section-title">绑定用户</text>
<text class="count-label">({{totalInvite}})</text>
</view>
</view>
<!-- Tab 切换 -->
<view class="tab-bar">
<view class="tab-item {{activeTab === 0 ? 'active' : ''}}" bindtap="switchTab" data-index="0">
绑定中 ({{bindingCount}})
</view>
<view class="tab-item {{activeTab === 1 ? 'active' : ''}}" bindtap="switchTab" data-index="1">
已付款 ({{paidCount}})
</view>
<view class="tab-item {{activeTab === 2 ? 'active' : ''}}" bindtap="switchTab" data-index="2">
已过期 ({{expiringCount}})
</view>
</view>
<!-- 空状态 -->
<view class="empty-state" wx:if="{{userList.length === 0}}">
<view class="empty-icon">
<text class="emoji-lg">🔍</text>
</view>
<text class="empty-text">目前还没有绑定的用户哦</text>
<text class="empty-sub">快去分享链接邀请好友吧</text>
</view>
<!-- 用户列表 -->
<view class="user-list" wx:else>
<view class="user-item" wx:for="{{userList}}" wx:key="id">
<!-- 头像 -->
<image wx:if="{{item.avatar}}" class="user-avatar" src="{{item.avatar}}" mode="aspectFill"/>
<view wx:else class="user-avatar user-avatar-placeholder">
<text class="user-avatar-letter">{{item.nickname ? item.nickname[0] : '?'}}</text>
</view>
<!-- 昵称 + 绑定时间 -->
<view class="user-info">
<text class="user-name">{{item.nickname || '微信用户'}}</text>
<text class="user-time">绑定于 {{item.createdAtStr}}</text>
</view>
<!-- 状态 / 剩余天数 -->
<view class="user-badge {{item.status === 'active' ? 'badge-active' : (item.status === 'expired' ? 'badge-expired' : 'badge-paid')}}">
<text wx:if="{{item.status === 'active'}}">{{item.remainDays}}天到期</text>
<text wx:elif="{{item.status === 'expired'}}">已过期</text>
<text wx:else>已付款</text>
</view>
</view>
<!-- 加载更多 / 没有更多 -->
<view class="list-footer" wx:if="{{listLoading}}">
<text class="list-footer-text">加载中...</text>
</view>
<view class="list-footer" wx:elif="{{listFinished && userList.length > 0}}">
<text class="list-footer-text">— 已加载全部 —</text>
</view>
</view>
</view>
<!-- 操作菜单 -->
<view class="menu-card">
<view class="menu-item" bindtap="generatePoster">
<view class="menu-icon-wrap rose">
<text class="emoji-sm">🎨</text>
</view>
<view class="menu-info">
<text class="menu-name">生成推广海报</text>
<text class="menu-desc">一键生成您的专属精美海报</text>
</view>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" bindtap="shareToTimeline">
<view class="menu-icon-wrap emerald">
<text class="emoji-sm">💬</text>
</view>
<view class="menu-info">
<text class="menu-name">分享到朋友圈</text>
<text class="menu-desc">通过右上角菜单分享到朋友圈</text>
</view>
<text class="menu-arrow"></text>
</view>
<button class="menu-item share-btn" open-type="share">
<view class="menu-icon-wrap violet">
<text class="emoji-sm">📤</text>
</view>
<view class="menu-info">
<text class="menu-name">分享给好友</text>
<text class="menu-desc">通过系统面板发送给微信好友</text>
</view>
<text class="menu-arrow"></text>
</button>
</view>
<!-- 底部提示 -->
<view class="footer-tip">
<text>分享专属链接,好友通过链接点击后自动绑定</text>
<text>购买任意测评即可获得 <text class="highlight">90%</text> 的现金返利</text>
</view>
<view class="safe-bottom"></view>
</view>
<!--pages/promo/index.wxml-->
<view class="container">
<!-- 顶部统计卡片 -->
<view class="hero-card">
<view class="hero-header-row">
<view class="hero-left">
<view class="wallet-icon">
<text class="emoji">🧧</text>
</view>
<view class="title-text">
<text class="label">可提现金额</text>
<view class="badge">
<view class="dot"></view>
<text class="badge-text">{{commissionRate}}% 高额返利</text>
</view>
</view>
</view>
<view class="hero-right">
<text class="amount">¥{{balance}}</text>
<text class="amount-sub">累计: ¥{{totalEarned}} | 待审核: ¥{{pendingAmount}}</text>
</view>
</view>
<button class="withdraw-btn {{balance > 0 ? '' : 'disabled'}}" bindtap="handleWithdraw">
<text>{{balance > 0 ? '立即提现' : '暂无提现'}}</text>
</button>
<view class="record-link" bindtap="goToWithdrawHistory">
<text>查看提现记录</text>
<text class="arrow"></text>
</view>
</view>
<!-- 自定义提现金额弹框 -->
<view class="withdraw-mask" wx:if="{{showWithdrawDialog}}">
<view class="withdraw-dialog">
<view class="dialog-title">申请提现</view>
<view class="dialog-sub">可提现 ¥{{balance}},请输入本次提现金额</view>
<view class="amount-input-row">
<text class="currency">¥</text>
<input
class="amount-input"
type="digit"
focus="true"
value="{{withdrawAmountInput}}"
placeholder="输入提现金额"
bindinput="onWithdrawInput"
/>
</view>
<view class="amount-hint">
<text>本次最高可提 ¥{{balance}}</text>
</view>
<view class="fee-actual-row" wx:if="{{withdrawFeePct > 0}}">
<text>手续费 ¥{{withdrawFeeYuan}}</text>
<text class="actual">实际到账 ¥{{withdrawActualYuan}}</text>
</view>
<view class="error-text" wx:if="{{withdrawError}}">
<text>{{withdrawError}}</text>
</view>
<view class="dialog-actions">
<view class="btn cancel" bindtap="closeWithdrawDialog">取消</view>
<view class="btn confirm" bindtap="confirmWithdraw">确定</view>
</view>
</view>
</view>
<!-- 四格统计 -->
<view class="stats-grid">
<view class="stat-item">
<text class="stat-val">{{bindingCount}}</text>
<text class="stat-label">绑定中</text>
</view>
<view class="stat-item">
<text class="stat-val">{{paidCount}}</text>
<text class="stat-label">已付款</text>
</view>
<view class="stat-item">
<text class="stat-val highlight">{{expiringCount}}</text>
<text class="stat-label">即将过期</text>
</view>
<view class="stat-item">
<text class="stat-val">{{totalInvite}}</text>
<text class="stat-label">总邀请</text>
</view>
</view>
<!-- 收益规则 -->
<view class="section rule-section">
<view class="section-header">
<view class="header-icon red">
<text class="emoji-sm">🛡️</text>
</view>
<text class="section-title">推广收益规则</text>
</view>
<view class="rule-list">
<view class="rule-item">
<text class="rule-bullet">✨</text>
<text class="rule-text">用户绑定有效期为 <text class="highlight">{{bindingDays}}天</text>,期满自动解除</text>
</view>
<view class="rule-item">
<text class="rule-bullet">💰</text>
<text class="rule-text">单笔提现:最低 <text class="highlight">¥{{withdrawMinYuan}}</text></text>
<text class="rule-text" wx:if="{{withdrawMaxYuan}}">最高 <text class="highlight">¥{{withdrawMaxYuan}}</text></text>
<text class="rule-text" wx:else>不设上限</text>
</view>
<view class="rule-item" wx:if="{{withdrawFeePct > 0}}">
<text class="rule-bullet">📋</text>
<text class="rule-text">提现手续费:<text class="highlight">{{withdrawFeePct}}%</text></text>
</view>
</view>
</view>
<!-- 绑定用户列表 -->
<view class="section user-section">
<view class="section-header border-b">
<view class="header-left">
<text class="emoji-sm">👥</text>
<text class="section-title">绑定用户</text>
<text class="count-label">({{totalInvite}})</text>
</view>
</view>
<!-- Tab 切换 -->
<view class="tab-bar">
<view class="tab-item {{activeTab === 0 ? 'active' : ''}}" bindtap="switchTab" data-index="0">
绑定中 ({{bindingCount}})
</view>
<view class="tab-item {{activeTab === 1 ? 'active' : ''}}" bindtap="switchTab" data-index="1">
已付款 ({{paidCount}})
</view>
<view class="tab-item {{activeTab === 2 ? 'active' : ''}}" bindtap="switchTab" data-index="2">
已过期 ({{expiringCount}})
</view>
</view>
<!-- 空状态 -->
<view class="empty-state" wx:if="{{userList.length === 0}}">
<view class="empty-icon">
<text class="emoji-lg">🔍</text>
</view>
<text class="empty-text">目前还没有绑定的用户哦</text>
<text class="empty-sub">快去分享链接邀请好友吧</text>
</view>
<!-- 用户列表 -->
<view class="user-list" wx:else>
<view class="user-item" wx:for="{{userList}}" wx:key="id">
<!-- 头像 -->
<image wx:if="{{item.avatar}}" class="user-avatar" src="{{item.avatar}}" mode="aspectFill"/>
<view wx:else class="user-avatar user-avatar-placeholder">
<text class="user-avatar-letter">{{item.nickname ? item.nickname[0] : '?'}}</text>
</view>
<!-- 昵称 + 绑定时间 -->
<view class="user-info">
<text class="user-name">{{item.nickname || '微信用户'}}</text>
<text class="user-time">绑定于 {{item.createdAtStr}}</text>
</view>
<!-- 状态 / 剩余天数 -->
<view class="user-badge {{item.status === 'active' ? 'badge-active' : (item.status === 'expired' ? 'badge-expired' : 'badge-paid')}}">
<text wx:if="{{item.status === 'active'}}">{{item.remainDays}}天到期</text>
<text wx:elif="{{item.status === 'expired'}}">已过期</text>
<text wx:else>已付款</text>
</view>
</view>
<!-- 加载更多 / 没有更多 -->
<view class="list-footer" wx:if="{{listLoading}}">
<text class="list-footer-text">加载中...</text>
</view>
<view class="list-footer" wx:elif="{{listFinished && userList.length > 0}}">
<text class="list-footer-text">— 已加载全部 —</text>
</view>
</view>
</view>
<!-- 操作菜单 -->
<view class="menu-card">
<view class="menu-item" bindtap="generatePoster">
<view class="menu-icon-wrap rose">
<text class="emoji-sm">🎨</text>
</view>
<view class="menu-info">
<text class="menu-name">生成推广海报</text>
<text class="menu-desc">一键生成您的专属精美海报</text>
</view>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" bindtap="shareToTimeline">
<view class="menu-icon-wrap emerald">
<text class="emoji-sm">💬</text>
</view>
<view class="menu-info">
<text class="menu-name">分享到朋友圈</text>
<text class="menu-desc">通过右上角菜单分享到朋友圈</text>
</view>
<text class="menu-arrow"></text>
</view>
<button class="menu-item share-btn" open-type="share">
<view class="menu-icon-wrap violet">
<text class="emoji-sm">📤</text>
</view>
<view class="menu-info">
<text class="menu-name">分享给好友</text>
<text class="menu-desc">通过系统面板发送给微信好友</text>
</view>
<text class="menu-arrow"></text>
</button>
</view>
<!-- 底部提示 -->
<view class="footer-tip">
<text>分享专属链接,好友通过链接点击后自动绑定</text>
<text>购买任意测评即可获得 <text class="highlight">90%</text> 的现金返利</text>
</view>
<view class="safe-bottom"></view>
</view>

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,69 @@
// pages/purchase/index.js - 开通会员(深度服务价格:个人/企业区分,类目由后端配置可新增)
const app = getApp()
// 禁止模块顶层 getApp():上传/首屏偶发 App 未就绪会导致空白页
const payment = require('../../utils/payment')
const { hasPhone, bindPhoneByCode } = require('../../utils/phoneAuth.js')
const {
buildDeepHeroDesc,
filterCategories
} = require('../../utils/deepPricingFilter.js')
function appSafe() {
try {
return getApp()
} catch (e) {
return { globalData: {} }
}
}
/** runtime 内嵌的深度类目(与 deep-pricing 同源) */
function embeddedDeepCategories(scope) {
const gd = appSafe().globalData || {}
const list = scope === 'enterprise' ? gd.deepPricingEnterprise : gd.deepPricingPersonal
return Array.isArray(list) && list.length ? list : null
}
function rememberCategoryKeys(cat, seen) {
const id = String((cat && cat.id) || '')
const pk = String((cat && cat.productKey) || '')
if (id) seen.add('id:' + id)
if (pk) seen.add('pk:' + pk)
}
function categorySeenInSet(cat, seen) {
const id = String((cat && cat.id) || '')
const pk = String((cat && cat.productKey) || '')
if (id && seen.has('id:' + id)) return true
if (pk && seen.has('pk:' + pk)) return true
return false
}
/**
* 合并接口与 runtime 类目:缺条、仅 1 条而 runtime 更多时以 runtime 为准;否则按 id/productKey 并集补全(防漏档)
*/
function mergeDeepPricingFromRuntime(scope, apiList) {
let list = Array.isArray(apiList) ? apiList.slice() : []
const emb = embeddedDeepCategories(scope)
if (!emb || !emb.length) return list
if (!list.length) return emb.slice()
if (list.length === 1 && emb.length > list.length) return emb.slice()
const seen = new Set()
list.forEach((c) => rememberCategoryKeys(c, seen))
emb.forEach((c) => {
if (!categorySeenInSet(c, seen)) {
list.push(c)
rememberCategoryKeys(c, seen)
}
})
return list
}
Page({
data: {
activeTab: 'personal',
personalCategories: [],
enterpriseCategories: [],
deepHeroDesc: '',
loading: true,
loadError: false,
loadErrorMsg: '',
@@ -24,49 +80,124 @@ Page({
this.loadDeepPricing()
},
/** 提审模式:深度套餐属虚拟商品,关闭本页并返回 */
_exitPurchaseForAudit() {
if (this._purchaseAuditExitScheduled) return
this._purchaseAuditExitScheduled = true
wx.showToast({ title: '版本审核中暂不可用', icon: 'none' })
this.setData({ loading: false, loadError: false, loadErrorMsg: '' })
setTimeout(() => {
wx.navigateBack({ fail: () => wx.switchTab({ url: '/pages/profile/index' }) })
}, 400)
},
onLoad(options) {
this._purchaseAuditExitScheduled = false
this._deepPersonalRecheckScheduled = false
const app = appSafe()
const tab = (options && options.tab === 'enterprise') ? 'enterprise' : 'personal'
this.setData({ activeTab: tab })
this.setData({
activeTab: tab,
deepHeroDesc: buildDeepHeroDesc(app.globalData.enterprisePermissions)
})
wx.setNavigationBarTitle({ title: '了解自己' })
this.loadDeepPricing()
},
/** 企业 permissions 在 runtime 返回后可能与首屏不同onShow / 拉价完成后重算 */
_reapplyPermissionFilter() {
const app = appSafe()
const rawP = this._rawPersonalCategories
const rawE = this._rawEnterpriseCategories
if ((!rawP || !rawP.length) && (!rawE || !rawE.length)) return
const perms = app.globalData.enterprisePermissions
this.setData({
personalCategories: filterCategories(rawP || [], perms),
enterpriseCategories: filterCategories(rawE || [], perms),
deepHeroDesc: buildDeepHeroDesc(perms)
})
},
onShow() {
// 不在此页强制跳转资料页:避免与付费按钮上的手机号授权打架导致死循环;未绑手机由按钮 open-type 引导
this.setData({ hasPhone: hasPhone() })
if (appSafe().globalData.miniprogramAuditMode) {
this._exitPurchaseForAudit()
return
}
this._reapplyPermissionFilter()
// 网关/缓存曾只返回 1 条时runtime 稍后就绪:首屏仅一条则静默再拉一次,便于露出 VMP 第二档
const raw = this._rawPersonalCategories
if (!this._deepPersonalRecheckScheduled && Array.isArray(raw) && raw.length === 1) {
this._deepPersonalRecheckScheduled = true
setTimeout(() => {
this.loadDeepPricing()
}, 500)
}
},
loadDeepPricing() {
const app = appSafe()
const apiBase = app.globalData.apiBase || ''
if (!apiBase) {
this.setData({ loading: false, loadError: true, loadErrorMsg: '服务地址未配置' })
return
}
this.setData({ loading: true, loadError: false, loadErrorMsg: '' })
Promise.all([
this.requestDeepPricing('personal'),
this.requestDeepPricing('enterprise')
]).then(([personal, enterprise]) => {
const preRuntime =
app.getRuntimeConfig && typeof app.getRuntimeConfig === 'function'
? app.getRuntimeConfig().catch(() => null)
: Promise.resolve(null)
preRuntime.then(() => {
if (appSafe().globalData.miniprogramAuditMode) {
this._exitPurchaseForAudit()
return
}
return Promise.all([this.requestDeepPricing('personal'), this.requestDeepPricing('enterprise')])
}).then((pair) => {
if (!pair) return
const [personal, enterprise] = pair
const pErr = personal && personal.__error
const eErr = enterprise && enterprise.__error
const pList = Array.isArray(personal) ? personal : []
const eList = Array.isArray(enterprise) ? enterprise : []
const bothFailed = pErr && eErr
let pList = Array.isArray(personal) ? personal : []
let eList = Array.isArray(enterprise) ? enterprise : []
if (pErr && embeddedDeepCategories('personal')) {
pList = mergeDeepPricingFromRuntime('personal', [])
personal = pList
}
if (eErr && embeddedDeepCategories('enterprise')) {
eList = mergeDeepPricingFromRuntime('enterprise', [])
enterprise = eList
}
if (!pErr && pList.length) {
pList = mergeDeepPricingFromRuntime('personal', pList)
}
if (!eErr && eList.length) {
eList = mergeDeepPricingFromRuntime('enterprise', eList)
}
const bothFailed = pErr && eErr && !pList.length && !eList.length
const bothEmpty = !pList.length && !eList.length
if (bothFailed || bothEmpty) {
const msg = pErr ? (personal.__errorMsg || '网络异常') : (eErr ? (enterprise.__errorMsg || '网络异常') : '暂无可购买方案')
this._rawPersonalCategories = []
this._rawEnterpriseCategories = []
this.setData({
personalCategories: [],
enterpriseCategories: [],
deepHeroDesc: buildDeepHeroDesc(null),
loading: false,
loadError: true,
loadErrorMsg: msg
})
return
}
this._rawPersonalCategories = pList
this._rawEnterpriseCategories = eList
const perms = appSafe().globalData.enterprisePermissions
this.setData({
personalCategories: pList,
enterpriseCategories: eList,
personalCategories: filterCategories(pList, perms),
enterpriseCategories: filterCategories(eList, perms),
deepHeroDesc: buildDeepHeroDesc(perms),
loading: false,
loadError: false,
loadErrorMsg: ''
@@ -81,20 +212,33 @@ Page({
},
requestDeepPricing(scope) {
const app = appSafe()
return new Promise((resolve) => {
wx.request({
url: `${app.globalData.apiBase.replace(/\/$/, '')}/api/config/deep-pricing`,
method: 'GET',
data: { scope },
timeout: 15000,
timeout: 25000,
success: (res) => {
if (res.statusCode === 200 && res.data && res.data.code === 200 && Array.isArray(res.data.data && res.data.data.categories)) {
resolve(res.data.data.categories)
resolve(mergeDeepPricingFromRuntime(scope, res.data.data.categories))
} else {
const emb = embeddedDeepCategories(scope)
if (emb && emb.length) {
resolve(emb.slice())
return
}
resolve({ __error: true, __errorMsg: (res && res.data && res.data.message) || '响应异常' })
}
},
fail: (err) => resolve({ __error: true, __errorMsg: (err && err.errMsg) || '网络异常' })
fail: (err) => {
const emb = embeddedDeepCategories(scope)
if (emb && emb.length) {
resolve(emb.slice())
return
}
resolve({ __error: true, __errorMsg: (err && err.errMsg) || '网络异常' })
}
})
})
},
@@ -188,11 +332,10 @@ Page({
applyConsult(category) {
const successMsg = (category.successMessage || '感谢您的申请,我们的顾问会尽快与您联系!').trim()
wx.showLoading({ title: '提交中...', mask: true })
const done = () => {
this._reportCrmLead(category, 'consult', () => {
wx.hideLoading()
this._showSuccessModal('申请成功', successMsg)
}
this._reportCrmLead(category, 'consult', done)
})
},
_showSuccessModal(title, content) {
@@ -212,29 +355,41 @@ Page({
},
/**
* 向后端上报存客宝线索,后端负责签名和调用存客宝 API
* @param {Object} category 深度服务类目对象consultWechat 为存客宝 KEY可空由后端按企业配置回落
* @param {string} actionType 'buy'(付款完成)| 'consult'(申请咨询)
* @param {Function} [onDone] 请求结束回调(含失败)
* 向后端上报线索:存客宝(有 KEY 时)+ 飞书群(申请咨询且 deepConsult
* @param {Function} [onDone] 请求结束回调(含失败
*/
_reportCrmLead(category, actionType, onDone) {
const app = appSafe()
const apiKey = (category.consultWechat || '').trim()
const apiBase = app.globalData.apiBase || ''
if (actionType === 'buy' && !apiKey) {
const apiBase = (app.globalData.apiBase || '').replace(/\/$/, '')
const isConsult = actionType === 'consult'
const deepConsult = isConsult
const finish = () => {
if (typeof onDone === 'function') onDone()
}
if (!apiBase) {
finish()
return
}
if (!apiBase) {
if (typeof onDone === 'function') onDone()
if (!deepConsult && !apiKey) {
finish()
return
}
const isEnterprise = this.data.activeTab === 'enterprise'
const source = (isEnterprise ? '企业深度服务' : '个人深度服务') + (category.title ? `-${category.title}` : '')
const remark = actionType === 'buy' ? '完成付款' : '申请咨询'
const remark =
actionType === 'buy'
? '完成付款'
: isEnterprise
? '申请咨询并降低30%成本'
: '申请咨询'
wx.request({
url: `${apiBase.replace(/\/$/, '')}/api/crm/report`,
url: `${apiBase}/api/crm/report`,
method: 'POST',
header: {
Authorization: `Bearer ${wx.getStorageSync('token') || ''}`,
@@ -245,7 +400,7 @@ Page({
source,
remark,
siteTags: category.title || '',
deepConsult: actionType === 'consult',
deepConsult: deepConsult ? true : false
},
success(res) {
console.log('[CRM] 线索上报结果', res.data)
@@ -253,9 +408,7 @@ Page({
fail(err) {
console.warn('[CRM] 线索上报请求失败', err)
},
complete() {
if (typeof onDone === 'function') onDone()
},
complete: finish
})
},

View File

@@ -10,8 +10,9 @@
</view>
<text class="success-dialog-title">{{successModal.title}}</text>
<text class="success-dialog-content">{{successModal.content}}</text>
<view class="success-dialog-btns">
<button class="success-btn-close" bindtap="closeSuccessModal">我知道了</button>
<text class="success-dialog-hint">顾问将通过您预留的联系方式与您沟通,请留意来电或短信。</text>
<view class="success-dialog-btns success-dialog-btns--single">
<button class="success-btn-close success-btn-close--primary" bindtap="closeSuccessModal">我知道了</button>
</view>
</view>
</view>
@@ -34,7 +35,7 @@
<view class="deep-hero">
<text class="deep-hero__eyebrow">UNDERSTAND YOURSELF · 了解自己</text>
<text class="deep-hero__title">更深入地看见你自己</text>
<text class="deep-hero__desc">结合 MBTI / DISC / PDP / SBTI / 面相的综合解读;个人 1v1、团队工作坊、VIP 职业发展三档方案。提交后顾问会主动与你联系。</text>
<text class="deep-hero__desc">{{deepHeroDesc}}</text>
</view>
<view class="tabs-container deep-tabs">
@@ -74,7 +75,7 @@
data-tab="personal"
data-index="{{index}}"
>
<text class="button-text">下一步</text>
<text class="button-text">{{item.purchaseButtonText || '了解自己并付款'}}</text>
</button>
<button
class="purchase-button primary"
@@ -83,7 +84,7 @@
data-tab="personal"
data-index="{{index}}"
>
<text class="button-text">下一步</text>
<text class="button-text">{{item.purchaseButtonText || '了解自己并付款'}}</text>
</button>
</view>
<view wx:if="{{!loading && activeTab === 'personal' && personalCategories.length === 0}}" class="empty-tip">
@@ -118,7 +119,7 @@
data-tab="enterprise"
data-index="{{index}}"
>
<text class="button-text">{{item.buttonText || '预约沟通'}}</text>
<text class="button-text">{{item.buttonText || '申请咨询并降低30%成本'}}</text>
</button>
<button
class="purchase-button secondary"
@@ -127,7 +128,7 @@
data-tab="enterprise"
data-index="{{index}}"
>
<text class="button-text">{{item.buttonText || '预约沟通'}}</text>
<text class="button-text">{{item.buttonText || '申请咨询并降低30%成本'}}</text>
</button>
</view>
<view wx:if="{{!loading && activeTab === 'enterprise' && enterpriseCategories.length === 0}}" class="empty-tip">
@@ -138,15 +139,15 @@
<view class="safety-tips">
<view class="safety-item">
<text class="safety-icon">🔒</text>
<text class="safety-text">流程在微信内完成</text>
<text class="safety-text">全程微信</text>
</view>
<view class="safety-item">
<text class="safety-icon">💼</text>
<text class="safety-text">顾问主动联系</text>
<text class="safety-text">顾问联络</text>
</view>
<view class="safety-item">
<text class="safety-icon">🧑‍💼</text>
<text class="safety-text">团队成本节约 40%+</text>
<text class="safety-text">降本30%+</text>
</view>
</view>
</block>

View File

@@ -55,32 +55,16 @@
color: #555;
text-align: center;
line-height: 1.7;
margin-bottom: 32rpx;
margin-bottom: 16rpx;
}
.success-wechat-wrap {
width: 100%;
background: #f5f5f5;
border-radius: 12rpx;
padding: 20rpx 24rpx;
margin-bottom: 32rpx;
display: flex;
align-items: center;
gap: 16rpx;
}
.success-wechat-label {
.success-dialog-hint {
font-size: 24rpx;
color: #999;
flex-shrink: 0;
}
.success-wechat-val {
font-size: 28rpx;
color: #333;
font-weight: 500;
flex: 1;
word-break: break-all;
color: #94a3b8;
text-align: center;
line-height: 1.6;
margin-bottom: 28rpx;
padding: 0 8rpx;
}
.success-dialog-btns {
@@ -90,19 +74,13 @@
gap: 16rpx;
}
.success-btn-copy {
flex: 1;
background: #2563eb;
color: #fff;
font-size: 28rpx;
font-weight: 500;
padding: 24rpx 0;
border-radius: 12rpx;
border: none;
.success-dialog-btns--single {
flex-direction: column;
}
.success-btn-close {
flex: 1;
width: 100%;
background: #f5f5f5;
color: #333;
font-size: 28rpx;
@@ -111,6 +89,12 @@
border: none;
}
.success-btn-close--primary {
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
color: #fff;
font-weight: 600;
}
.container {
width: 100vw;
@@ -736,28 +720,38 @@
color: #9333ea;
}
/* 底部安全提示 */
/* 底部安全提示(短文案 + 单列居中,避免挤换行) */
.safety-tips {
display: flex;
justify-content: center;
gap: 32rpx;
padding: 24rpx;
margin: 24rpx;
justify-content: space-between;
align-items: flex-start;
gap: 12rpx;
padding: 20rpx 16rpx;
margin: 20rpx 16rpx 32rpx;
box-sizing: border-box;
}
.safety-item {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
justify-content: flex-start;
gap: 6rpx;
text-align: center;
}
.safety-icon {
font-size: 24rpx;
font-size: 22rpx;
line-height: 1;
}
.safety-text {
font-size: 22rpx;
font-size: 20rpx;
color: #999;
line-height: 1.2;
white-space: nowrap;
}
.privileges {

View File

@@ -2,7 +2,12 @@
const app = getApp()
const payment = require('../../utils/payment')
const { getTypeOnly } = require('../../utils/resultFormat')
const { isReportProfileComplete } = require('../../utils/phoneAuth.js')
const {
hasPhone,
bindPhoneByCode,
needsResultProfileGate,
navigateToCompleteProfileAfterPhoneIfNeeded
} = require('../../utils/phoneAuth.js')
const {
slicePreviewText,
slicePreviewList,
@@ -56,6 +61,7 @@ Page({
shareToken: '',
hasReloadedAfterPay: false,
fromShare: false,
hasPhone: false,
profileGate: false,
previewDiscDescription: '',
previewDiscStrengths: [],
@@ -202,19 +208,23 @@ Page({
},
onShow() {
this.setData({ hasPhone: hasPhone() })
if (this.data.testResultId && this.data.result) {
this._syncDiscGate()
} else if (!this.data.testResultId) {
const raw = wx.getStorageSync('discResult')
if (!raw) return
const r = withPercentagesInt(raw)
this.setData({ result: r, typeSummaryLine: getTypeOnly(raw, 'disc') })
this._syncDiscGate()
}
this._syncJourney()
if (this.data.testResultId) return
const raw = wx.getStorageSync('discResult')
if (!raw) return
const r = withPercentagesInt(raw)
this.setData({ result: r, typeSummaryLine: getTypeOnly(raw, 'disc') })
this._syncDiscGate()
},
_syncDiscGate() {
const r = this.data.result
const fromShare = !!this.data.fromShare
const profileGate = !fromShare && !isReportProfileComplete()
const profileGate = needsResultProfileGate(fromShare)
const desc = (r && r.description) || {}
const code = (r && (r.dominantType || r.disc)) || ''
this.setData({
@@ -239,10 +249,41 @@ Page({
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() })
if (this.data.testResultId) {
this._syncDiscGate()
} else {
const raw = wx.getStorageSync('discResult')
if (raw) {
const r = withPercentagesInt(raw)
this.setData({ result: r, typeSummaryLine: getTypeOnly(raw, 'disc') })
this._syncDiscGate()
}
}
navigateToCompleteProfileAfterPhoneIfNeeded()
})
.catch(() => {})
},
onTapReadFull() {
try { require('../../utils/analytics').track('tap_read_full', { type: 'disc' }) } catch (e) {}
if (this.data.profileGate) {
this.goCompleteProfile()
wx.showToast({
title: this.data.hasPhone ? '请先完善头像与昵称' : '请先点击下方「登录解锁全文」',
icon: 'none'
})
return
}
if (this.data.payInfo.requiresPayment && !this.data.payInfo.isPaid) {

View File

@@ -1,10 +1,5 @@
{
"navigationBarTitleText": "DISC结果",
"navigationBarBackgroundColor": "#3B82F6",
"navigationBarTextStyle": "white",
"enableShareAppMessage": true,
"enableShareTimeline": true,
"usingComponents": {
"result-section-nav": "/components/result-section-nav/index"
}
"navigationBarTextStyle": "white"
}

View File

@@ -106,10 +106,10 @@
<view class="type-card" wx:elif="{{result && result.locked && !fromShare && !profileGate}}">
<view class="paywall-card">
<view class="paywall-content">
<text class="paywall-fake-title">完整报告需完善资料</text>
<text class="paywall-fake-line">请补全头像、昵称并绑定手机号后查看完整 DISC 报告。</text>
<text class="paywall-fake-title">完整报告暂不可查看</text>
<text class="paywall-fake-line">请稍后在「我的」重试;若刚授权手机,可下拉刷新本页。</text>
<view class="paywall-btn" bindtap="goCompleteProfile">
<text class="paywall-btn-main">去完善资料</text>
<text class="paywall-btn-main">去个人资料</text>
</view>
</view>
</view>
@@ -144,10 +144,22 @@
<text class="preview-strength-dot">•</text>
<text class="preview-strength-text">{{item}}</text>
</view>
<text class="preview-teaser-hint">完善头像、昵称并绑定手机号后可查看完整解读与职业建议。</text>
<text class="preview-teaser-hint">授权手机号并设置头像、昵称后即可查看全文(与下方按钮为同一条流程)。</text>
</view>
<button class="paywall-btn paywall-btn--inline-profile" bindtap="goCompleteProfile">
<text class="paywall-btn-main">去完善资料 · 解锁全文</text>
<button
wx:if="{{!hasPhone}}"
class="paywall-btn paywall-btn--inline-profile"
open-type="getPhoneNumber"
bindgetphonenumber="onPhoneLoginForResultGate"
>
<text class="paywall-btn-main">登录解锁全文</text>
</button>
<button
wx:else
class="paywall-btn paywall-btn--inline-profile"
bindtap="goCompleteProfile"
>
<text class="paywall-btn-main">完善资料 · 查看全文</text>
</button>
</view>
@@ -230,7 +242,7 @@
<view class="result-bottom-tools__row">
<view class="result-tool-btn result-tool-btn--primary" bindtap="onTapReadFull">
<text>{{journey.step1Unlocked ? '查看全文' : '① 解锁全文'}}</text>
<text class="result-tool-sub">{{journey.step1Unlocked ? '已解锁' : (profileGate ? '完善资料解锁' : '点击解锁')}}</text>
<text class="result-tool-sub">{{journey.step1Unlocked ? '已解锁' : (profileGate ? (hasPhone ? '完善资料解锁' : '登录并完善资料') : '点击解锁')}}</text>
</view>
<view class="result-tool-btn {{journey.step1Unlocked ? 'result-tool-btn--rose' : 'result-tool-btn--locked'}}" bindtap="onTapShareMoment">
<text><text wx:if="{{!journey.step1Unlocked}}" class="result-tool-btn__lock">🔒</text>朋友圈</text>

View File

@@ -1,5 +1,4 @@
/* pages/result/disc.wxss - 按旧版模板重构 */
@import "../../styles/result-page-dashboard.wxss";
/* pages/result/disc.wxssdash 公共样式由 app.wxss 全局引入,避免工具链解析 @import 失败导致白屏) */
.result-page {
width: 100%;

View File

@@ -1,7 +1,12 @@
// pages/result/mbti.js - MBTI结果页支持付费墙 + 历史详情拉取)
const app = getApp()
const payment = require('../../utils/payment')
const { hasPhone, bindPhoneByCode, isReportProfileComplete } = require('../../utils/phoneAuth.js')
const {
hasPhone,
bindPhoneByCode,
needsResultProfileGate,
navigateToCompleteProfileAfterPhoneIfNeeded
} = require('../../utils/phoneAuth.js')
const {
slicePreviewText,
slicePreviewList,
@@ -114,12 +119,13 @@ Page({
onShow() {
this.setData({ hasPhone: hasPhone() })
this._syncJourney()
if (this.data.testResultId) return
const raw = wx.getStorageSync('mbtiResult')
if (raw) {
this.applyResult(raw)
if (this.data.testResultId && this.data.result) {
this.applyResult(this.data.result)
} else if (!this.data.testResultId) {
const raw = wx.getStorageSync('mbtiResult')
if (raw) this.applyResult(raw)
}
this._syncJourney()
},
goCompleteProfile() {
@@ -206,7 +212,7 @@ Page({
if (!result) return
const desc = result.description || {}
const fromShare = !!this.data.fromShare
const profileGate = !fromShare && !isReportProfileComplete()
const profileGate = needsResultProfileGate(fromShare)
const dimOk =
result.dimensionScores &&
(!result.locked || profileGate)
@@ -287,10 +293,33 @@ Page({
this.setData({ journey: j })
},
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('mbtiResult')
if (r) this.applyResult(r)
navigateToCompleteProfileAfterPhoneIfNeeded()
})
.catch(() => {})
},
onTapReadFull() {
try { require('../../utils/analytics').track('tap_read_full', { type: 'mbti' }) } catch (e) {}
if (this.data.profileGate) {
this.goCompleteProfile()
wx.showToast({
title: this.data.hasPhone ? '请先完善头像与昵称' : '请先点击下方「登录解锁全文」',
icon: 'none'
})
return
}
if (this.data.payInfo.requiresPayment && !this.data.payInfo.isPaid) {
@@ -402,7 +431,7 @@ Page({
}
bindPhoneByCode(code)
.then(() => {
this.setData({ hasPhone: true })
this.setData({ hasPhone: hasPhone() })
this.unlockFullReport()
})
.catch(() => {

View File

@@ -1,10 +1,5 @@
{
"navigationBarTitleText": "MBTI结果",
"navigationBarBackgroundColor": "#FF6B8A",
"navigationBarTextStyle": "white",
"enableShareAppMessage": true,
"enableShareTimeline": true,
"usingComponents": {
"result-section-nav": "/components/result-section-nav/index"
}
"navigationBarTextStyle": "white"
}

View File

@@ -119,10 +119,10 @@
<view class="type-card" wx:elif="{{result && result.locked && !fromShare && !profileGate}}">
<view class="paywall-card">
<view class="paywall-content">
<text class="paywall-fake-title">完整报告需完善资料</text>
<text class="paywall-fake-line">请补全头像、昵称并绑定手机号后查看完整性格分析。</text>
<text class="paywall-fake-title">完整报告暂不可查看</text>
<text class="paywall-fake-line">请稍后在「我的」重试或联系客服;若刚授权手机,可下拉刷新本页。</text>
<button class="paywall-btn" bindtap="goCompleteProfile">
<text class="paywall-btn-main">去完善资料</text>
<text class="paywall-btn-main">去个人资料</text>
</button>
</view>
</view>
@@ -161,10 +161,22 @@
<text class="preview-strength-dot">•</text>
<text class="preview-strength-text">{{item}}</text>
</view>
<text class="preview-teaser-hint">完善头像、昵称并绑定手机号后可看全文与分享完整卡片。</text>
<text class="preview-teaser-hint">授权手机号并设置头像、昵称后即可查看全文(与下方按钮为同一条流程)。</text>
</view>
<button class="paywall-btn paywall-btn--inline-profile" bindtap="goCompleteProfile">
<text class="paywall-btn-main">去完善资料 · 解锁全文</text>
<button
wx:if="{{!hasPhone}}"
class="paywall-btn paywall-btn--inline-profile"
open-type="getPhoneNumber"
bindgetphonenumber="onPhoneLoginForResultGate"
>
<text class="paywall-btn-main">登录解锁全文</text>
</button>
<button
wx:else
class="paywall-btn paywall-btn--inline-profile"
bindtap="goCompleteProfile"
>
<text class="paywall-btn-main">完善资料 · 查看全文</text>
</button>
</view>
@@ -287,7 +299,7 @@
<view class="result-bottom-tools__row">
<view class="result-tool-btn result-tool-btn--primary" bindtap="onTapReadFull">
<text>{{journey.step1Unlocked ? '查看全文' : '① 解锁全文'}}</text>
<text class="result-tool-sub">{{journey.step1Unlocked ? '已解锁' : (profileGate ? '完善资料解锁' : '点击解锁')}}</text>
<text class="result-tool-sub">{{journey.step1Unlocked ? '已解锁' : (profileGate ? (hasPhone ? '完善资料解锁' : '登录并完善资料') : '点击解锁')}}</text>
</view>
<view class="result-tool-btn {{journey.step1Unlocked ? 'result-tool-btn--rose' : 'result-tool-btn--locked'}}" bindtap="onTapShareMoment">
<text><text wx:if="{{!journey.step1Unlocked}}" class="result-tool-btn__lock">🔒</text>朋友圈</text>

View File

@@ -1,5 +1,4 @@
/* pages/result/mbti.wxss - 按旧版模板重构 */
@import "../../styles/result-page-dashboard.wxss";
/* pages/result/mbti.wxssdash 样式见 app.wxss */
.result-page {
width: 100%;

View File

@@ -2,7 +2,12 @@
const app = getApp()
const payment = require('../../utils/payment')
const { getTypeOnly } = require('../../utils/resultFormat')
const { isReportProfileComplete } = require('../../utils/phoneAuth.js')
const {
hasPhone,
bindPhoneByCode,
needsResultProfileGate,
navigateToCompleteProfileAfterPhoneIfNeeded
} = require('../../utils/phoneAuth.js')
const {
slicePreviewText,
slicePreviewList,
@@ -55,6 +60,7 @@ Page({
shareToken: '',
hasReloadedAfterPay: false,
fromShare: false,
hasPhone: false,
profileGate: false,
previewPdpDescription: '',
previewPdpStrengths: [],
@@ -203,21 +209,25 @@ Page({
},
onShow() {
this.setData({ hasPhone: hasPhone() })
if (this.data.testResultId && this.data.result) {
this._syncPdpGate()
} else if (!this.data.testResultId) {
const raw = wx.getStorageSync('pdpResult')
if (!raw) return
this.setData({
result: withPercentagesInt(raw),
typeSummaryLine: getTypeOnly(raw, 'pdp')
})
this._syncPdpGate()
}
this._syncJourney()
if (this.data.testResultId) return
const raw = wx.getStorageSync('pdpResult')
if (!raw) return
this.setData({
result: withPercentagesInt(raw),
typeSummaryLine: getTypeOnly(raw, 'pdp')
})
this._syncPdpGate()
},
_syncPdpGate() {
const r = this.data.result
const fromShare = !!this.data.fromShare
const profileGate = !fromShare && !isReportProfileComplete()
const profileGate = needsResultProfileGate(fromShare)
const desc = (r && r.description) || {}
const code = (r && (r.dominantType || (desc && desc.type))) || ''
this.setData({
@@ -242,10 +252,43 @@ Page({
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() })
if (this.data.testResultId) {
this._syncPdpGate()
} else {
const raw = wx.getStorageSync('pdpResult')
if (raw) {
this.setData({
result: withPercentagesInt(raw),
typeSummaryLine: getTypeOnly(raw, 'pdp')
})
this._syncPdpGate()
}
}
navigateToCompleteProfileAfterPhoneIfNeeded()
})
.catch(() => {})
},
onTapReadFull() {
try { require('../../utils/analytics').track('tap_read_full', { type: 'pdp' }) } catch (e) {}
if (this.data.profileGate) {
this.goCompleteProfile()
wx.showToast({
title: this.data.hasPhone ? '请先完善头像与昵称' : '请先点击下方「登录解锁全文」',
icon: 'none'
})
return
}
if (this.data.payInfo.requiresPayment && !this.data.payInfo.isPaid) {

View File

@@ -1,10 +1,5 @@
{
"navigationBarTitleText": "PDP结果",
"navigationBarBackgroundColor": "#F59E0B",
"navigationBarTextStyle": "white",
"enableShareAppMessage": true,
"enableShareTimeline": true,
"usingComponents": {
"result-section-nav": "/components/result-section-nav/index"
}
"navigationBarTextStyle": "white"
}

View File

@@ -106,10 +106,10 @@
<view class="type-card" wx:elif="{{result && result.locked && !fromShare && !profileGate}}">
<view class="paywall-card">
<view class="paywall-content">
<text class="paywall-fake-title">完整报告需完善资料</text>
<text class="paywall-fake-line">请补全头像、昵称并绑定手机号后查看完整 PDP 报告。</text>
<text class="paywall-fake-title">完整报告暂不可查看</text>
<text class="paywall-fake-line">请稍后在「我的」重试;若刚授权手机,可下拉刷新本页。</text>
<view class="paywall-btn" bindtap="goCompleteProfile">
<text class="paywall-btn-main">去完善资料</text>
<text class="paywall-btn-main">去个人资料</text>
</view>
</view>
</view>
@@ -144,10 +144,22 @@
<text class="preview-strength-dot">•</text>
<text class="preview-strength-text">{{item}}</text>
</view>
<text class="preview-teaser-hint">完善头像、昵称并绑定手机号后可查看团队角色与推荐职业全文。</text>
<text class="preview-teaser-hint">授权手机号并设置头像、昵称后即可查看全文(与下方按钮为同一条流程)。</text>
</view>
<button class="paywall-btn paywall-btn--inline-profile" bindtap="goCompleteProfile">
<text class="paywall-btn-main">去完善资料 · 解锁全文</text>
<button
wx:if="{{!hasPhone}}"
class="paywall-btn paywall-btn--inline-profile"
open-type="getPhoneNumber"
bindgetphonenumber="onPhoneLoginForResultGate"
>
<text class="paywall-btn-main">登录解锁全文</text>
</button>
<button
wx:else
class="paywall-btn paywall-btn--inline-profile"
bindtap="goCompleteProfile"
>
<text class="paywall-btn-main">完善资料 · 查看全文</text>
</button>
</view>
@@ -243,7 +255,7 @@
<view class="result-bottom-tools__row">
<view class="result-tool-btn result-tool-btn--primary" bindtap="onTapReadFull">
<text>{{journey.step1Unlocked ? '查看全文' : '① 解锁全文'}}</text>
<text class="result-tool-sub">{{journey.step1Unlocked ? '已解锁' : (profileGate ? '完善资料解锁' : '点击解锁')}}</text>
<text class="result-tool-sub">{{journey.step1Unlocked ? '已解锁' : (profileGate ? (hasPhone ? '完善资料解锁' : '登录并完善资料') : '点击解锁')}}</text>
</view>
<view class="result-tool-btn {{journey.step1Unlocked ? 'result-tool-btn--rose' : 'result-tool-btn--locked'}}" bindtap="onTapShareMoment">
<text><text wx:if="{{!journey.step1Unlocked}}" class="result-tool-btn__lock">🔒</text>朋友圈</text>

View File

@@ -1,5 +1,4 @@
/* pages/result/pdp.wxss - 按旧版模板重构 */
@import "../../styles/result-page-dashboard.wxss";
/* pages/result/pdp.wxssdash 样式见 app.wxss */
.result-page {
width: 100%;

View File

@@ -1,7 +1,12 @@
// pages/result/sbti.js — SBTI 结果页
const app = getApp()
const payment = require('../../utils/payment')
const { hasPhone, bindPhoneByCode, isReportProfileComplete } = require('../../utils/phoneAuth.js')
const {
hasPhone,
bindPhoneByCode,
needsResultProfileGate,
navigateToCompleteProfileAfterPhoneIfNeeded
} = require('../../utils/phoneAuth.js')
const {
slicePreviewText,
openTimelineShareHint
@@ -60,7 +65,7 @@ Page({
shareToken: '',
hasReloadedAfterPay: false,
hasPhone: false,
/** 分享落地path 带 fs=1 或旧版仅 id+st用于隐藏「去完善资料」、展示底部「我也要测试」 */
/** 分享落地path 带 fs=1 或旧版仅 id+st用于隐藏「登录解锁」区、展示底部「我也要测试」 */
fromShare: false,
profileGate: false,
previewSbtiIntro: '',
@@ -137,12 +142,13 @@ Page({
onShow() {
this.setData({ hasPhone: hasPhone() })
this._syncJourney()
if (this.data.testResultId) return
const raw = wx.getStorageSync('sbtiResult')
if (raw) {
this.applyResult(raw)
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() {
@@ -228,7 +234,7 @@ Page({
if (!result) return
const normalized = normalizeSbtiResultForDisplay(result)
const fromShare = !!this.data.fromShare
const profileGate = !fromShare && !isReportProfileComplete()
const profileGate = needsResultProfileGate(fromShare)
const src = Array.isArray(normalized.dimExplainList) ? normalized.dimExplainList : []
const allowDims = profileGate || !normalized.locked
let dimExplainList = []
@@ -275,10 +281,33 @@ Page({
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) {
this.goCompleteProfile()
wx.showToast({
title: this.data.hasPhone ? '请先完善头像与昵称' : '请先点击下方「登录解锁全文」',
icon: 'none'
})
return
}
if (this.data.payInfo.requiresPayment && !this.data.payInfo.isPaid) {
@@ -397,7 +426,7 @@ Page({
}
bindPhoneByCode(code)
.then(() => {
this.setData({ hasPhone: true })
this.setData({ hasPhone: hasPhone() })
this.unlockFullReport()
})
.catch(() => {})

View File

@@ -2,10 +2,5 @@
"navigationBarTitleText": "SBTI结果",
"navigationBarBackgroundColor": "#f2f7f3",
"navigationBarTextStyle": "black",
"backgroundColor": "#f2f7f3",
"enableShareAppMessage": true,
"enableShareTimeline": true,
"usingComponents": {
"result-section-nav": "/components/result-section-nav/index"
}
"backgroundColor": "#f2f7f3"
}

View File

@@ -76,10 +76,10 @@
<view class="card paywall-card" wx:elif="{{result && result.locked && !fromShare && !profileGate}}">
<view class="paywall-content">
<text class="paywall-fake-title">完整报告需完善资料</text>
<text class="paywall-fake-line">请补全头像、昵称并绑定手机号后查看完整解读。</text>
<text class="paywall-fake-title">完整报告暂不可查看</text>
<text class="paywall-fake-line">请稍后在「我的」重试;若刚授权手机,可下拉刷新本页。</text>
<button class="paywall-btn" bindtap="goCompleteProfile">
<text class="paywall-btn-main">去完善资料</text>
<text class="paywall-btn-main">去个人资料</text>
</button>
</view>
</view>
@@ -141,9 +141,21 @@
</block>
<view wx:if="{{profileGate}}" class="card profile-gate-cta-card">
<text class="preview-teaser-hint sbti-gate-hint">完善头像、昵称并绑定手机号后,可查看全部维度解读与分享完整卡片。</text>
<button class="paywall-btn paywall-btn--inline-profile" bindtap="goCompleteProfile">
<text class="paywall-btn-main">去完善资料 · 解锁全文</text>
<text class="preview-teaser-hint sbti-gate-hint">授权手机号并设置头像、昵称后即可查看全文(与下方按钮为同一条流程)。</text>
<button
wx:if="{{!hasPhone}}"
class="paywall-btn paywall-btn--inline-profile"
open-type="getPhoneNumber"
bindgetphonenumber="onPhoneLoginForResultGate"
>
<text class="paywall-btn-main">登录解锁全文</text>
</button>
<button
wx:else
class="paywall-btn paywall-btn--inline-profile"
bindtap="goCompleteProfile"
>
<text class="paywall-btn-main">完善资料 · 查看全文</text>
</button>
</view>
@@ -184,7 +196,7 @@
<view class="result-bottom-tools__row">
<view class="result-tool-btn result-tool-btn--primary" bindtap="onTapReadFull">
<text>{{journey.step1Unlocked ? '查看全文' : '① 解锁全文'}}</text>
<text class="result-tool-sub">{{journey.step1Unlocked ? '已解锁' : (profileGate ? '完善资料解锁' : '点击解锁')}}</text>
<text class="result-tool-sub">{{journey.step1Unlocked ? '已解锁' : (profileGate ? (hasPhone ? '完善资料解锁' : '登录并完善资料') : '点击解锁')}}</text>
</view>
<view class="result-tool-btn {{journey.step1Unlocked ? 'result-tool-btn--rose' : 'result-tool-btn--locked'}}" bindtap="onTapShareMoment">
<text><text wx:if="{{!journey.step1Unlocked}}" class="result-tool-btn__lock">🔒</text>朋友圈</text>

View File

@@ -1,5 +1,4 @@
/* pages/result/sbti.wxss */
@import "../../styles/result-page-dashboard.wxss";
/* pages/result/sbti.wxssdash 样式见 app.wxss */
.result-page {
width: 100%;

View File

@@ -1,5 +1,6 @@
// pages/test-select/index.js
const app = getApp()
const { isAuditHideAiMode } = require('../../utils/miniprogramAuditGate.js')
Page({
data: {
@@ -8,9 +9,9 @@ Page({
permSbti: true,
permPdp: true,
permDisc: true,
/** AI 测评聚合页(对话 / 拍照);企业显式关闭 aiHub 时隐藏 */
/** AI 对话(神仙 AI);企业关闭 aiHub 时隐藏 */
permAiHub: true,
/** 四类入口均被企业权限关闭时提示,避免误以为白屏 */
/** 四类问卷入口均被企业权限关闭时提示 */
allTestsDisabled: false
},
@@ -27,17 +28,22 @@ Page({
},
onShow() {
this._syncPerms()
if (app.getRuntimeConfig) {
app.getRuntimeConfig().finally(() => this._syncPerms())
} else {
this._syncPerms()
}
},
_syncPerms() {
const p = app.globalData.enterprisePermissions
const hideAi = isAuditHideAiMode(app.globalData)
const permFace = !p || p.face !== false
const permMbti = !p || p.mbti !== false
const permSbti = !p || p.sbti !== false
const permPdp = !p || p.pdp !== false
const permDisc = !p || p.disc !== false
const permAiHub = !p || p.aiHub !== false
const permAiHub = (!p || p.aiHub !== false) && !hideAi
this.setData({
permFace,
permMbti,
@@ -73,13 +79,15 @@ Page({
wx.navigateTo({ url: '/pages/test/disc' })
},
/** AI 对话解读(神仙 AI */
goAIChatInterpretation() {
if (isAuditHideAiMode(getApp().globalData)) {
wx.showToast({ title: '功能升级中', icon: 'none' })
return
}
this._trackSelect('ai_chat')
wx.navigateTo({ url: '/pages/ai-chat/index?src=test_select' })
},
/** 拍照面相分析(与底部「拍摄」同页) */
goAIFaceAnalysis() {
this._trackSelect('ai_face')
wx.switchTab({

View File

@@ -1,131 +1,131 @@
/* pages/test-select/index.wxss - 详细性格测试入口,与现有 UI 风格一致 */
.container {
min-height: 100vh;
background: linear-gradient(180deg, #fff5f5 0%, #f5f5f5 50%, #ffffff 100%);
padding: 24rpx 24rpx 60rpx;
box-sizing: border-box;
}
.card {
background-color: #fff;
border-radius: 16rpx;
padding: 32rpx;
margin-bottom: 24rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
}
/* 顶部说明 */
.intro-card {
text-align: center;
padding: 40rpx 32rpx;
}
.intro-title {
display: block;
font-size: 34rpx;
font-weight: 700;
color: #e63946;
margin-bottom: 16rpx;
}
.intro-desc {
display: block;
font-size: 26rpx;
color: #666;
line-height: 1.6;
}
.perm-disabled-hint {
text-align: center;
padding: 32rpx;
}
.perm-disabled-text {
font-size: 28rpx;
color: #888;
line-height: 1.6;
}
/* 入口卡片(与 result 页 next-btn 同色系) */
.entry-card {
display: flex;
align-items: center;
padding: 28rpx 32rpx;
transition: opacity 0.2s;
border-radius: 16rpx;
border: 2rpx solid transparent;
}
.entry-card:active {
opacity: 0.9;
background: linear-gradient(135deg, rgba(230, 57, 70, 0.04) 0%, rgba(255, 107, 157, 0.04) 100%);
border-color: rgba(230, 57, 70, 0.15);
}
.entry-icon-wrap {
width: 88rpx;
height: 88rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
flex-shrink: 0;
}
.entry-icon-wrap.mbti {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.15) 0%, rgba(139, 92, 246, 0.2) 100%);
}
.entry-icon-wrap.sbti {
background: linear-gradient(135deg, #f2f7f3 0%, #dce8e0 100%);
border: 1rpx solid #c5d4cc;
}
.entry-icon-wrap.pdp {
background: linear-gradient(135deg, rgba(230, 57, 70, 0.12) 0%, rgba(255, 107, 157, 0.15) 100%);
}
.entry-icon-wrap.disc {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(96, 165, 250, 0.18) 100%);
}
.entry-icon-wrap.ai-chat {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.16) 0%, rgba(99, 102, 241, 0.2) 100%);
}
.entry-icon-wrap.ai-face {
background: linear-gradient(135deg, rgba(236, 72, 153, 0.14) 0%, rgba(244, 114, 182, 0.18) 100%);
}
.entry-emoji {
font-size: 44rpx;
}
.entry-content {
flex: 1;
min-width: 0;
}
.entry-name {
display: block;
font-size: 30rpx;
font-weight: 600;
color: #333;
margin-bottom: 8rpx;
}
.entry-brief {
display: block;
font-size: 24rpx;
color: #888;
line-height: 1.4;
}
.entry-arrow {
font-size: 32rpx;
color: #e63946;
font-weight: 600;
margin-left: 16rpx;
flex-shrink: 0;
}
/* pages/test-select/index.wxss - 详细性格测试入口,与现有 UI 风格一致 */
.container {
min-height: 100vh;
background: linear-gradient(180deg, #fff5f5 0%, #f5f5f5 50%, #ffffff 100%);
padding: 24rpx 24rpx 60rpx;
box-sizing: border-box;
}
.card {
background-color: #fff;
border-radius: 16rpx;
padding: 32rpx;
margin-bottom: 24rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
}
/* 顶部说明 */
.intro-card {
text-align: center;
padding: 40rpx 32rpx;
}
.intro-title {
display: block;
font-size: 34rpx;
font-weight: 700;
color: #e63946;
margin-bottom: 16rpx;
}
.intro-desc {
display: block;
font-size: 26rpx;
color: #666;
line-height: 1.6;
}
.perm-disabled-hint {
text-align: center;
padding: 32rpx;
}
.perm-disabled-text {
font-size: 28rpx;
color: #888;
line-height: 1.6;
}
/* 入口卡片(与 result 页 next-btn 同色系) */
.entry-card {
display: flex;
align-items: center;
padding: 28rpx 32rpx;
transition: opacity 0.2s;
border-radius: 16rpx;
border: 2rpx solid transparent;
}
.entry-card:active {
opacity: 0.9;
background: linear-gradient(135deg, rgba(230, 57, 70, 0.04) 0%, rgba(255, 107, 157, 0.04) 100%);
border-color: rgba(230, 57, 70, 0.15);
}
.entry-icon-wrap {
width: 88rpx;
height: 88rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
flex-shrink: 0;
}
.entry-icon-wrap.mbti {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.15) 0%, rgba(139, 92, 246, 0.2) 100%);
}
.entry-icon-wrap.sbti {
background: linear-gradient(135deg, #f2f7f3 0%, #dce8e0 100%);
border: 1rpx solid #c5d4cc;
}
.entry-icon-wrap.pdp {
background: linear-gradient(135deg, rgba(230, 57, 70, 0.12) 0%, rgba(255, 107, 157, 0.15) 100%);
}
.entry-icon-wrap.disc {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(96, 165, 250, 0.18) 100%);
}
.entry-icon-wrap.ai-chat {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.16) 0%, rgba(99, 102, 241, 0.2) 100%);
}
.entry-icon-wrap.ai-face {
background: linear-gradient(135deg, rgba(236, 72, 153, 0.14) 0%, rgba(244, 114, 182, 0.18) 100%);
}
.entry-emoji {
font-size: 44rpx;
}
.entry-content {
flex: 1;
min-width: 0;
}
.entry-name {
display: block;
font-size: 30rpx;
font-weight: 600;
color: #333;
margin-bottom: 8rpx;
}
.entry-brief {
display: block;
font-size: 24rpx;
color: #888;
line-height: 1.4;
}
.entry-arrow {
font-size: 32rpx;
color: #e63946;
font-weight: 600;
margin-left: 16rpx;
flex-shrink: 0;
}

View File

@@ -1,6 +1,7 @@
// pages/test/disc.js
const { loadQuestions } = require('../../utils/questionBank')
const { discDescriptions } = require('../../utils/descriptions')
const { afterTestSubmitNavigate } = require('../../utils/phoneAuth')
const app = getApp()
const DISC_TIME_SEC = 15 * 60
@@ -201,14 +202,11 @@ Page({
if (app && typeof app.saveTestResult === 'function') {
app.saveTestResult('disc', resultData).then((extra) => {
const rid = extra && extra.id
if (rid) {
wx.redirectTo({ url: `/pages/result/disc?id=${rid}&type=disc` })
} else {
wx.redirectTo({ url: '/pages/result/disc' })
}
const target = rid ? `/pages/result/disc?id=${rid}&type=disc` : '/pages/result/disc'
afterTestSubmitNavigate(target)
})
} else {
wx.redirectTo({ url: '/pages/result/disc' })
afterTestSubmitNavigate('/pages/result/disc')
}
},

View File

@@ -2,6 +2,7 @@
const { loadQuestions } = require('../../utils/questionBank')
const { mbtiDescriptions } = require('../../utils/descriptions')
const payment = require('../../utils/payment')
const { afterTestSubmitNavigate } = require('../../utils/phoneAuth')
const app = getApp()
const MBTI_TIME_SEC = 30 * 60 // 30 分钟
@@ -257,11 +258,8 @@ Page({
app.saveTestResult('mbti', resultData).then((extra) => {
const rid = extra && extra.id
if (rid) {
wx.redirectTo({ url: `/pages/result/mbti?id=${rid}&type=mbti` })
} else {
wx.redirectTo({ url: '/pages/result/mbti' })
}
const target = rid ? `/pages/result/mbti?id=${rid}&type=mbti` : '/pages/result/mbti'
afterTestSubmitNavigate(target)
})
},

View File

@@ -1,6 +1,7 @@
// pages/test/pdp.js
const { loadQuestions } = require('../../utils/questionBank')
const { pdpDescriptions } = require('../../utils/descriptions')
const { afterTestSubmitNavigate } = require('../../utils/phoneAuth')
const app = getApp()
const PDP_TIME_SEC = 15 * 60
@@ -204,14 +205,11 @@ Page({
if (app && typeof app.saveTestResult === 'function') {
app.saveTestResult('pdp', resultData).then((extra) => {
const rid = extra && extra.id
if (rid) {
wx.redirectTo({ url: `/pages/result/pdp?id=${rid}&type=pdp` })
} else {
wx.redirectTo({ url: '/pages/result/pdp' })
}
const target = rid ? `/pages/result/pdp?id=${rid}&type=pdp` : '/pages/result/pdp'
afterTestSubmitNavigate(target)
})
} else {
wx.redirectTo({ url: '/pages/result/pdp' })
afterTestSubmitNavigate('/pages/result/pdp')
}
},

View File

@@ -1,6 +1,7 @@
// pages/test/sbti.js — SBTI服务端全量拉题 + 组卷(闸口 DG1/DG2+ sbtiEngine 计分
const { fetchQuestionBank } = require('../../utils/questionBank')
const { buildShuffledPaper, getVisibleQuestions, computeSbtiResult } = require('../../utils/sbtiEngine')
const { afterTestSubmitNavigate } = require('../../utils/phoneAuth')
const app = getApp()
const SBTI_TIME_SEC = 45 * 60
@@ -256,11 +257,8 @@ Page({
app.saveTestResult('sbti', resultData).then((extra) => {
const rid = extra && extra.id
if (rid) {
wx.redirectTo({ url: `/pages/result/sbti?id=${rid}&type=sbti` })
} else {
wx.redirectTo({ url: '/pages/result/sbti' })
}
const target = rid ? `/pages/result/sbti?id=${rid}&type=sbti` : '/pages/result/sbti'
afterTestSubmitNavigate(target)
})
},

View File

@@ -1,227 +1,242 @@
// pages/user-profile/index.js - 个人资料页
const app = getApp()
const { request } = require('../../utils/request')
const { bindPhoneByCode } = require('../../utils/phoneAuth')
Page({
data: {
userInfo: null,
nickname: '',
avatar: '',
birthday: '',
gender: 0,
genderIndex: 0,
genderText: '',
genderOptions: ['保密', '男', '女'],
phone: '',
avatarLetter: '我',
avatarBgColor: '#6366f1',
nicknameFocused: false,
saving: false
},
onLoad() {
this.loadUserInfo()
},
onShow() {
// 从其他页返回时刷新
if (app.globalData.userInfo) {
this.loadUserInfo()
}
},
loadUserInfo() {
const userInfo = app.globalData.userInfo || wx.getStorageSync('userInfo')
if (!userInfo) {
wx.showToast({ title: '请先登录', icon: 'none' })
setTimeout(() => wx.navigateBack(), 1500)
return
}
const nickname = (userInfo.nickname || userInfo.nickName || '').trim()
const avatar = userInfo.avatar || userInfo.avatarUrl || ''
const birthday = userInfo.birthday || ''
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 || userInfo.phoneNumber || '').trim()
const { avatarLetter, avatarBgColor } = this._avatarFromNickname(nickname || '我')
this.setData({
userInfo,
nickname,
avatar,
birthday,
gender,
genderIndex,
genderText,
phone,
avatarLetter,
avatarBgColor
})
},
_genderText(g) {
const map = { 0: '保密', 1: '男', 2: '女' }
return map[g] || '保密'
},
_avatarFromNickname(name) {
const str = (name && String(name).trim()) || '我'
const letter = str.charAt(0).toUpperCase() || '我'
const palette = ['#6366f1', '#8b5cf6', '#ec4899', '#f43f5e', '#14b8a6', '#0ea5e9', '#3b82f6', '#eab308']
let hash = 0
for (let i = 0; i < str.length; i++) hash += str.charCodeAt(i)
const bgColor = palette[Math.abs(hash) % palette.length]
return { avatarLetter: letter, avatarBgColor: bgColor }
},
onChooseAvatar(e) {
const { avatarUrl } = e.detail
if (!avatarUrl || !app.globalData.token) {
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
wx.showLoading({ title: '上传中...' })
const token = app.globalData.token
const apiBase = app.globalData.apiBase
wx.uploadFile({
url: `${apiBase}/api/upload/image`,
filePath: avatarUrl,
name: 'file',
header: { 'Authorization': `Bearer ${token}` },
success: (res) => {
const data = res.data
let json = {}
try { json = typeof data === 'string' ? JSON.parse(data) : data } catch (_) {}
if (json.code === 200 && json.data && json.data.url) {
const avatar = json.data.url
this.setData({ avatar })
this._saveProfile({ avatar })
} else {
wx.hideLoading()
wx.showToast({ title: json.message || '上传失败', icon: 'none' })
}
},
fail: () => {
wx.hideLoading()
wx.showToast({ title: '上传失败', icon: 'none' })
}
})
},
onGetPhoneNumber(e) {
const { errMsg, code } = 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((user) => {
const phone = ((user && (user.phone || user.phoneNumber)) || '').trim()
this.setData({ phone })
}).catch(() => {})
},
onNicknameRowTap() {
this.setData({ nicknameFocused: true })
},
onNicknameBlur() {
this.setData({ nicknameFocused: false })
},
onNicknameChange(e) {
const nickname = (e?.detail?.value || '').trim()
const { avatarLetter, avatarBgColor } = this._avatarFromNickname(nickname || '我')
this.setData({ nickname, avatarLetter, avatarBgColor })
},
onBirthdayChange(e) {
const birthday = (e?.detail?.value || '').trim()
this.setData({ birthday })
},
onGenderChange(e) {
const idx = parseInt(e?.detail?.value, 10) || 0
const gender = idx
const genderText = this.data.genderOptions[idx] || '保密'
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()
const origBirthday = userInfo?.birthday || ''
const origGender = (userInfo?.gender !== undefined && userInfo?.gender !== null) ? Number(userInfo.gender) : 0
if (nickname !== origNickname) profile.nickname = nickname
if (birthday !== origBirthday) profile.birthday = birthday
if (gender !== origGender) profile.gender = gender
if (Object.keys(profile).length === 0) {
wx.showToast({ title: '暂无修改', icon: 'none' })
return
}
this._saveProfile(profile)
},
_saveProfile(profile) {
if (!app.globalData.token || !profile || Object.keys(profile).length === 0) return
if (this.data.saving) return
this.setData({ saving: true })
wx.showLoading({ title: '保存中...' })
request({
url: '/api/auth/wechat/profile',
method: 'PUT',
data: profile,
success: (res) => {
wx.hideLoading()
this.setData({ saving: false })
const payload = res && res.data
if (payload && payload.code === 200) {
const updated = { ...(app.globalData.userInfo || {}), ...(payload.data || {}), ...profile }
if (profile.nickname !== undefined) updated.nickname = profile.nickname
if (profile.avatar !== undefined) updated.avatar = profile.avatar
if (profile.birthday !== undefined) updated.birthday = profile.birthday
if (profile.gender !== undefined) updated.gender = profile.gender
app.globalData.userInfo = updated
wx.setStorageSync('userInfo', updated)
wx.showToast({ title: '已保存', icon: 'success' })
} else {
wx.showToast({ title: payload?.message || '保存失败', icon: 'none' })
}
},
fail: () => {
wx.hideLoading()
this.setData({ saving: false })
wx.showToast({ title: '网络错误', icon: 'none' })
}
})
}
})
// pages/user-profile/index.js - 个人资料页
const app = getApp()
const { request } = require('../../utils/request')
const { bindPhoneByCode, isReportProfileComplete } = require('../../utils/phoneAuth')
Page({
data: {
userInfo: null,
nickname: '',
avatar: '',
birthday: '',
gender: 0,
genderIndex: 0,
genderText: '',
genderOptions: ['保密', '男', '女'],
phone: '',
avatarLetter: '我',
avatarBgColor: '#6366f1',
nicknameFocused: false,
saving: false
},
onLoad() {
this.loadUserInfo()
},
onShow() {
// 从其他页返回时刷新
if (app.globalData.userInfo) {
this.loadUserInfo()
}
this._flushPendingNavigate()
},
_flushPendingNavigate() {
const pending = app.globalData._navigateAfterProfile
if (
pending &&
typeof pending === 'string' &&
pending.startsWith('/') &&
isReportProfileComplete()
) {
app.globalData._navigateAfterProfile = ''
wx.redirectTo({ url: pending, fail: () => {} })
}
},
loadUserInfo() {
const userInfo = app.globalData.userInfo || wx.getStorageSync('userInfo')
if (!userInfo) {
wx.showToast({ title: '请先登录', icon: 'none' })
setTimeout(() => wx.navigateBack(), 1500)
return
}
const nickname = (userInfo.nickname || userInfo.nickName || '').trim()
const avatar = userInfo.avatar || userInfo.avatarUrl || ''
const birthday = userInfo.birthday || ''
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 || userInfo.phoneNumber || '').trim()
const { avatarLetter, avatarBgColor } = this._avatarFromNickname(nickname || '我')
this.setData({
userInfo,
nickname,
avatar,
birthday,
gender,
genderIndex,
genderText,
phone,
avatarLetter,
avatarBgColor
})
},
_genderText(g) {
const map = { 0: '保密', 1: '男', 2: '女' }
return map[g] || '保密'
},
_avatarFromNickname(name) {
const str = (name && String(name).trim()) || '我'
const letter = str.charAt(0).toUpperCase() || '我'
const palette = ['#6366f1', '#8b5cf6', '#ec4899', '#f43f5e', '#14b8a6', '#0ea5e9', '#3b82f6', '#eab308']
let hash = 0
for (let i = 0; i < str.length; i++) hash += str.charCodeAt(i)
const bgColor = palette[Math.abs(hash) % palette.length]
return { avatarLetter: letter, avatarBgColor: bgColor }
},
onChooseAvatar(e) {
const { avatarUrl } = e.detail
if (!avatarUrl || !app.globalData.token) {
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
wx.showLoading({ title: '上传中...' })
const token = app.globalData.token
const apiBase = app.globalData.apiBase
wx.uploadFile({
url: `${apiBase}/api/upload/image`,
filePath: avatarUrl,
name: 'file',
header: { 'Authorization': `Bearer ${token}` },
success: (res) => {
const data = res.data
let json = {}
try { json = typeof data === 'string' ? JSON.parse(data) : data } catch (_) {}
if (json.code === 200 && json.data && json.data.url) {
const avatar = json.data.url
this.setData({ avatar })
this._saveProfile({ avatar })
} else {
wx.hideLoading()
wx.showToast({ title: json.message || '上传失败', icon: 'none' })
}
},
fail: () => {
wx.hideLoading()
wx.showToast({ title: '上传失败', icon: 'none' })
}
})
},
onGetPhoneNumber(e) {
const { errMsg, code } = 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((user) => {
const phone = ((user && (user.phone || user.phoneNumber)) || '').trim()
this.setData({ phone }, () => this._flushPendingNavigate())
}).catch(() => {})
},
onNicknameRowTap() {
this.setData({ nicknameFocused: true })
},
onNicknameBlur() {
this.setData({ nicknameFocused: false })
},
onNicknameChange(e) {
const nickname = (e?.detail?.value || '').trim()
const { avatarLetter, avatarBgColor } = this._avatarFromNickname(nickname || '我')
this.setData({ nickname, avatarLetter, avatarBgColor })
},
onBirthdayChange(e) {
const birthday = (e?.detail?.value || '').trim()
this.setData({ birthday })
},
onGenderChange(e) {
const idx = parseInt(e?.detail?.value, 10) || 0
const gender = idx
const genderText = this.data.genderOptions[idx] || '保密'
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()
const origBirthday = userInfo?.birthday || ''
const origGender = (userInfo?.gender !== undefined && userInfo?.gender !== null) ? Number(userInfo.gender) : 0
if (nickname !== origNickname) profile.nickname = nickname
if (birthday !== origBirthday) profile.birthday = birthday
if (gender !== origGender) profile.gender = gender
if (Object.keys(profile).length === 0) {
wx.showToast({ title: '暂无修改', icon: 'none' })
return
}
this._saveProfile(profile)
},
_saveProfile(profile) {
if (!app.globalData.token || !profile || Object.keys(profile).length === 0) return
if (this.data.saving) return
this.setData({ saving: true })
wx.showLoading({ title: '保存中...' })
request({
url: '/api/auth/wechat/profile',
method: 'PUT',
data: profile,
success: (res) => {
wx.hideLoading()
this.setData({ saving: false })
const payload = res && res.data
if (payload && payload.code === 200) {
const updated = { ...(app.globalData.userInfo || {}), ...(payload.data || {}), ...profile }
if (profile.nickname !== undefined) updated.nickname = profile.nickname
if (profile.avatar !== undefined) updated.avatar = profile.avatar
if (profile.birthday !== undefined) updated.birthday = profile.birthday
if (profile.gender !== undefined) updated.gender = profile.gender
app.globalData.userInfo = updated
wx.setStorageSync('userInfo', updated)
wx.showToast({ title: '已保存', icon: 'success' })
setTimeout(() => this._flushPendingNavigate(), 400)
} else {
wx.showToast({ title: payload?.message || '保存失败', icon: 'none' })
}
},
fail: () => {
wx.hideLoading()
this.setData({ saving: false })
wx.showToast({ title: '网络错误', icon: 'none' })
}
})
}
})

View File

@@ -15,7 +15,7 @@
"showShadowRootInWxmlPanel": true,
"scopeDataCheck": false,
"uglifyFileName": false,
"checkInvalidKey": true,
"checkInvalidKey": false,
"checkSiteMap": true,
"uploadWithSourceMap": true,
"compileHotReLoad": false,

View File

@@ -1,48 +1,48 @@
{
"description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html",
"projectname": "Mbti",
"setting": {
"compileHotReLoad": true
},
"condition": {
"miniprogram": {
"list": [
{
"name": "分享的测试数据",
"pathName": "pages/result/sbti",
"query": "id=6229&type=sbti&uid=24&fs=1",
"scene": null,
"launchMode": "default"
},
{
"name": "MBTI",
"pathName": "pages/test/mbti",
"query": "phone=18649947301",
"launchMode": "default",
"scene": null
},
{
"name": "pages/enterprise/index",
"pathName": "pages/enterprise/index",
"query": "scene=uid%253D6%2526eid%253D6",
"launchMode": "default",
"scene": null
},
{
"name": "pages/enterprise/index",
"pathName": "pages/enterprise/index",
"query": "scene=e_6",
"launchMode": "default",
"scene": null
},
{
"name": "pages/enterprise/index",
"pathName": "pages/enterprise/index",
"query": "scene=e_6",
"launchMode": "default",
"scene": null
"description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html",
"projectname": "Mbti",
"setting": {
"compileHotReLoad": true
},
"condition": {
"miniprogram": {
"list": [
{
"name": "分享的测试数据",
"pathName": "pages/result/sbti",
"query": "id=6229&type=sbti&uid=24&fs=1",
"launchMode": "default",
"scene": null
},
{
"name": "MBTI",
"pathName": "pages/test/mbti",
"query": "phone=18649947301",
"launchMode": "default",
"scene": null
},
{
"name": "pages/enterprise/index",
"pathName": "pages/enterprise/index",
"query": "scene=uid%253D6%2526eid%253D6",
"launchMode": "default",
"scene": null
},
{
"name": "pages/enterprise/index",
"pathName": "pages/enterprise/index",
"query": "scene=e_6",
"launchMode": "default",
"scene": null
},
{
"name": "pages/enterprise/index",
"pathName": "pages/enterprise/index",
"query": "scene=e_6",
"launchMode": "default",
"scene": null
}
]
}
]
}
}
}

View File

@@ -297,9 +297,14 @@
letter-spacing: 0.02em;
}
.content-container--with-bottom-tools {
/* 步骤条 (~72rpx) + 按钮行 (~110rpx) + 垂直内边距 (~34rpx) = 216rpx 本体 + 40rpx 留白 = 256rpx */
padding-bottom: calc(80rpx + 320rpx + env(safe-area-inset-bottom));
/*
* 底部固定条占位:须用 .content-container.content-container--with-bottom-tools
* 否则各页 wxss 里单独的 .content-container { padding-bottom: 64rpx } 会晚加载并覆盖单类名修饰符,导致「深度解读」等末尾区块被挡住。
*/
.content-container.content-container--with-bottom-tools {
padding-bottom: calc(
120rpx + 400rpx + env(safe-area-inset-bottom)
);
}
.content-scroll--with-bottom-tools {

View File

@@ -0,0 +1,105 @@
/**
* 神仙 AI根据用户输入识别跳转「我的」同源能力匹配工作 / 了解自己 / 性格测试)
*/
function detectJobIntent(text) {
const s = String(text || '').trim()
if (!s) return false
return (
/工作|求职|就业|职业|岗位|职位|面试|跳槽|转行|offer|薪资|薪水|上班|打工|应聘|招聘|简历|适合.+工作|找.+工作|干什么工作|做什么工作|哪类工作|啥工作|职场|事业|工种/i.test(
s
) || /适合什么样|适合什么类型|MBTI.+工作|性格.+工作/i.test(s)
)
}
function detectSelfKnowIntent(text) {
const s = String(text || '').trim()
if (!s) return false
return /了解自己|认识自己|认识自我|内在|潜能|我是什么样|我是谁|读懂自己|深度了解|真实的我|本心|自我认知|探索自己|看清自己|个性|人格|我的特点|我是怎样的人/i.test(
s
)
}
/** 用户明确想做题 / 测评(与「了解自己」泛聊区分) */
function detectPersonalityTestIntent(text) {
const s = String(text || '').trim()
if (!s) return false
return /性格测试|人格测试|MBTI测试|测MBTI|做测评|做测试|测一测|问卷|量表|测题|测一下|重新测|再测/i.test(s)
}
/**
* 求职 > 测评 > 了解自己
* @returns {'job'|'test'|'self'|null}
*/
function resolveChatFeatureIntent(text, hasMbtiType) {
const s = String(text || '').trim()
if (!s) return null
if (detectJobIntent(s)) return 'job'
if (detectPersonalityTestIntent(s)) return 'test'
if (detectSelfKnowIntent(s)) return 'self'
return null
}
/** 助手回复是否与该功能意图相关(避免文不对题的推荐卡片) */
function replyMatchesFeatureIntent(intent, assistantText) {
const t = String(assistantText || '').trim()
if (!t) return false
if (intent === 'job') {
return /工作|职业|岗位|求职|就业|职场|行业|方向|规划|面试|转行|技能|发展|适配|资源|管理|团队|领导|MBTI|性格.*工作|适合.*工作/i.test(t)
}
if (intent === 'test') {
return /测试|测评|MBTI|问卷|维度|类型|题目|结果|人格|性格|指标|信度|效度/i.test(t)
}
if (intent === 'self') {
return /性格|人格|特质|优势|盲点|内在|情绪|状态|成长|了解自己|认识|适合|关系|伴侣|沟通|需求|模式/i.test(t)
}
return false
}
/**
* 「了解自己」类:随机打开 了解自己(购买页) 或 性格测试(选测页)。
* 尚无 MBTI 结果时略提高去「性格测试」的概率,便于先测评。
* @param {boolean} hasMbtiType 是否已有 MBTI 类型(与 ai-chat 页 mbtiType 一致)
* @returns {string} 小程序 path
*/
function pickSelfServicePath(hasMbtiType) {
try {
const app = getApp()
if (app && app.globalData && app.globalData.miniprogramAuditMode) {
return '/pages/test-select/index'
}
} catch (e) {}
const r = Math.random()
if (!hasMbtiType) {
return r < 0.65 ? '/pages/test-select/index' : '/pages/purchase/index'
}
return r < 0.5 ? '/pages/purchase/index' : '/pages/test-select/index'
}
/**
* 阅读数展示8630 + 距发布日天数×50≥1万 显示为「x万+」
* @param {string} publishedAt 如 2026-04-17
*/
function formatInlineReadLabel(publishedAt) {
const s = String(publishedAt || '').trim()
let ts = s ? Date.parse(s.replace(/\./g, '-')) : NaN
if (Number.isNaN(ts)) ts = Date.now()
const days = Math.max(0, Math.floor((Date.now() - ts) / 86400000))
const raw = 8630 + days * 50
if (raw >= 10000) {
const wan = raw / 10000
const rounded = Math.round(wan * 10) / 10
return `${rounded}万+`
}
return `${raw}阅读`
}
module.exports = {
detectJobIntent,
detectSelfKnowIntent,
detectPersonalityTestIntent,
resolveChatFeatureIntent,
replyMatchesFeatureIntent,
pickSelfServicePath,
formatInlineReadLabel
}

View File

@@ -0,0 +1,101 @@
/**
* 「了解自己」深度服务页:按 enterprisePermissions与 /api/config/runtime 一致)过滤套餐权益文案。
* permissions 为 null 时视为全开(个人未绑定企业)。
*/
const TEST_ORDER = [
{ key: 'mbti', label: 'MBTI' },
{ key: 'disc', label: 'DISC' },
{ key: 'pdp', label: 'PDP' },
{ key: 'sbti', label: 'SBTI' },
{ key: 'face', label: '面相' }
]
/** @param {Record<string, any>|null|undefined} p */
function isPermOn(key, p) {
if (!p || typeof p !== 'object') return true
return p[key] !== false
}
/**
* 顶部副标题里「结合 xxx 的综合解读」动态片段
* @param {Record<string, any>|null|undefined} perms
*/
function buildTestsLabelForHero(perms) {
const parts = []
for (const { key, label } of TEST_ORDER) {
if (isPermOn(key, perms)) parts.push(label)
}
if (parts.length === 0) return '定制测评组合'
return parts.join(' / ')
}
/**
* 完整 Hero 说明(第二行长文案)
* @param {Record<string, any>|null|undefined} perms
*/
function buildDeepHeroDesc(perms) {
const label = buildTestsLabelForHero(perms)
return `结合 ${label} 的综合解读;个人 1v1、团队工作坊、VIP 职业发展三档方案。提交后顾问会主动与你联系。`
}
/**
* 是否至少有一项性格/面相类能力开启(用于「综合报告」「职业」等泛化权益)
* @param {Record<string, any>|null|undefined} p
*/
function anyPersonalityOn(p) {
if (!p || typeof p !== 'object') return true
return TEST_ORDER.some(({ key }) => p[key] !== false)
}
// 单行权益与权限键:命中正则则受对应开关控制
const FEATURE_LINE_RULES = [
{ re: /面相|面部分析|面部|三张照片|照片\+问卷/i, key: 'face' },
{ re: /\bMBTI\b|MBTI性格|16型/i, key: 'mbti' },
{ re: /\bDISC\b|DISC沟通/i, key: 'disc' },
{ re: /\bPDP\b|PDP行为/i, key: 'pdp' },
{ re: /盖洛普|SBTI|sbti/i, key: 'sbti' }
]
/**
* @param {string[]} features
* @param {Record<string, any>|null|undefined} perms
* @returns {string[]}
*/
function filterFeatureLines(features, perms) {
if (!Array.isArray(features) || !features.length) return []
return features.filter((line) => {
const s = String(line || '').trim()
if (!s) return false
for (const { re, key } of FEATURE_LINE_RULES) {
if (re.test(s)) return isPermOn(key, perms)
}
if (/多维度|综合.*性格|优势解读|潜在盲区/i.test(s)) return anyPersonalityOn(perms)
if (/职业|发展方向|匹配.*职业/i.test(s)) return anyPersonalityOn(perms)
return true
})
}
/**
* @param {any[]} categories
* @param {Record<string, any>|null|undefined} perms
*/
function filterCategories(categories, perms) {
if (!Array.isArray(categories)) return []
return categories.map((cat) => {
const copy = Object.assign({}, cat)
if (Array.isArray(copy.features)) {
copy.features = filterFeatureLines(copy.features, perms)
}
return copy
})
}
module.exports = {
buildTestsLabelForHero,
buildDeepHeroDesc,
filterFeatureLines,
filterCategories,
isPermOn,
anyPersonalityOn
}

View File

@@ -0,0 +1,46 @@
/**
* 超管审核相关:隐藏神仙 AI Tab/入口;提审模式下另由后端/各页关闭虚拟商品与「了解自己」深度套餐。
* - miniprogramAuditMode提审专用含虚拟支付合规
* - maintenanceMode / reviewMode面相审核用户口语「审核模式」常指其一
*/
function isAuditHideAiMode(gd) {
if (!gd) return false
return !!(gd.miniprogramAuditMode || gd.maintenanceMode || gd.reviewMode)
}
function redirectIfMiniprogramAudit(message) {
const app = getApp()
const gd = app && app.globalData
if (!isAuditHideAiMode(gd)) return false
wx.showToast({ title: message || '功能升级中', icon: 'none' })
setTimeout(() => {
wx.switchTab({ url: '/pages/index/index' })
}, 300)
return true
}
/**
* 先拉 runtime若存在再若提审则踢回首页否则执行 callback。
*/
function ensureRuntimeThenGate(callback) {
const app = getApp()
const run = () => {
if (isAuditHideAiMode(app.globalData)) {
redirectIfMiniprogramAudit()
return
}
if (typeof callback === 'function') callback()
}
if (app && typeof app.getRuntimeConfig === 'function') {
app.getRuntimeConfig().then(run).catch(run)
return
}
run()
}
module.exports = {
isAuditHideAiMode,
redirectIfMiniprogramAudit,
ensureRuntimeThenGate
}

View File

@@ -71,6 +71,13 @@ function generateOrderId(productType) {
function wxPay(options) {
const { orderId, amount = 0, description, productType, testResultId, deepProductId, enterpriseId, success, fail } = options
if (app.globalData && app.globalData.miniprogramAuditMode) {
const msg = '版本审核期间不可发起支付'
wx.showToast({ title: msg, icon: 'none' })
if (typeof fail === 'function') fail(new Error(msg))
return
}
try {
const analyticsMod = require('./analytics')
if (analyticsMod && typeof analyticsMod.track === 'function') {

View File

@@ -3,6 +3,67 @@
* 手机号与个人资料:详见 isProfileComplete 规则
*/
/** 本会话/本机「已用手机号授权登录」标记(与是否库里已有手机无关;换 userId 自动失效) */
const PHONE_LOGIN_SESSION_KEY = 'mbti_phone_login_v1'
const PHONE_LOGIN_TTL_MS = 90 * 86400000
function getLoginUserId() {
try {
const app = getApp()
const u = (app && app.globalData && app.globalData.userInfo) || wx.getStorageSync('userInfo')
if (!u || typeof u !== 'object') return ''
const id = u.id != null ? u.id : u.userId
return id != null && id !== '' ? String(id) : ''
} catch (e) {
return ''
}
}
/**
* 与当前登录用户一致且未过期时,视为已在结果页完成过「手机登录」授权
* @returns {boolean}
*/
function hasPhoneLoginSession() {
const uid = getLoginUserId()
if (!uid) return false
try {
const row = wx.getStorageSync(PHONE_LOGIN_SESSION_KEY)
if (!row || typeof row !== 'object') return false
if (String(row.uid || '') !== uid) return false
const at = Number(row.at || 0)
if (at > 0 && Date.now() - at > PHONE_LOGIN_TTL_MS) return false
return true
} catch (e) {
return false
}
}
function markPhoneLoginSession() {
const uid = getLoginUserId()
if (!uid) return
try {
wx.setStorageSync(PHONE_LOGIN_SESSION_KEY, { uid, at: Date.now() })
} catch (e) {}
}
function clearPhoneLoginSession() {
try {
wx.removeStorageSync(PHONE_LOGIN_SESSION_KEY)
} catch (e) {}
}
/** 静默登录写入新用户后调用userId 变化则清掉旧会话标记 */
function syncPhoneLoginStorageWithUser(user) {
const uid = user && (user.id != null ? String(user.id) : (user.userId != null ? String(user.userId) : ''))
if (!uid) return
try {
const row = wx.getStorageSync(PHONE_LOGIN_SESSION_KEY)
if (row && row.uid && String(row.uid) !== uid) {
wx.removeStorageSync(PHONE_LOGIN_SESSION_KEY)
}
} catch (e) {}
}
/**
* 个人资料是否已满足业务门禁(避免反复跳转「完善资料」)
* - 已绑定手机号:视为可用(付费/深度服务以手机为准;仅首字头像无 URL 不再卡死)
@@ -33,6 +94,25 @@ function isReportProfileComplete() {
return nickname.length > 0 && avatar.length > 0 && phone.length > 0
}
/**
* 问卷/结果页预览门禁:分享落地不遮挡;否则需手机号+昵称+头像齐全后才可看全文
*/
function needsResultProfileGate(fromShare) {
if (fromShare) return false
return !isReportProfileComplete()
}
/**
* 结果页 getPhoneNumber 成功后:若资料未齐,进入「我的-资料」与查看全文同一套规则
*/
function navigateToCompleteProfileAfterPhoneIfNeeded() {
if (isReportProfileComplete()) return
wx.showToast({ title: '请完善头像与昵称', icon: 'none' })
setTimeout(() => {
wx.navigateTo({ url: '/pages/user-profile/index?from=result_gate', fail: () => {} })
}, 450)
}
/**
* 若资料未完善则跳转到个人资料页,需登录
* @returns {boolean} true=已完善可继续false=已跳转
@@ -104,6 +184,7 @@ function bindPhoneByCode(code) {
const newUser = { ...user, phone }
app.globalData.userInfo = newUser
wx.setStorageSync('userInfo', newUser)
markPhoneLoginSession()
wx.showToast({ title: '授权成功', icon: 'success' })
resolve(newUser)
} else {
@@ -121,10 +202,26 @@ function bindPhoneByCode(code) {
})
}
/**
* 测评提交后直接进结果页;完整报告是否在结果页展示由「资料齐全」门禁控制(见 needsResultProfileGate / isReportProfileComplete
* @param {string} targetUrl 必须以 / 开头的本地路径,如 /pages/result/mbti?id=1&type=mbti
*/
function afterTestSubmitNavigate(targetUrl) {
const url = (targetUrl && String(targetUrl).trim()) || '/pages/index/index'
wx.redirectTo({ url, fail: () => wx.reLaunch({ url: '/pages/index/index' }) })
}
module.exports = {
hasPhone,
bindPhoneByCode,
isProfileComplete,
isReportProfileComplete,
needsResultProfileGate,
navigateToCompleteProfileAfterPhoneIfNeeded,
hasPhoneLoginSession,
markPhoneLoginSession,
clearPhoneLoginSession,
syncPhoneLoginStorageWithUser,
ensureProfileCompleteAndRedirect,
afterTestSubmitNavigate,
}

View File

@@ -0,0 +1,27 @@
/**
* 精选推荐 → 跳转其他小程序 path与「一场 soul」阅读页 pages/read/read?id= 对齐)
* @param {string} basePath 后台配置的 path无 sourceId 时使用
* @param {{ id?: string|number, title?: string, sourceId?: string|number, fromTag?: string }} opts
*/
function buildRecoMiniProgramPath(basePath, opts) {
const id = opts && opts.id != null ? String(opts.id).trim() : ''
const title = opts && opts.title != null ? String(opts.title).trim() : ''
const sourceId = opts && opts.sourceId != null ? String(opts.sourceId).trim() : ''
const fromTag = (opts && opts.fromTag) || 'mbti_reco'
const parts = ['from=' + encodeURIComponent(fromTag)]
if (id) parts.push('mbtiArticleId=' + encodeURIComponent(id))
if (title) parts.push('title=' + encodeURIComponent(title.slice(0, 200)))
if (sourceId) {
let path = 'pages/read/read?id=' + encodeURIComponent(sourceId)
return path + '&' + parts.join('&')
}
let path = (basePath || 'pages/index/index').replace(/^\//, '')
if (!path) path = 'pages/index/index'
const sep = path.indexOf('?') >= 0 ? '&' : '?'
return path + sep + parts.join('&')
}
module.exports = {
buildRecoMiniProgramPath
}

View File

@@ -1,6 +1,6 @@
/**
* 统一请求封装:自动拼接 baseURL、携带 token、处理 401
* 使用前需已执行 App(),否则 getApp() 在 require 时可能未就绪,这里在请求时再取 app
* 401 时先尝试静默登录一次并重试,减少 token 过期/首屏竞态导致的误 401
*/
function getAppSafe() {
try {
@@ -12,7 +12,17 @@ function getAppSafe() {
function getToken() {
const app = getAppSafe()
return (app && app.globalData && app.globalData.token) || wx.getStorageSync('token') || ''
const g = app && app.globalData && app.globalData.token
if (g) return g
try {
const st = wx.getStorageSync('token')
if (st && app && app.globalData) {
app.globalData.token = st
}
return st || ''
} catch (e) {
return ''
}
}
function getApiBase() {
@@ -20,9 +30,6 @@ function getApiBase() {
return (app && app.globalData && app.globalData.apiBase) || ''
}
/**
* 清除登录态401 或主动退出时调用)
*/
function clearLoginState() {
const app = getAppSafe()
if (app && app.globalData) {
@@ -37,17 +44,29 @@ function clearLoginState() {
}
/**
* 发起请求
* @param {Object} options - 同 wx.requesturl 可为相对路径(自动加 apiBase
* @param {boolean} options.needAuth - 是否携带 Authorization默认 true
* @param {boolean} options.allow401 - 401 时是否静默清除登录态而不 fail,默认 true
* @param {Object} options - 同 wx.request
* @param {boolean} options.needAuth - 是否带 Authorization默认 true
* @param {boolean} options.optionalAuth
* @param {boolean} options.allow401 - 401 且已带 Bearer 时是否清登录态(重试失败后),默认 true
*/
function request(options) {
const apiBase = getApiBase()
const url = options.url
const fullUrl = url.startsWith('http') ? url : `${apiBase.replace(/\/$/, '')}${url.startsWith('/') ? '' : '/'}${url}`
const {
__didAuthRetry,
success: userSuccess,
fail: userFail,
complete: userComplete,
url: optUrl,
...rest
} = options
const fullUrl = optUrl.startsWith('http')
? optUrl
: `${apiBase.replace(/\/$/, '')}${optUrl.startsWith('/') ? '' : '/'}${optUrl}`
const needAuth = options.needAuth !== false
const optionalAuth = options.optionalAuth === true
const allow401 = options.allow401 !== false
const didRetry = __didAuthRetry === true
const header = {
'Content-Type': 'application/json',
@@ -56,34 +75,58 @@ function request(options) {
if (needAuth) {
const token = getToken()
if (token) header['Authorization'] = `Bearer ${token}`
} else if (optionalAuth) {
const token = getToken()
if (token) header['Authorization'] = `Bearer ${token}`
}
const success = options.success
const fail = options.fail
const complete = options.complete
const authHeaderSent = !!header['Authorization']
return wx.request({
...options,
wx.request({
...rest,
url: fullUrl,
header,
success(res) {
if (res.statusCode === 401 && allow401) {
if (
res.statusCode === 401 &&
allow401 &&
authHeaderSent &&
needAuth &&
!didRetry
) {
const app = getAppSafe()
if (app && typeof app.silentLogin === 'function') {
app
.silentLogin()
.then((loginOk) => {
if (loginOk && getToken()) {
request({ ...options, __didAuthRetry: true })
} else {
clearLoginState()
if (userSuccess) userSuccess(res)
}
})
.catch(() => {
clearLoginState()
if (userSuccess) userSuccess(res)
})
return
}
}
if (res.statusCode === 401 && allow401 && authHeaderSent) {
clearLoginState()
}
if (success) success(res)
if (userSuccess) userSuccess(res)
},
fail(err) {
if (fail) fail(err)
if (userFail) userFail(err)
},
complete(res) {
if (complete) complete(res)
if (userComplete) userComplete(res)
}
})
}
/**
* Promise 版 request便于 async/await
*/
function requestPromiseOnce(options) {
return new Promise((resolve, reject) => {
request({
@@ -110,7 +153,6 @@ function requestPromiseOnce(options) {
}
},
fail(err) {
// wx.request fail网络不可达 / CONNECTION_CLOSED / TLS / 超时
const e = new Error((err && err.errMsg) || '网络异常')
e.isNetworkError = true
reject(e)
@@ -119,23 +161,20 @@ function requestPromiseOnce(options) {
})
}
/**
* 带指数退避重试(默认 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)
})
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()
}

View File

@@ -1,6 +1,6 @@
/**
* 结果页「按步骤解锁」状态:
* Step 1 · 看全文(需完善资料或付费)
* Step 1 · 看全文(需手机号+头像+昵称齐全,或已付费)
* Step 2 · 分享朋友圈Step 1 完成后启用)
* Step 3 · AI 拍照测试Step 2 完成后启用)
*
@@ -27,7 +27,7 @@ function write(obj) {
/**
* Step 1 是否已解锁
* 条件:资料完善profileGate===false免费或已付费
* 条件:资料已齐profileGate===false,与 isReportProfileComplete 一致)且(免费或已付费)
*/
function isStep1Unlocked({ profileGate, payRequired, isPaid }) {
if (profileGate) return false

View File

@@ -1,12 +1,12 @@
/**
* 结果页资料门禁:未完善头像+昵称+手机时,客户端展示约 30% 预览(与后端 locked 互补)
* 结果页门禁:非分享落地且资料未齐(手机+头像+昵称)时展示约 30% 预览
*/
const { isReportProfileComplete } = require('./phoneAuth.js')
const { needsResultProfileGate } = require('./phoneAuth.js')
/** @param {WechatMiniprogram.Page.TrivialInstance} page */
function setProfileGateOnPage(page) {
const fromShare = !!(page.data && page.data.fromShare)
const gate = !fromShare && !isReportProfileComplete()
const gate = needsResultProfileGate(fromShare)
page.setData({ profileGate: gate })
}
@@ -50,7 +50,6 @@ function openTimelineShareHint() {
}
module.exports = {
isReportProfileComplete,
setProfileGateOnPage,
slicePreviewText,
slicePreviewList,