feat: 管理端与用户端批量更新(订单/邀请双码/tabBar/审核与分销等)

- 管理端:用户详情、企业看板双小程序码、超管设置与用户列表等
- 后端:订单接口、邀请码企业版+个人版、分析与测试相关调整
- 微信小程序/抖音:自定义 tabBar 居中与拍摄钮上移、相机页与结果页、订单页、支付与统计
- 新增 faceResultDetail 工具与 mp 分析迁移脚本

Made-with: Cursor
This commit is contained in:
Ghost
2026-03-28 16:46:00 +08:00
parent 2bb8b979f1
commit 3317c84b5e
65 changed files with 4442 additions and 1839 deletions

View File

@@ -1,6 +1,36 @@
// app.js - MBTI小程序主入口
const { request } = require('./utils/request.js')
// 全局注入:所有页面 onShow 自动上报 page_viewonShareAppMessage 自动上报 share
const _OrigPage = Page
Page = function (pageConfig) {
const origOnShow = pageConfig.onShow
pageConfig.onShow = function () {
try {
const { reportPageView } = require('./utils/analytics.js')
reportPageView()
} catch (e) {}
if (typeof origOnShow === 'function') {
origOnShow.call(this)
}
}
if (typeof pageConfig.onShareAppMessage === 'function') {
const origShare = pageConfig.onShareAppMessage
pageConfig.onShareAppMessage = function (res) {
try { require('./utils/analytics.js').reportShare('friend') } catch (e) {}
return origShare.call(this, res)
}
}
if (typeof pageConfig.onShareTimeline === 'function') {
const origTimeline = pageConfig.onShareTimeline
pageConfig.onShareTimeline = function () {
try { require('./utils/analytics.js').reportShare('timeline') } catch (e) {}
return origTimeline.call(this)
}
}
return _OrigPage(pageConfig)
}
App({
globalData: {
userInfo: null,
@@ -17,8 +47,8 @@ App({
// 超管配置的默认企业 ID无 scene/eid 等入口参数时回落)
defaultEnterpriseId: null,
// API基础地址开发时用本地生产环境替换为实际域名
apiBase: 'https://mbtiapi.quwanzhi.com',
//apiBase: 'http://mbti.com',
//apiBase: 'https://mbtiapi.quwanzhi.com',
apiBase: 'http://mbti.com',
// VIP信息
vipInfo: null,
// 测试次数
@@ -32,13 +62,22 @@ App({
aiResult: null
},
onLaunch() {
onLaunch(launchOptions) {
// 记录场景值供埋点使用
this.globalData.scene = launchOptions && launchOptions.scene ? launchOptions.scene : ''
// 加载本地存储的数据
this.loadStoredData()
// 静默登录获取openId
this.silentLogin()
// 上报应用启动事件
try {
const analytics = require('./utils/analytics.js')
analytics.reportAppLaunch(launchOptions)
} catch (e) {}
// 预加载站点/小程序名称、维护模式与面相审核模式camera/首页文案)
this.getRuntimeConfig().then((cfg) => {
if (cfg) {
@@ -121,11 +160,17 @@ App({
return
}
const url = `${this.globalData.apiBase}/api/auth/wechat`
const loginData = { code: res.code }
try {
const { getEffectiveEnterpriseId } = require('./utils/enterpriseContext.js')
const eid = getEffectiveEnterpriseId()
if (eid) loginData.enterpriseId = eid
} catch (e) {}
wx.request({
url,
method: 'POST',
header: { 'Content-Type': 'application/json' },
data: { code: res.code },
data: loginData,
success: (response) => {
if (response.statusCode === 200 && response.data && response.data.code === 200) {
const data = response.data.data || {}

View File

@@ -19,6 +19,7 @@
"pages/profile/index",
"pages/user-profile/index",
"pages/history/index",
"pages/order/index",
"pages/phone-auth/index",
"pages/promo/index",
"pages/promo/poster",

View File

@@ -17,7 +17,7 @@
.tab-bar-inner {
display: flex;
align-items: flex-end;
align-items: center;
justify-content: space-between;
min-height: 120rpx;
padding: 12rpx 12rpx 10rpx;
@@ -31,8 +31,8 @@
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding-bottom: 4rpx;
justify-content: center;
padding-bottom: 0;
z-index: 1;
}
@@ -60,19 +60,19 @@
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding-bottom: 4rpx;
justify-content: center;
padding-bottom: 0;
}
/* 浮钮:略上移即可,避免压住首页主按钮 */
/* 浮钮:整体上移,更突出圆形快门、减少与底栏顶边重叠感 */
.middle-fab {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
justify-content: center;
width: 100%;
transform: translateY(-10rpx);
margin-bottom: -6rpx;
transform: translateY(-26rpx);
margin-bottom: -10rpx;
}
.center-circle {

View File

@@ -132,6 +132,7 @@ Page({
this.cameraContext.takePhoto({
quality: 'high',
success: (res) => {
try { require('../../utils/analytics').track('take_photo', { index: this.data.photos.length + 1 }) } catch (e) {}
const photos = [...this.data.photos, res.tempImagePath]
const photoIndex = photos.length
const guideText = this.data.guideTexts[photoIndex] || '拍摄完成'
@@ -231,11 +232,13 @@ Page({
wx.hideLoading()
wx.setStorageSync('aiPhotos', urls)
this.setData({ uploading: false })
try { require('../../utils/analytics').track('photo_upload_success', { count: urls.length }) } catch (e) {}
wx.navigateTo({ url: '/pages/index/result' })
})
.catch((err) => {
wx.hideLoading()
this.setData({ uploading: false })
try { require('../../utils/analytics').track('photo_upload_fail', { error: (err && err.message) || '' }) } catch (e) {}
wx.showToast({ title: err.message || '上传失败', icon: 'none' })
})
},

View File

@@ -102,6 +102,7 @@ Page({
},
onLoad(options) {
this._payInfoSetByDetail = false
const id = options && options.id
const type = options && options.type
@@ -154,13 +155,14 @@ Page({
const apiData = payload.data || payload
// 历史详情场景下记录当前测试记录ID
this.setData({ testResultId: id })
this.processResult(apiData)
// 从历史进入:金额与是否需付款以 test_results 的 paidAmount/needPaymentToUnlock 为准
// 先确定付费状态(设置 _payInfoSetByDetail 标记),再渲染结果
// 避免 processResult 内部异步拉全局配置覆盖掉数据库级别的付费判定
this.initPayInfoFromRuntime(
!!payload.requiresPayment,
!!payload.isPaid,
payload
)
this.processResult(apiData)
} else {
wx.showToast({ title: res.data?.message || '加载失败', icon: 'none' })
setTimeout(() => wx.navigateBack(), 1500)
@@ -254,6 +256,7 @@ Page({
setTimeout(() => this.showNoFaceError(msg), 300)
} else if (bodyCode === 200) {
const apiData = res.data.data || res.data
try { require('../../utils/analytics').track('ai_analysis_complete', { mbti: (apiData.mbti && apiData.mbti.type) || '' }) } catch (e) {}
this.setData({ progress: 100, analyzingTip: '分析完成!' })
setTimeout(() => {
this.processResult(apiData)
@@ -342,8 +345,9 @@ Page({
amountYuan
}
})
} else {
// 拍照直连分析未带 _payment 时,拉 runtime 定价(与历史详情入口互不覆盖)
} else if (!this._payInfoSetByDetail) {
// 拍照直连分析未带 _payment 时,拉 runtime 定价
// 历史详情入口已由 initPayInfoFromRuntime(detailPayload) 处理过,不再覆盖
this.initPayInfoFromRuntime()
}
},
@@ -369,6 +373,8 @@ Page({
const paidAmount = Number(detailPayload.paidAmount ?? 0)
const amountYuan = detailPayload.amountYuan != null ? Number(detailPayload.amountYuan) : (paidAmount > 0 ? paidAmount / 100 : 0)
const needPay = detailPayload.needPaymentToUnlock === true || (!!detailPayload.requiresPayment && !detailPayload.isPaid && paidAmount > 0)
// 标记历史详情已确定付费状态processResult 中不再用全局配置覆盖
this._payInfoSetByDetail = true
this.setData({
payInfo: {
requiresPayment: needPay,
@@ -430,6 +436,7 @@ Page({
/** 付费墙主按钮:先支付,支付后再引导手机号与资料 */
onTapUnlockPay() {
try { require('../../utils/analytics').track('tap_unlock_pay', { amount: this.data.payInfo.amountYuan, testResultId: this.data.testResultId }) } catch (e) {}
this.unlockFullReport()
},
@@ -534,16 +541,14 @@ Page({
const payload = res.data.data
const apiData = payload.data || payload
// 详情接口在后台会根据 isPaid 返回完整结构,这里直接复用已有处理逻辑
this.processResult(apiData)
// 同步数据库中的付费状态与金额test_results.paidAmount
// 先确定付费状态,再渲染结果(避免 processResult 异步覆盖)
const recordRequires =
typeof payload.requiresPayment === 'boolean' || typeof payload.requiresPayment === 'number'
? !!payload.requiresPayment
: null
const recordIsPaid = !!payload.isPaid
this.initPayInfoFromRuntime(recordRequires, recordIsPaid, payload)
this.processResult(apiData)
} else {
console.error('刷新完整报告失败', res)
}
@@ -577,11 +582,6 @@ Page({
})
},
// 跳转到详情性格测试选择页MBTI / PDP / DISC 三选一)
goToMBTI() {
wx.navigateTo({ url: '/pages/test-select/index' })
},
shareResult() {
wx.showShareMenu({ withShareTicket: true, menus: ['shareAppMessage', 'shareTimeline'] })
},

View File

@@ -74,15 +74,10 @@
class="card personality-card {{(payInfo.requiresPayment && !payInfo.isPaid) ? 'personality-card--locked' : ''}}"
>
<text class="card-title">性格类型分析</text>
<text class="personality-card-sub">MBTI · PDP · DISC 联合速览</text>
<view class="personality-type">
<text class="type-code">{{result.mbti}}</text>
<text class="type-name">{{result.title}}</text>
<view class="type-chip-row" wx:if="{{result.pdp || result.disc}}">
<text class="type-chip" wx:if="{{result.pdp}}">{{result.pdpEmoji}}{{result.pdp}}</text>
<text class="type-chip type-chip--disc" wx:if="{{result.disc}}">DISC {{result.disc}}</text>
</view>
<text class="type-desc">{{result.summary}}</text>
</view>
@@ -110,10 +105,6 @@
</view>
</view>
</view>
<view class="next-btn" bindtap="goToMBTI">
<text class="next-btn-text">下一步:详细性格测试 →</text>
</view>
</view>
<view

View File

@@ -323,11 +323,11 @@
top: 0;
bottom: 0;
pointer-events: none;
/* 约前 40% 区域保持清晰预览,以下渐强为「约 60% 可读」观感 */
/* 约前 50% 区域保持清晰预览,以下渐强变白 */
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0) 38%,
rgba(255, 255, 255, 0) 50%,
rgba(255, 255, 255, 0.2) 52%,
rgba(255, 255, 255, 0.55) 68%,
rgba(255, 255, 255, 0.82) 88%,

View File

@@ -0,0 +1,81 @@
// pages/order/index.js - 我的订单(支付记录)
const app = getApp()
const { request } = require('../../utils/request')
Page({
data: {
list: [],
total: 0,
page: 1,
pageSize: 20,
hasMore: false,
loading: false,
empty: false
},
onLoad() {
this.loadList(true)
},
onPullDownRefresh() {
this.loadList(true)
.then(() => wx.stopPullDownRefresh())
.catch(() => wx.stopPullDownRefresh())
},
loadList(reset) {
const token = app.globalData.token || wx.getStorageSync('token')
if (!token) {
wx.showToast({ title: '请先登录', icon: 'none' })
return Promise.resolve()
}
const page = reset ? 1 : this.data.page
if (this.data.loading) return Promise.resolve()
this.setData({ loading: true })
return new Promise((resolve) => {
request({
url: `/api/orders?page=${page}&pageSize=${this.data.pageSize}`,
method: 'GET',
needAuth: true,
success: (res) => {
const body = res.data
if (res.statusCode === 200 && body && body.code === 200 && body.data) {
const d = body.data
const raw = Array.isArray(d.list) ? d.list : []
const list = reset ? raw : this.data.list.concat(raw)
this.setData({
list,
total: d.total || 0,
page: d.page || page,
hasMore: !!d.hasMore,
empty: list.length === 0,
loading: false
})
} else {
this.setData({
list: reset ? [] : this.data.list,
empty: reset && (!this.data.list || this.data.list.length === 0),
loading: false
})
if (body && body.message) wx.showToast({ title: body.message, icon: 'none' })
}
resolve()
},
fail: () => {
this.setData({ loading: false })
wx.showToast({ title: '加载失败', icon: 'none' })
resolve()
}
})
})
},
loadMore() {
if (!this.data.hasMore || this.data.loading) return
this.setData({ page: this.data.page + 1 }, () => {
this.loadList(false)
})
}
})

View File

@@ -0,0 +1,7 @@
{
"navigationBarTitleText": "我的订单",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black",
"enablePullDownRefresh": true,
"usingComponents": {}
}

View File

@@ -0,0 +1,32 @@
<!--pages/order/index.wxml-->
<view class="page">
<view class="loading-wrap" wx:if="{{loading && list.length === 0}}">
<text class="loading-text">加载中...</text>
</view>
<view class="empty" wx:elif="{{empty && !loading}}">
<text class="empty-icon">🧾</text>
<text class="empty-title">暂无订单</text>
<text class="empty-desc">购买专业报告或测试服务后,订单会显示在这里</text>
</view>
<view class="list" wx:else>
<view class="order-card" wx:for="{{list}}" wx:key="id">
<view class="card-head">
<text class="title">{{item.productTitle}}</text>
<text class="status status-{{item.status}}">{{item.statusText}}</text>
</view>
<text class="order-no">单号 {{item.orderNo}}</text>
<view class="card-foot">
<text class="time">{{item.payTimeStr || item.createdAtStr}}</text>
<text class="amount">¥{{item.amountYuan}}</text>
</view>
</view>
<view class="load-more" wx:if="{{hasMore}}" bindtap="loadMore">
<text class="load-more-text">{{loading ? '加载中...' : '加载更多'}}</text>
</view>
<view class="list-end" wx:if="{{!hasMore && list.length > 0}}">
<text class="list-end-text">没有更多了</text>
</view>
</view>
</view>

View File

@@ -0,0 +1,134 @@
.page {
min-height: 100vh;
background: #f5f5f5;
padding: 24rpx;
padding-bottom: 48rpx;
box-sizing: border-box;
}
.loading-wrap {
padding: 120rpx 0;
text-align: center;
}
.loading-text {
font-size: 28rpx;
color: #9ca3af;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 160rpx;
}
.empty-icon {
font-size: 100rpx;
margin-bottom: 24rpx;
}
.empty-title {
font-size: 32rpx;
color: #374151;
font-weight: 600;
margin-bottom: 12rpx;
}
.empty-desc {
font-size: 26rpx;
color: #9ca3af;
text-align: center;
padding: 0 48rpx;
}
.order-card {
background: #fff;
border-radius: 16rpx;
padding: 28rpx;
margin-bottom: 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16rpx;
margin-bottom: 12rpx;
}
.title {
flex: 1;
font-size: 30rpx;
font-weight: 600;
color: #111827;
line-height: 1.4;
}
.status {
font-size: 24rpx;
flex-shrink: 0;
padding: 4rpx 14rpx;
border-radius: 8rpx;
}
.status-pending {
color: #d97706;
background: #fffbeb;
}
.status-paid,
.status-completed {
color: #059669;
background: #ecfdf5;
}
.status-cancelled {
color: #6b7280;
background: #f3f4f6;
}
.order-no {
font-size: 24rpx;
color: #9ca3af;
margin-bottom: 16rpx;
word-break: break-all;
}
.card-foot {
display: flex;
justify-content: space-between;
align-items: center;
}
.time {
font-size: 24rpx;
color: #6b7280;
}
.amount {
font-size: 32rpx;
font-weight: 700;
color: #7c3aed;
}
.load-more {
text-align: center;
padding: 24rpx;
}
.load-more-text {
font-size: 28rpx;
color: #7c3aed;
}
.list-end {
text-align: center;
padding: 16rpx;
}
.list-end-text {
font-size: 24rpx;
color: #9ca3af;
}

View File

@@ -278,6 +278,14 @@ Page({
try { require('../../utils/analytics').track('tap_deep_service', {}) } catch (e) {}
wx.navigateTo({ url: '/pages/purchase/index' })
},
goToOrders() {
if (!this.data.hasLogin) {
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
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' }) },
@@ -316,10 +324,10 @@ Page({
if (id) wx.navigateTo({ url: `/pages/index/result?id=${id}&type=ai` })
else wx.switchTab({ url: '/pages/index/camera' })
},
/** 动作测试:进入底部「拍摄」页 */
goToActionTest() {
try { require('../../utils/analytics').track('tap_action_test_camera', {}) } catch (e) {}
wx.switchTab({ url: '/pages/index/camera' })
/** 详细性格测试MBTI / PDP / DISC 选择页 */
goToTestSelect() {
try { require('../../utils/analytics').track('tap_test_select_from_profile', {}) } catch (e) {}
wx.navigateTo({ url: '/pages/test-select/index' })
},
logout() {

View File

@@ -129,20 +129,20 @@
</view>
</scroll-view>
<view class="depth-empty-hint depth-empty-hint--compact">
<text>灰色为未完成项,点击即可进入测评或拍摄;右上方可查看全部历史。</text>
<text>灰色为未完成项,点击即可进入测评;右上方可查看全部历史。</text>
</view>
<view class="depth-inner-divider"></view>
<view class="menu-item menu-item--flat" bindtap="goToActionTest" wx:if="{{!reviewMode}}">
<view class="menu-item menu-item--flat" bindtap="goToTestSelect">
<view class="menu-icon-wrap menu-icon-amber">
<text class="menu-icon">📷</text>
<text class="menu-icon">🧠</text>
</view>
<view class="menu-content">
<text class="menu-title">动作测试(拍摄)</text>
<text class="menu-sub">拍摄人像照片,生成面相、骨相与盖洛普等解析</text>
<text class="menu-title">详细性格测试</text>
<text class="menu-sub">MBTI、PDP、DISC 三套问卷,任选一项开始</text>
</view>
<text class="menu-chevron"></text>
</view>
<view class="menu-divider menu-divider--in-card" wx:if="{{!reviewMode}}"></view>
<view class="menu-divider menu-divider--in-card"></view>
<view class="menu-item menu-item--flat" bindtap="goToDeepService">
<view class="menu-icon-wrap menu-icon-purple">
<text class="menu-icon">✨</text>
@@ -153,6 +153,17 @@
</view>
<text class="menu-chevron"></text>
</view>
<view class="menu-divider menu-divider--in-card"></view>
<view class="menu-item menu-item--flat" bindtap="goToOrders">
<view class="menu-icon-wrap menu-icon-emerald">
<text class="menu-icon">🧾</text>
</view>
<view class="menu-content">
<text class="menu-title">我的订单</text>
<text class="menu-sub">支付记录与订单状态</text>
</view>
<text class="menu-chevron"></text>
</view>
<view class="menu-divider menu-divider--in-card" wx:if="{{hasEnterprise}}"></view>
<view class="menu-item menu-item--flat" wx:if="{{hasEnterprise}}" bindtap="goToMyResume">
<view class="menu-icon-wrap menu-icon-indigo">

View File

@@ -568,6 +568,7 @@ custom-tab-bar {
.menu-icon-red { background: #FEF2F2; }
.menu-icon-indigo { background: #EEF2FF; }
.menu-icon-amber { background: #FFFBEB; }
.menu-icon-emerald { background: #ECFDF5; }
.menu-icon-purple { background: #EDE9FE; }
.menu-icon {

View File

@@ -1,157 +1,157 @@
// pages/result/disc.js - DISC结果页支持付费墙 + 历史详情拉取)
const app = getApp()
const payment = require('../../utils/payment')
function toIntPercent(v) {
if (v == null) return 0
const n = typeof v === 'number' ? v : Number(v)
return Number.isFinite(n) ? Math.round(n) : 0
}
function withPercentagesInt(data) {
if (!data) return data
const p = data.percentages || {}
const out = { ...data }
out.percentagesInt = {
D: toIntPercent(p.D ?? p.d),
I: toIntPercent(p.I ?? p.i),
S: toIntPercent(p.S ?? p.s),
C: toIntPercent(p.C ?? p.c)
}
return out
}
Page({
data: {
result: null,
typeList: [
{ type: 'D', label: 'D型 - 支配型', colorClass: 'fill-d' },
{ type: 'I', label: 'I型 - 影响型', colorClass: 'fill-i' },
{ type: 'S', label: 'S型 - 稳健型', colorClass: 'fill-s' },
{ type: 'C', label: 'C型 - 谨慎型', colorClass: 'fill-c' }
],
payInfo: { requiresPayment: false, isPaid: false, amountYuan: 0 },
testResultId: null,
hasReloadedAfterPay: false
},
onLoad(options) {
const id = options && options.id
const type = options && options.type
if (id && type === 'disc') {
this.setData({ testResultId: id })
this.loadDetail(id)
return
}
const result = wx.getStorageSync('discResult')
if (result) {
this.setData({ result: withPercentagesInt(result) })
this.initPayInfoFromRuntime('disc')
} else {
wx.showToast({ title: '暂无测试结果', icon: 'none' })
setTimeout(() => wx.navigateBack(), 1500)
}
},
loadDetail(id) {
const apiBase = app.globalData?.apiBase || ''
const token = app.globalData?.token || wx.getStorageSync('token') || ''
if (!apiBase) { wx.showToast({ title: '配置异常', icon: 'none' }); return }
wx.showLoading({ title: '加载中...' })
wx.request({
url: `${apiBase}/api/test/detail`,
method: 'GET',
header: token ? { Authorization: `Bearer ${token}` } : {},
data: { id },
success: (res) => {
if (res.statusCode === 200 && res.data && res.data.code === 200) {
const payload = res.data.data || {}
const data = payload.data || payload
const isPaid = !!payload.isPaid
const paidAmount = payload.paidAmount != null ? Number(payload.paidAmount) : 0
const amountYuan = payload.amountYuan != null ? Number(payload.amountYuan) : (paidAmount > 0 ? paidAmount / 100 : 0)
const needPaymentToUnlock = payload.needPaymentToUnlock === true || (!!payload.requiresPayment && !isPaid && paidAmount > 0)
this.setData({ result: withPercentagesInt(data) })
const payInfo = {
requiresPayment: needPaymentToUnlock,
isPaid,
amountYuan: needPaymentToUnlock ? amountYuan : 0
}
this.setData({ payInfo })
} else {
wx.showToast({ title: res.data?.message || '加载失败', icon: 'none' })
}
},
fail: () => wx.showToast({ title: '网络错误', icon: 'none' }),
complete: () => wx.hideLoading()
})
},
initPayInfoFromRuntime(testType) {
app.getRuntimeConfig()
.then((cfg) => {
const reportRequires = cfg.reportRequiresPayment || {}
const pricing = cfg.pricing || {}
const requiresPayment = !!(reportRequires && reportRequires[testType])
const amountYuan = Number(pricing[testType]) || (requiresPayment ? 1.98 : 0)
this.setData({
payInfo: { requiresPayment, isPaid: false, amountYuan }
})
})
.catch(() => this.setData({ payInfo: { requiresPayment: false, isPaid: false, amountYuan: 0 } }))
},
unlockFullReport() {
const { payInfo, testResultId, hasReloadedAfterPay } = this.data
if (!payInfo.requiresPayment || payInfo.isPaid) return
app.ensureLogin && app.ensureLogin().then((logged) => {
if (!logged) { wx.showToast({ title: '请先登录', icon: 'none' }); return }
payment.purchaseDiscTest({
testResultId: testResultId || undefined,
success: () => {
wx.showToast({ title: '已解锁完整报告', icon: 'success' })
this.setData({ 'payInfo.isPaid': true })
if (testResultId && !hasReloadedAfterPay) {
this.setData({ hasReloadedAfterPay: true })
setTimeout(() => this.loadDetail(testResultId), 500)
}
},
fail: () => {}
})
})
},
retakeTest() {
if (!this.data.testResultId) {
wx.removeStorageSync('discResult')
}
wx.navigateTo({ url: '/pages/test/disc' })
},
goHome() {
const scope = (getApp().globalData && getApp().globalData.appScope) || 'personal'
if (scope === 'enterprise') {
wx.navigateTo({ url: '/pages/enterprise/index' })
} else {
wx.switchTab({ url: '/pages/index/index' })
}
},
onShareAppMessage() {
const result = this.data.result
const { getSharePathByScope } = require('../../utils/share')
return {
title: `我的DISC类型是${result?.dominantType}型(${result?.description?.title}),来测测你的吧!`,
path: getSharePathByScope('/pages/index/index')
}
},
onShareTimeline() {
const result = this.data.result
const { buildShareQuery } = require('../../utils/share')
return {
title: `我的DISC类型是${result?.dominantType}型(${result?.description?.title}),来测测你的吧!`,
query: buildShareQuery()
}
}
})
// pages/result/disc.js - DISC结果页支持付费墙 + 历史详情拉取)
const app = getApp()
const payment = require('../../utils/payment')
function toIntPercent(v) {
if (v == null) return 0
const n = typeof v === 'number' ? v : Number(v)
return Number.isFinite(n) ? Math.round(n) : 0
}
function withPercentagesInt(data) {
if (!data) return data
const p = data.percentages || {}
const out = { ...data }
out.percentagesInt = {
D: toIntPercent(p.D ?? p.d),
I: toIntPercent(p.I ?? p.i),
S: toIntPercent(p.S ?? p.s),
C: toIntPercent(p.C ?? p.c)
}
return out
}
Page({
data: {
result: null,
typeList: [
{ type: 'D', label: 'D型 - 支配型', colorClass: 'fill-d' },
{ type: 'I', label: 'I型 - 影响型', colorClass: 'fill-i' },
{ type: 'S', label: 'S型 - 稳健型', colorClass: 'fill-s' },
{ type: 'C', label: 'C型 - 谨慎型', colorClass: 'fill-c' }
],
payInfo: { requiresPayment: false, isPaid: false, amountYuan: 0 },
testResultId: null,
hasReloadedAfterPay: false
},
onLoad(options) {
const id = options && options.id
const type = options && options.type
if (id && type === 'disc') {
this.setData({ testResultId: id })
this.loadDetail(id)
return
}
const result = wx.getStorageSync('discResult')
if (result) {
this.setData({ result: withPercentagesInt(result) })
this.initPayInfoFromRuntime('disc')
} else {
wx.showToast({ title: '暂无测试结果', icon: 'none' })
setTimeout(() => wx.navigateBack(), 1500)
}
},
loadDetail(id) {
const apiBase = app.globalData?.apiBase || ''
const token = app.globalData?.token || wx.getStorageSync('token') || ''
if (!apiBase) { wx.showToast({ title: '配置异常', icon: 'none' }); return }
wx.showLoading({ title: '加载中...' })
wx.request({
url: `${apiBase}/api/test/detail`,
method: 'GET',
header: token ? { Authorization: `Bearer ${token}` } : {},
data: { id },
success: (res) => {
if (res.statusCode === 200 && res.data && res.data.code === 200) {
const payload = res.data.data || {}
const data = payload.data || payload
const isPaid = !!payload.isPaid
const paidAmount = payload.paidAmount != null ? Number(payload.paidAmount) : 0
const amountYuan = payload.amountYuan != null ? Number(payload.amountYuan) : (paidAmount > 0 ? paidAmount / 100 : 0)
const needPaymentToUnlock = payload.needPaymentToUnlock === true || (!!payload.requiresPayment && !isPaid && paidAmount > 0)
this.setData({ result: withPercentagesInt(data) })
const payInfo = {
requiresPayment: needPaymentToUnlock,
isPaid,
amountYuan: needPaymentToUnlock ? amountYuan : 0
}
this.setData({ payInfo })
} else {
wx.showToast({ title: res.data?.message || '加载失败', icon: 'none' })
}
},
fail: () => wx.showToast({ title: '网络错误', icon: 'none' }),
complete: () => wx.hideLoading()
})
},
initPayInfoFromRuntime(testType) {
app.getRuntimeConfig()
.then((cfg) => {
const reportRequires = cfg.reportRequiresPayment || {}
const pricing = cfg.pricing || {}
const requiresPayment = !!(reportRequires && reportRequires[testType])
const amountYuan = Number(pricing[testType]) || (requiresPayment ? 1.98 : 0)
this.setData({
payInfo: { requiresPayment, isPaid: false, amountYuan }
})
})
.catch(() => this.setData({ payInfo: { requiresPayment: false, isPaid: false, amountYuan: 0 } }))
},
unlockFullReport() {
const { payInfo, testResultId, hasReloadedAfterPay } = this.data
if (!payInfo.requiresPayment || payInfo.isPaid) return
app.ensureLogin && app.ensureLogin().then((logged) => {
if (!logged) { wx.showToast({ title: '请先登录', icon: 'none' }); return }
payment.purchaseDiscTest({
testResultId: testResultId || undefined,
success: () => {
wx.showToast({ title: '已解锁完整报告', icon: 'success' })
this.setData({ 'payInfo.isPaid': true })
if (testResultId && !hasReloadedAfterPay) {
this.setData({ hasReloadedAfterPay: true })
setTimeout(() => this.loadDetail(testResultId), 500)
}
},
fail: () => {}
})
})
},
retakeTest() {
if (!this.data.testResultId) {
wx.removeStorageSync('discResult')
}
wx.navigateTo({ url: '/pages/test/disc' })
},
goHome() {
const scope = (getApp().globalData && getApp().globalData.appScope) || 'personal'
if (scope === 'enterprise') {
wx.navigateTo({ url: '/pages/enterprise/index' })
} else {
wx.switchTab({ url: '/pages/index/index' })
}
},
onShareAppMessage() {
const result = this.data.result
const { getSharePathByScope } = require('../../utils/share')
return {
title: `我的DISC类型是${result?.dominantType}型(${result?.description?.title}),来测测你的吧!`,
path: getSharePathByScope('/pages/index/index')
}
},
onShareTimeline() {
const result = this.data.result
const { buildShareQuery } = require('../../utils/share')
return {
title: `我的DISC类型是${result?.dominantType}型(${result?.description?.title}),来测测你的吧!`,
query: buildShareQuery()
}
}
})

View File

@@ -65,7 +65,7 @@
</view>
</view>
<view class="action-section">
<!-- <view class="action-section">
<button class="btn btn-primary" open-type="share">
<text class="btn-text">分享结果</text>
</button>
@@ -75,7 +75,7 @@
<view class="btn btn-outline" bindtap="goHome">
<text class="btn-text-outline">返回首页</text>
</view>
</view>
</view> -->
</view>
</scroll-view>
</view>

View File

@@ -91,7 +91,7 @@
<text class="relationship-text">{{mbtiDesc.relationships}}</text>
</view>
<view class="action-section">
<!-- <view class="action-section">
<button class="btn btn-primary" open-type="share">
<text class="btn-text">分享结果</text>
</button>
@@ -101,7 +101,7 @@
<view class="btn btn-outline" bindtap="goHome">
<text class="btn-text-outline">返回首页</text>
</view>
</view>
</view> -->
</view>
</scroll-view>
</view>

View File

@@ -1,157 +1,157 @@
// pages/result/pdp.js - PDP结果页支持付费墙 + 历史详情拉取)
const app = getApp()
const payment = require('../../utils/payment')
const PDP_KEYS = ['Tiger', 'Peacock', 'Koala', 'Owl', 'Chameleon']
function toIntPercent(v) {
if (v == null) return 0
const n = typeof v === 'number' ? v : Number(v)
return Number.isFinite(n) ? Math.round(n) : 0
}
function withPercentagesInt(data) {
if (!data) return data
const p = data.percentages || {}
const out = { ...data }
const ints = {}
PDP_KEYS.forEach((k) => { ints[k] = toIntPercent(p[k] ?? p[k.toLowerCase()]) })
out.percentagesInt = ints
return out
}
Page({
data: {
result: null,
typeList: [
{ type: 'Tiger', emoji: '🐅', label: '老虎型', colorClass: 'fill-tiger' },
{ type: 'Peacock', emoji: '🦚', label: '孔雀型', colorClass: 'fill-peacock' },
{ type: 'Koala', emoji: '🐨', label: '考拉型', colorClass: 'fill-koala' },
{ type: 'Owl', emoji: '🦉', label: '猫头鹰型', colorClass: 'fill-owl' },
{ type: 'Chameleon', emoji: '🦎', label: '变色龙型', colorClass: 'fill-chameleon' }
],
payInfo: { requiresPayment: false, isPaid: false, amountYuan: 0 },
testResultId: null,
hasReloadedAfterPay: false
},
onLoad(options) {
const id = options && options.id
const type = options && options.type
if (id && type === 'pdp') {
this.setData({ testResultId: id })
this.loadDetail(id)
return
}
const result = wx.getStorageSync('pdpResult')
if (result) {
this.setData({ result: withPercentagesInt(result) })
this.initPayInfoFromRuntime('pdp')
} else {
wx.showToast({ title: '暂无测试结果', icon: 'none' })
setTimeout(() => wx.navigateBack(), 1500)
}
},
loadDetail(id) {
const apiBase = app.globalData?.apiBase || ''
const token = app.globalData?.token || wx.getStorageSync('token') || ''
if (!apiBase) { wx.showToast({ title: '配置异常', icon: 'none' }); return }
wx.showLoading({ title: '加载中...' })
wx.request({
url: `${apiBase}/api/test/detail`,
method: 'GET',
header: token ? { Authorization: `Bearer ${token}` } : {},
data: { id },
success: (res) => {
if (res.statusCode === 200 && res.data && res.data.code === 200) {
const payload = res.data.data || {}
const data = payload.data || payload
const isPaid = !!payload.isPaid
const paidAmount = payload.paidAmount != null ? Number(payload.paidAmount) : 0
const amountYuan = payload.amountYuan != null ? Number(payload.amountYuan) : (paidAmount > 0 ? paidAmount / 100 : 0)
const needPaymentToUnlock = payload.needPaymentToUnlock === true || (!!payload.requiresPayment && !isPaid && paidAmount > 0)
this.setData({ result: withPercentagesInt(data) })
const payInfo = {
requiresPayment: needPaymentToUnlock,
isPaid,
amountYuan: needPaymentToUnlock ? amountYuan : 0
}
this.setData({ payInfo })
} else {
wx.showToast({ title: res.data?.message || '加载失败', icon: 'none' })
}
},
fail: () => wx.showToast({ title: '网络错误', icon: 'none' }),
complete: () => wx.hideLoading()
})
},
initPayInfoFromRuntime(testType) {
app.getRuntimeConfig()
.then((cfg) => {
const reportRequires = cfg.reportRequiresPayment || {}
const pricing = cfg.pricing || {}
const requiresPayment = !!(reportRequires && reportRequires[testType])
const amountYuan = Number(pricing[testType]) || (requiresPayment ? 1.98 : 0)
this.setData({
payInfo: { requiresPayment, isPaid: false, amountYuan }
})
})
.catch(() => this.setData({ payInfo: { requiresPayment: false, isPaid: false, amountYuan: 0 } }))
},
unlockFullReport() {
const { payInfo, testResultId, hasReloadedAfterPay } = this.data
if (!payInfo.requiresPayment || payInfo.isPaid) return
app.ensureLogin && app.ensureLogin().then((logged) => {
if (!logged) { wx.showToast({ title: '请先登录', icon: 'none' }); return }
payment.purchasePdpTest({
testResultId: testResultId || undefined,
success: () => {
wx.showToast({ title: '已解锁完整报告', icon: 'success' })
this.setData({ 'payInfo.isPaid': true })
if (testResultId && !hasReloadedAfterPay) {
this.setData({ hasReloadedAfterPay: true })
setTimeout(() => this.loadDetail(testResultId), 500)
}
},
fail: () => {}
})
})
},
retakeTest() {
if (!this.data.testResultId) {
wx.removeStorageSync('pdpResult')
}
wx.navigateTo({ url: '/pages/test/pdp' })
},
goHome() {
const scope = (getApp().globalData && getApp().globalData.appScope) || 'personal'
if (scope === 'enterprise') {
wx.navigateTo({ url: '/pages/enterprise/index' })
} else {
wx.switchTab({ url: '/pages/index/index' })
}
},
onShareAppMessage() {
const result = this.data.result
const { getSharePathByScope } = require('../../utils/share')
return {
title: `我的PDP类型是${result?.description?.type}${result?.description?.emoji},来测测你的吧!`,
path: getSharePathByScope('/pages/index/index')
}
},
onShareTimeline() {
const result = this.data.result
const { buildShareQuery } = require('../../utils/share')
return {
title: `我的PDP类型是${result?.description?.type}${result?.description?.emoji},来测测你的吧!`,
query: buildShareQuery()
}
}
})
// pages/result/pdp.js - PDP结果页支持付费墙 + 历史详情拉取)
const app = getApp()
const payment = require('../../utils/payment')
const PDP_KEYS = ['Tiger', 'Peacock', 'Koala', 'Owl', 'Chameleon']
function toIntPercent(v) {
if (v == null) return 0
const n = typeof v === 'number' ? v : Number(v)
return Number.isFinite(n) ? Math.round(n) : 0
}
function withPercentagesInt(data) {
if (!data) return data
const p = data.percentages || {}
const out = { ...data }
const ints = {}
PDP_KEYS.forEach((k) => { ints[k] = toIntPercent(p[k] ?? p[k.toLowerCase()]) })
out.percentagesInt = ints
return out
}
Page({
data: {
result: null,
typeList: [
{ type: 'Tiger', emoji: '🐅', label: '老虎型', colorClass: 'fill-tiger' },
{ type: 'Peacock', emoji: '🦚', label: '孔雀型', colorClass: 'fill-peacock' },
{ type: 'Koala', emoji: '🐨', label: '考拉型', colorClass: 'fill-koala' },
{ type: 'Owl', emoji: '🦉', label: '猫头鹰型', colorClass: 'fill-owl' },
{ type: 'Chameleon', emoji: '🦎', label: '变色龙型', colorClass: 'fill-chameleon' }
],
payInfo: { requiresPayment: false, isPaid: false, amountYuan: 0 },
testResultId: null,
hasReloadedAfterPay: false
},
onLoad(options) {
const id = options && options.id
const type = options && options.type
if (id && type === 'pdp') {
this.setData({ testResultId: id })
this.loadDetail(id)
return
}
const result = wx.getStorageSync('pdpResult')
if (result) {
this.setData({ result: withPercentagesInt(result) })
this.initPayInfoFromRuntime('pdp')
} else {
wx.showToast({ title: '暂无测试结果', icon: 'none' })
setTimeout(() => wx.navigateBack(), 1500)
}
},
loadDetail(id) {
const apiBase = app.globalData?.apiBase || ''
const token = app.globalData?.token || wx.getStorageSync('token') || ''
if (!apiBase) { wx.showToast({ title: '配置异常', icon: 'none' }); return }
wx.showLoading({ title: '加载中...' })
wx.request({
url: `${apiBase}/api/test/detail`,
method: 'GET',
header: token ? { Authorization: `Bearer ${token}` } : {},
data: { id },
success: (res) => {
if (res.statusCode === 200 && res.data && res.data.code === 200) {
const payload = res.data.data || {}
const data = payload.data || payload
const isPaid = !!payload.isPaid
const paidAmount = payload.paidAmount != null ? Number(payload.paidAmount) : 0
const amountYuan = payload.amountYuan != null ? Number(payload.amountYuan) : (paidAmount > 0 ? paidAmount / 100 : 0)
const needPaymentToUnlock = payload.needPaymentToUnlock === true || (!!payload.requiresPayment && !isPaid && paidAmount > 0)
this.setData({ result: withPercentagesInt(data) })
const payInfo = {
requiresPayment: needPaymentToUnlock,
isPaid,
amountYuan: needPaymentToUnlock ? amountYuan : 0
}
this.setData({ payInfo })
} else {
wx.showToast({ title: res.data?.message || '加载失败', icon: 'none' })
}
},
fail: () => wx.showToast({ title: '网络错误', icon: 'none' }),
complete: () => wx.hideLoading()
})
},
initPayInfoFromRuntime(testType) {
app.getRuntimeConfig()
.then((cfg) => {
const reportRequires = cfg.reportRequiresPayment || {}
const pricing = cfg.pricing || {}
const requiresPayment = !!(reportRequires && reportRequires[testType])
const amountYuan = Number(pricing[testType]) || (requiresPayment ? 1.98 : 0)
this.setData({
payInfo: { requiresPayment, isPaid: false, amountYuan }
})
})
.catch(() => this.setData({ payInfo: { requiresPayment: false, isPaid: false, amountYuan: 0 } }))
},
unlockFullReport() {
const { payInfo, testResultId, hasReloadedAfterPay } = this.data
if (!payInfo.requiresPayment || payInfo.isPaid) return
app.ensureLogin && app.ensureLogin().then((logged) => {
if (!logged) { wx.showToast({ title: '请先登录', icon: 'none' }); return }
payment.purchasePdpTest({
testResultId: testResultId || undefined,
success: () => {
wx.showToast({ title: '已解锁完整报告', icon: 'success' })
this.setData({ 'payInfo.isPaid': true })
if (testResultId && !hasReloadedAfterPay) {
this.setData({ hasReloadedAfterPay: true })
setTimeout(() => this.loadDetail(testResultId), 500)
}
},
fail: () => {}
})
})
},
retakeTest() {
if (!this.data.testResultId) {
wx.removeStorageSync('pdpResult')
}
wx.navigateTo({ url: '/pages/test/pdp' })
},
goHome() {
const scope = (getApp().globalData && getApp().globalData.appScope) || 'personal'
if (scope === 'enterprise') {
wx.navigateTo({ url: '/pages/enterprise/index' })
} else {
wx.switchTab({ url: '/pages/index/index' })
}
},
onShareAppMessage() {
const result = this.data.result
const { getSharePathByScope } = require('../../utils/share')
return {
title: `我的PDP类型是${result?.description?.type}${result?.description?.emoji},来测测你的吧!`,
path: getSharePathByScope('/pages/index/index')
}
},
onShareTimeline() {
const result = this.data.result
const { buildShareQuery } = require('../../utils/share')
return {
title: `我的PDP类型是${result?.description?.type}${result?.description?.emoji},来测测你的吧!`,
query: buildShareQuery()
}
}
})

View File

@@ -71,7 +71,7 @@
</view>
</view>
<view class="action-section">
<!-- <view class="action-section">
<button class="btn btn-primary" open-type="share">
<text class="btn-text">分享结果</text>
</button>
@@ -81,7 +81,7 @@
<view class="btn btn-outline" bindtap="goHome">
<text class="btn-text-outline">返回首页</text>
</view>
</view>
</view> -->
</view>
</scroll-view>
</view>

View File

@@ -23,10 +23,15 @@ Page({
onLoad() {
const questions = shuffleQuestions(discQuestions)
this.setData({ questions, currentQuestion: questions[0] })
try { require('../../utils/analytics').track('test_start', { type: 'disc', total: questions.length }) } catch (e) {}
this.startTimer()
},
onUnload() {
if (this._advanceTimer) {
clearTimeout(this._advanceTimer)
this._advanceTimer = null
}
if (this.timer) clearInterval(this.timer)
},
@@ -48,28 +53,48 @@ Page({
},
selectAnswer(e) {
if (this._advanceTimer) {
clearTimeout(this._advanceTimer)
this._advanceTimer = null
}
const value = e.currentTarget.dataset.value
const questionId = this.data.currentQuestion.id
let answers = { ...this.data.answers }
answers[questionId] = value
this.setData({
selectedAnswer: value,
answers: answers,
answeredCount: Object.keys(answers).length,
progress: (Object.keys(answers).length / this.data.total) * 100
})
const cq = this.data.currentQuestion
if (value == null || !cq || cq.id == null) return
setTimeout(() => {
if (this.data.currentIndex < this.data.total - 1) {
this.nextQuestion()
} else {
this.submitTest()
const questionId = cq.id
const idx = this.data.currentIndex
const tot = this.data.total
const answers = { ...this.data.answers }
answers[questionId] = value
const answeredCount = Object.keys(answers).length
this.setData(
{
selectedAnswer: value,
answers,
answeredCount,
progress: tot ? (answeredCount / tot) * 100 : 0
},
() => {
this._advanceTimer = setTimeout(() => {
this._advanceTimer = null
const d = this.data
if (d.currentIndex !== idx || !d.currentQuestion || d.currentQuestion.id !== questionId) return
if (idx < tot - 1) {
this.nextQuestion()
} else {
this.submitTest()
}
}, 300)
}
}, 300)
)
},
prevQuestion() {
if (this._advanceTimer) {
clearTimeout(this._advanceTimer)
this._advanceTimer = null
}
if (this.data.currentIndex > 0) {
const newIndex = this.data.currentIndex - 1
const newQuestion = this.data.questions[newIndex]
@@ -131,6 +156,7 @@ Page({
// 本地缓存 + 全局缓存
wx.setStorageSync('discResult', resultData)
try { require('../../utils/analytics').track('test_complete', { type: 'disc', result: dominantType, duration: resultData.testDuration }) } catch (e) {}
if (app && typeof app.saveTestResult === 'function') {
app.saveTestResult('disc', resultData)
}

View File

@@ -32,6 +32,7 @@ Page({
total,
progress: total ? Math.round((1 / total) * 100) : 0
})
try { require('../../utils/analytics').track('test_start', { type: 'mbti', total }) } catch (e) {}
this.startTimer()
},
@@ -43,6 +44,10 @@ Page({
},
onUnload() {
if (this._advanceTimer) {
clearTimeout(this._advanceTimer)
this._advanceTimer = null
}
if (this.timer) {
clearInterval(this.timer)
}
@@ -66,36 +71,53 @@ Page({
}, 1000)
},
// 选择答案(用闭包保存题号,避免 setTimeout 与 setData 时序导致最后一题未提交)
// 选择答案:防抖 + 过期回调校验,避免连点导致重复 next 漏题
selectAnswer(e) {
if (this._advanceTimer) {
clearTimeout(this._advanceTimer)
this._advanceTimer = null
}
const value = e.currentTarget.dataset.value
const questionId = this.data.currentQuestion.id
const cq = this.data.currentQuestion
if (value == null || !cq || cq.id == null) return
const questionId = cq.id
const idx = this.data.currentIndex
const tot = this.data.total
let answers = { ...this.data.answers }
const answers = { ...this.data.answers }
answers[questionId] = value
const answeredCount = Object.keys(answers).length
const progress = tot ? Math.round(((idx + 1) / tot) * 100) : 0
this.setData({
selectedAnswer: value,
answers,
answeredCount,
progress
})
setTimeout(() => {
if (idx < tot - 1) {
this.nextQuestion()
} else {
this.submitTest()
this.setData(
{
selectedAnswer: value,
answers,
answeredCount,
progress
},
() => {
this._advanceTimer = setTimeout(() => {
this._advanceTimer = null
const d = this.data
if (d.currentIndex !== idx || !d.currentQuestion || d.currentQuestion.id !== questionId) return
if (idx < tot - 1) {
this.nextQuestion()
} else {
this.submitTest()
}
}, 320)
}
}, 320)
)
},
// 上一题
prevQuestion() {
if (this._advanceTimer) {
clearTimeout(this._advanceTimer)
this._advanceTimer = null
}
if (this.data.currentIndex > 0) {
const newIndex = this.data.currentIndex - 1
const newQuestion = this.data.questions[newIndex]
@@ -181,6 +203,7 @@ Page({
}
wx.setStorageSync('mbtiResult', resultData)
app.saveTestResult('mbti', resultData)
try { require('../../utils/analytics').track('test_complete', { type: 'mbti', result: result.mbtiType, confidence: result.confidence, duration: resultData.testDuration }) } catch (e) {}
wx.redirectTo({
url: '/pages/result/mbti'

View File

@@ -23,10 +23,15 @@ Page({
onLoad() {
const questions = shuffleQuestions(pdpQuestions)
this.setData({ questions, currentQuestion: questions[0] })
try { require('../../utils/analytics').track('test_start', { type: 'pdp', total: questions.length }) } catch (e) {}
this.startTimer()
},
onUnload() {
if (this._advanceTimer) {
clearTimeout(this._advanceTimer)
this._advanceTimer = null
}
if (this.timer) clearInterval(this.timer)
},
@@ -53,28 +58,48 @@ Page({
},
selectAnswer(e) {
if (this._advanceTimer) {
clearTimeout(this._advanceTimer)
this._advanceTimer = null
}
const value = e.currentTarget.dataset.value
const questionId = this.data.currentQuestion.id
let answers = { ...this.data.answers }
answers[questionId] = value
this.setData({
selectedAnswer: value,
answers: answers,
answeredCount: Object.keys(answers).length,
progress: (Object.keys(answers).length / this.data.total) * 100
})
const cq = this.data.currentQuestion
if (value == null || !cq || cq.id == null) return
setTimeout(() => {
if (this.data.currentIndex < this.data.total - 1) {
this.nextQuestion()
} else {
this.submitTest()
const questionId = cq.id
const idx = this.data.currentIndex
const tot = this.data.total
const answers = { ...this.data.answers }
answers[questionId] = value
const answeredCount = Object.keys(answers).length
this.setData(
{
selectedAnswer: value,
answers,
answeredCount,
progress: tot ? (answeredCount / tot) * 100 : 0
},
() => {
this._advanceTimer = setTimeout(() => {
this._advanceTimer = null
const d = this.data
if (d.currentIndex !== idx || !d.currentQuestion || d.currentQuestion.id !== questionId) return
if (idx < tot - 1) {
this.nextQuestion()
} else {
this.submitTest()
}
}, 300)
}
}, 300)
)
},
prevQuestion() {
if (this._advanceTimer) {
clearTimeout(this._advanceTimer)
this._advanceTimer = null
}
if (this.data.currentIndex > 0) {
const newIndex = this.data.currentIndex - 1
const newQuestion = this.data.questions[newIndex]
@@ -134,6 +159,7 @@ Page({
// 本地缓存 + 全局缓存
wx.setStorageSync('pdpResult', resultData)
try { require('../../utils/analytics').track('test_complete', { type: 'pdp', result: dominantType, duration: resultData.testDuration }) } catch (e) {}
if (app && typeof app.saveTestResult === 'function') {
app.saveTestResult('pdp', resultData)
}

View File

@@ -1,18 +1,52 @@
/**
* 小程序埋点:批量上报 /api/analytics/events(可选登录,便于超管后台关联用户)
* 小程序埋点:批量上报 /api/analytics/events
* - session_id 区分每次启动
* - 自动采集平台、网络、设备、场景值
* - 定时 flush + 页面隐藏 flush
*/
const { request } = require('./request.js')
const MAX_BATCH = 30
const FLUSH_INTERVAL = 10000
const queue = []
let lastPageReport = { path: '', t: 0 }
function getAppSafe() {
const sessionId = 'wx_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
let _deviceInfo = null
function getDeviceInfo() {
if (_deviceInfo) return _deviceInfo
try {
return getApp()
const sys = wx.getSystemInfoSync()
_deviceInfo = {
brand: sys.brand,
model: sys.model,
system: sys.system,
platform: sys.platform,
version: sys.version,
SDKVersion: sys.SDKVersion,
screenWidth: sys.screenWidth,
screenHeight: sys.screenHeight,
pixelRatio: sys.pixelRatio
}
} catch (e) {
return null
_deviceInfo = {}
}
return _deviceInfo
}
let _networkType = 'unknown'
function refreshNetwork() {
try {
wx.getNetworkType({
success(res) { _networkType = res.networkType || 'unknown' }
})
} catch (e) {}
}
refreshNetwork()
function getAppSafe() {
try { return getApp() } catch (e) { return null }
}
function getCurrentRoute() {
@@ -21,19 +55,27 @@ function getCurrentRoute() {
return p && p.route ? p.route : ''
}
function getScene() {
const app = getAppSafe()
return (app && app.globalData && app.globalData.scene) || ''
}
function track(eventName, props) {
if (!eventName || typeof eventName !== 'string') return
const app = getAppSafe()
const openId =
app && app.globalData
? (app.globalData.openId || (app.globalData.userInfo && (app.globalData.userInfo.openid || app.globalData.userInfo.openId)) || '')
: ''
const openId = app && app.globalData
? (app.globalData.openId || (app.globalData.userInfo && (app.globalData.userInfo.openid || app.globalData.userInfo.openId)) || '')
: ''
queue.push({
event_name: eventName,
page_path: getCurrentRoute(),
props: props && typeof props === 'object' ? props : {},
client_ts: Date.now(),
openid: openId || undefined
openid: openId || undefined,
session_id: sessionId,
platform: 'wechat',
network: _networkType,
scene: getScene()
})
if (queue.length >= MAX_BATCH) {
flush()
@@ -41,15 +83,13 @@ function track(eventName, props) {
}
/** 页面曝光(防抖:同路径 2 秒内只记一次) */
function reportPageView() {
function reportPageView(extraProps) {
const path = getCurrentRoute()
if (!path) return
const now = Date.now()
if (path === lastPageReport.path && now - lastPageReport.t < 2000) {
return
}
if (path === lastPageReport.path && now - lastPageReport.t < 2000) return
lastPageReport = { path, t: now }
track('page_view', { path })
track('page_view', { path, ...(extraProps || {}) })
}
function flush() {
@@ -59,16 +99,57 @@ function flush() {
url: '/api/analytics/events',
method: 'POST',
needAuth: true,
data: { events },
data: { events, device: getDeviceInfo() },
allow401: false,
success() {},
fail() {}
})
}
/** 上报应用启动(首次 onLaunch 调用一次) */
function reportAppLaunch(launchOptions) {
const opts = launchOptions || {}
track('app_launch', {
scene: opts.scene,
query: opts.query || {},
referrerInfo: opts.referrerInfo || {},
device: getDeviceInfo()
})
}
/** 上报分享行为 */
function reportShare(channel, extra) {
track('share', { channel: channel || 'friend', ...(extra || {}) })
flush()
}
/** 上报支付结果 */
function reportPayResult(success, extra) {
track(success ? 'pay_success' : 'pay_fail', extra || {})
flush()
}
let _flushTimer = null
function startAutoFlush() {
if (_flushTimer) return
_flushTimer = setInterval(flush, FLUSH_INTERVAL)
}
function stopAutoFlush() {
if (_flushTimer) {
clearInterval(_flushTimer)
_flushTimer = null
}
}
startAutoFlush()
module.exports = {
track,
flush,
reportPageView,
getCurrentRoute
reportAppLaunch,
reportShare,
reportPayResult,
getCurrentRoute,
startAutoFlush,
stopAutoFlush
}

View File

@@ -130,15 +130,15 @@ function wxPay(options) {
// 支付成功后轮询后端订单状态 3~5 次,确保通过微信订单查询接口确认成功
pollOrderStatus(orderId, 5, 1000, (ok, order) => {
if (ok) {
try { require('./analytics').reportPayResult(true, { productType: productType || '', orderId, amount }) } catch (e) {}
wx.showToast({
title: '支付成功',
icon: 'success',
duration: 2000
})
// 将本地订单信息一并透传给业务方
success && success({ payRes, order })
} else {
// 落库状态暂未确认,但微信侧已成功,一般稍后会自动对齐
try { require('./analytics').reportPayResult(true, { productType: productType || '', orderId, amount, note: 'poll_pending' }) } catch (e) {}
wx.showToast({
title: '支付结果处理中,请稍后在历史中查看',
icon: 'none',
@@ -150,8 +150,9 @@ function wxPay(options) {
},
fail: (payErr) => {
console.error('支付失败', payErr)
if (payErr.errMsg.indexOf('cancel') !== -1) {
const isCancel = payErr.errMsg && payErr.errMsg.indexOf('cancel') !== -1
try { require('./analytics').reportPayResult(false, { productType: productType || '', orderId, reason: isCancel ? 'cancel' : 'fail' }) } catch (e) {}
if (isCancel) {
wx.showToast({
title: '支付已取消',
icon: 'none'