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

@@ -9,142 +9,132 @@
append-to-body
@update:model-value="$emit('update:modelValue', $event)"
>
<div v-loading="loading" class="ud-wrap">
<!-- 侧栏 ud-sidebar 与主区 ud-main-pane 之间间距由 .ud-layout gap 控制 -->
<div v-loading="loading" class="ud-layout">
<template v-if="user">
<aside class="ud-side">
<div class="ud-avatar-block">
<img v-if="user.avatar" :src="user.avatar" class="ud-avatar-img" referrerpolicy="no-referrer" />
<div v-else class="ud-avatar-letter">{{ avatarLetter }}</div>
<div class="ud-name">{{ user.username || '未命名用户' }}</div>
<aside class="ud-sidebar">
<div class="ud-profile-avatar">
<img
v-if="detailAvatarUrl"
:src="detailAvatarUrl"
class="ud-profile-avatar__img"
referrerpolicy="no-referrer"
/>
<div v-else class="ud-profile-avatar__letter">{{ avatarLetter }}</div>
<div class="ud-profile-name">{{ user.username || '未命名用户' }}</div>
</div>
<div class="ud-meta">
<div class="ud-meta-row">
<div class="ud-profile-meta">
<div class="ud-profile-meta__row">
<el-icon><Key /></el-icon>
<span>ID {{ user.id }}</span>
</div>
<div class="ud-meta-row" v-if="user.phone">
<div class="ud-profile-meta__row" v-if="user.phone">
<el-icon><Phone /></el-icon>
<span>{{ user.phone }}</span>
</div>
<div class="ud-meta-row">
<div class="ud-profile-meta__row">
<el-icon><Calendar /></el-icon>
<span>{{ formatDate(user.createdAt) }}</span>
</div>
</div>
<div class="ud-stat-icons">
<div class="ud-quick-stats">
<el-tooltip content="测试次数" placement="top">
<div class="ud-stat-ic"><el-icon><DataAnalysis /></el-icon>{{ user.testCount ?? 0 }}</div>
<div class="ud-quick-stats__tile"><el-icon><DataAnalysis /></el-icon>{{ user.testCount ?? 0 }}</div>
</el-tooltip>
<el-tooltip content="MBTI" placement="top">
<div class="ud-stat-ic"><el-icon><Aim /></el-icon>{{ shortOrDash(user.mbtiType) }}</div>
<div class="ud-quick-stats__tile"><el-icon><Aim /></el-icon>{{ shortOrDash(user.mbtiType) }}</div>
</el-tooltip>
<el-tooltip content="PDP" placement="top">
<div class="ud-stat-ic"><el-icon><TrendCharts /></el-icon>{{ shortOrDash(user.pdpType, 4) }}</div>
<div class="ud-quick-stats__tile"><el-icon><TrendCharts /></el-icon>{{ shortOrDash(user.pdpType, 4) }}</div>
</el-tooltip>
<el-tooltip content="DISC" placement="top">
<div class="ud-stat-ic"><el-icon><PieChart /></el-icon>{{ shortOrDash(user.discType, 3) }}</div>
<div class="ud-quick-stats__tile"><el-icon><PieChart /></el-icon>{{ shortOrDash(user.discType, 3) }}</div>
</el-tooltip>
</div>
<div class="ud-tags" v-if="profileTags.length">
<div class="ud-tags-title">维度标签</div>
<el-tag v-for="t in profileTags" :key="t" size="small" class="ud-tag">{{ t }}</el-tag>
<div class="ud-dimension-tags" v-if="profileTags.length">
<div class="ud-dimension-tags__title">维度标签</div>
<el-tag v-for="t in profileTags" :key="t" size="small" class="ud-dimension-tags__item">{{ t }}</el-tag>
</div>
</aside>
<main class="ud-main">
<main class="ud-main-pane">
<el-tabs v-model="udTab" class="ud-tabs">
<el-tab-pane label="分析结果" name="analysis">
<div class="ud-scroll">
<div class="ud-radar-row">
<div class="ud-radar-cell">
<div class="ud-radar-title"><el-icon><Aim /></el-icon> MBTI</div>
<VChart v-if="mbtiRadarOption" class="ud-chart" :option="mbtiRadarOption" autoresize />
<div v-else class="ud-chart-empty">暂无 MBTI 测评</div>
<div v-if="mbtiSummary" class="ud-mini-type">{{ mbtiSummary }}</div>
<div class="ud-main-scroll">
<div class="ud-chart-row">
<div class="ud-chart-panel">
<div class="ud-chart-heading"><el-icon><Aim /></el-icon> MBTI</div>
<VChart v-if="mbtiRadarOption" class="ud-chart-echart" :option="mbtiRadarOption" autoresize />
<div v-else class="ud-chart-placeholder">暂无 MBTI 测评</div>
<div v-if="mbtiSummary" class="ud-chart-footnote">{{ mbtiSummary }}</div>
</div>
<div class="ud-radar-cell">
<div class="ud-radar-title"><el-icon><TrendCharts /></el-icon> PDP</div>
<VChart v-if="pdpRadarOption" class="ud-chart" :option="pdpRadarOption" autoresize />
<div v-else class="ud-chart-empty">暂无 PDP 测评</div>
<div class="ud-chart-panel">
<div class="ud-chart-heading"><el-icon><TrendCharts /></el-icon> PDP</div>
<VChart v-if="pdpRadarOption" class="ud-chart-echart" :option="pdpRadarOption" autoresize />
<div v-else class="ud-chart-placeholder">暂无 PDP 测评</div>
</div>
<div class="ud-radar-cell">
<div class="ud-radar-title"><el-icon><PieChart /></el-icon> DISC</div>
<VChart v-if="discRadarOption" class="ud-chart" :option="discRadarOption" autoresize />
<div v-else class="ud-chart-empty">暂无 DISC 测评</div>
<div class="ud-chart-panel">
<div class="ud-chart-heading"><el-icon><PieChart /></el-icon> DISC</div>
<VChart v-if="discRadarOption" class="ud-chart-echart" :option="discRadarOption" autoresize />
<div v-else class="ud-chart-placeholder">暂无 DISC 测评</div>
</div>
</div>
<div class="ud-row2">
<div class="ud-card">
<div class="ud-card-h"><el-icon><Star /></el-icon> 盖洛普优势</div>
<div v-if="gallupList.length" class="ud-gallup">
<div v-for="(g, i) in gallupList" :key="i" class="ud-gallup-item">
<span class="ud-gi-n">{{ i + 1 }}</span>
<span class="ud-gi-t">{{ g }}</span>
<div class="ud-dual-cards">
<div class="ud-panel-card">
<div class="ud-panel-card__head"><el-icon><Star /></el-icon> 盖洛普优势</div>
<div v-if="gallupList.length" class="ud-gallup-list">
<div v-for="(g, idx) in gallupList" :key="idx" class="ud-gallup-list__item">
<span class="ud-gallup-list__index">{{ Number(idx) + 1 }}</span>
<span class="ud-gallup-list__text">{{ g }}</span>
</div>
</div>
<div v-else class="ud-muted">暂无盖洛普数据深度报告解锁后可见</div>
<div v-else class="ud-empty-hint">暂无盖洛普数据深度报告解锁后可见</div>
</div>
<div class="ud-card ud-grow">
<div class="ud-card-h"><el-icon><OfficeBuilding /></el-icon> 岗位匹配参考</div>
<div class="ud-roles">
<div v-for="r in roleFitList" :key="r.name" class="ud-role">
<span class="ud-role-n">{{ r.name }}</span>
<div class="ud-panel-card ud-panel-card--wide">
<div class="ud-panel-card__head"><el-icon><OfficeBuilding /></el-icon> 岗位匹配参考</div>
<div class="ud-rolefit-list">
<div v-for="r in roleFitList" :key="r.name" class="ud-rolefit-list__row">
<span class="ud-rolefit-list__name">{{ r.name }}</span>
<el-progress :percentage="r.pct" :stroke-width="6" :show-text="false" />
<span class="ud-role-p">{{ r.pct }}%</span>
<span class="ud-rolefit-list__percent">{{ r.pct }}%</span>
</div>
</div>
</div>
</div>
<div v-if="showEnterpriseMatch" class="ud-card ud-match-card">
<div class="ud-card-h">
<div v-if="showEnterpriseMatch" class="ud-panel-card ud-enterprise-match">
<div class="ud-panel-card__head">
<el-icon><Connection /></el-icon> 推荐匹配企业
<span class="ud-hint">按登记企业测评池与您维度同质度排序</span>
<span class="ud-panel-card__hint">按登记企业测评池与您维度同质度排序</span>
</div>
<div v-if="matchingEnterprises.length" class="ud-match-list">
<div v-for="ent in matchingEnterprises" :key="ent.id" class="ud-match-row">
<div class="ud-m-info">
<div class="ud-m-name">{{ ent.name }}</div>
<div class="ud-m-sub">{{ ent.matchTypeLabel }} · 匹配度 {{ ent.matchScore }}%</div>
<div class="ud-m-reason">{{ ent.matchReason }}</div>
<div v-if="ent.contactName || ent.contactPhone" class="ud-m-contact">
<div v-if="matchingEnterprises.length" class="ud-enterprise-match__list">
<div v-for="ent in matchingEnterprises" :key="ent.id" class="ud-enterprise-match__item">
<div class="ud-enterprise-match__body">
<div class="ud-enterprise-match__name">{{ ent.name }}</div>
<div class="ud-enterprise-match__meta">{{ ent.matchTypeLabel }} · 匹配度 {{ ent.matchScore }}%</div>
<div class="ud-enterprise-match__desc">{{ ent.matchReason }}</div>
<div v-if="ent.contactName || ent.contactPhone" class="ud-enterprise-match__contact">
<el-icon><User /></el-icon>
{{ ent.contactName || '负责人' }}
<span v-if="ent.contactPhone" class="ud-m-phone">{{ ent.contactPhone }}</span>
<span v-if="ent.contactPhone" class="ud-enterprise-match__phone">{{ ent.contactPhone }}</span>
</div>
</div>
<div class="ud-m-actions">
<el-button
v-if="ent.contactPhone"
type="primary"
size="small"
@click="openDial(ent.contactPhone)"
>
<el-icon><Phone /></el-icon>
</el-button>
<el-button v-if="ent.contactPhone" size="small" @click="copyText(ent.contactPhone, '电话已复制')">
复制电话
</el-button>
<el-button v-if="ent.contactEmail" size="small" @click="openMail(ent.contactEmail)">
邮件
</el-button>
</div>
</div>
</div>
<div v-else class="ud-muted">暂无企业数据</div>
<div v-else class="ud-empty-hint">暂无企业数据</div>
</div>
<div v-else class="ud-muted ud-enterprise-hint">
<div v-else class="ud-empty-hint ud-enterprise-match__hint">
跨企业人才匹配与负责人直连请在超级管理后台 用户总览查看
</div>
</div>
</el-tab-pane>
<el-tab-pane label="测试记录" name="tests">
<el-table v-if="testTableData.length" :data="paginatedTests" size="small" max-height="360" class="ud-test-tb">
<el-table v-if="testTableData.length" :data="paginatedTests" size="small" max-height="360" class="ud-test-records-table">
<el-table-column width="52" align="center">
<template #default="{ row }">
<el-icon class="ud-ticon" :class="testIconClass(row.testType)"><component :is="testIcon(row.testType)" /></el-icon>
<el-icon class="ud-test-type-icon" :class="testIconClass(row.testType)"><component :is="testIcon(row.testType)" /></el-icon>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="时间" width="108">
@@ -172,8 +162,8 @@
</template>
</el-table-column>
</el-table>
<div v-else class="ud-muted">暂无测试记录</div>
<div class="ud-pager" v-if="testTableData.length > testPageSize">
<div v-else class="ud-empty-hint">暂无测试记录</div>
<div class="ud-test-records-pagination" v-if="testTableData.length > testPageSize">
<el-pagination
v-model:current-page="testPage"
:page-size="testPageSize"
@@ -185,17 +175,17 @@
</el-tab-pane>
<el-tab-pane label="人像相册" name="photos">
<div v-if="facePhotos.length" class="ud-photos">
<div v-if="facePhotos.length" class="ud-photo-gallery">
<el-image
v-for="(url, idx) in facePhotos"
:key="url + idx"
:src="url"
fit="cover"
class="ud-ph"
class="ud-photo-gallery__thumb"
:preview-src-list="facePhotos"
/>
</div>
<div v-else class="ud-muted">暂无人脸分析照片</div>
<div v-else class="ud-empty-hint">暂无人脸分析照片</div>
</el-tab-pane>
</el-tabs>
</main>
@@ -277,6 +267,13 @@ const avatarLetter = computed(() => {
return (n.charAt(0) || '?').toUpperCase()
})
const detailAvatarUrl = computed(() => {
const u = props.user as Record<string, any> | null | undefined
if (!u) return ''
const a = u.avatar ?? u.avatarUrl ?? ''
return typeof a === 'string' ? a.trim() : ''
})
const rawTests = computed(() => (props.user?.testList || []) as any[])
const hasTestScopeCol = computed(() => rawTests.value.some(t => t.testScope != null))
@@ -601,18 +598,16 @@ function openMail(email: string) {
}
}
.ud-wrap {
min-height: 200px;
}
.ud-grid {
/* 整行布局:侧栏与主区之间保留视觉空隙(与设计稿一致约 1624px */
.ud-layout {
display: flex;
gap: 14px;
align-items: stretch;
gap: 20px;
min-height: 200px;
max-height: 78vh;
}
.ud-side {
.ud-sidebar {
width: 200px;
flex-shrink: 0;
background: linear-gradient(180deg, #faf5ff 0%, #fff 40%);
@@ -621,19 +616,19 @@ function openMail(email: string) {
padding: 12px;
}
.ud-avatar-block {
.ud-profile-avatar {
text-align: center;
margin-bottom: 10px;
}
.ud-avatar-img {
.ud-profile-avatar__img {
width: 56px;
height: 56px;
border-radius: 50%;
object-fit: cover;
}
.ud-avatar-letter {
.ud-profile-avatar__letter {
width: 56px;
height: 56px;
border-radius: 50%;
@@ -646,14 +641,14 @@ function openMail(email: string) {
justify-content: center;
}
.ud-name {
.ud-profile-name {
margin-top: 6px;
font-weight: 700;
font-size: 14px;
color: #111827;
}
.ud-meta-row {
.ud-profile-meta__row {
display: flex;
align-items: center;
gap: 6px;
@@ -666,14 +661,14 @@ function openMail(email: string) {
}
}
.ud-stat-icons {
.ud-quick-stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-top: 10px;
}
.ud-stat-ic {
.ud-quick-stats__tile {
background: #fff;
border-radius: 8px;
padding: 6px 8px;
@@ -690,21 +685,21 @@ function openMail(email: string) {
}
}
.ud-tags {
.ud-dimension-tags {
margin-top: 12px;
}
.ud-tags-title {
.ud-dimension-tags__title {
font-size: 11px;
color: #9ca3af;
margin-bottom: 6px;
}
.ud-tag {
.ud-dimension-tags__item {
margin: 0 4px 4px 0;
}
.ud-main {
.ud-main-pane {
flex: 1;
min-width: 0;
border: 1px solid #f3f4f6;
@@ -730,26 +725,26 @@ function openMail(email: string) {
}
}
.ud-scroll {
.ud-main-scroll {
max-height: calc(78vh - 120px);
overflow-y: auto;
padding-right: 4px;
}
.ud-radar-row {
.ud-chart-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.ud-radar-cell {
.ud-chart-panel {
background: #fafafa;
border-radius: 8px;
padding: 6px 4px 4px;
text-align: center;
}
.ud-radar-title {
.ud-chart-heading {
display: flex;
align-items: center;
justify-content: center;
@@ -764,12 +759,12 @@ function openMail(email: string) {
}
}
.ud-chart {
.ud-chart-echart {
height: 150px;
width: 100%;
}
.ud-chart-empty {
.ud-chart-placeholder {
height: 120px;
display: flex;
align-items: center;
@@ -778,19 +773,19 @@ function openMail(email: string) {
color: #9ca3af;
}
.ud-mini-type {
.ud-chart-footnote {
font-size: 11px;
color: #6b7280;
padding: 0 4px 4px;
}
.ud-row2 {
.ud-dual-cards {
display: flex;
gap: 10px;
margin-top: 10px;
}
.ud-card {
.ud-panel-card {
background: #fff;
border: 1px solid #f3f4f6;
border-radius: 8px;
@@ -799,11 +794,11 @@ function openMail(email: string) {
min-width: 0;
}
.ud-grow {
.ud-panel-card--wide {
flex: 1.2;
}
.ud-card-h {
.ud-panel-card__head {
display: flex;
align-items: center;
gap: 6px;
@@ -817,14 +812,14 @@ function openMail(email: string) {
}
}
.ud-hint {
.ud-panel-card__hint {
font-weight: 400;
font-size: 11px;
color: #9ca3af;
margin-left: 4px;
}
.ud-gallup-item {
.ud-gallup-list__item {
display: flex;
align-items: center;
gap: 8px;
@@ -832,7 +827,7 @@ function openMail(email: string) {
margin-bottom: 6px;
}
.ud-gi-n {
.ud-gallup-list__index {
width: 18px;
height: 18px;
border-radius: 4px;
@@ -845,13 +840,13 @@ function openMail(email: string) {
justify-content: center;
}
.ud-roles {
.ud-rolefit-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.ud-role {
.ud-rolefit-list__row {
display: grid;
grid-template-columns: 72px 1fr 36px;
gap: 8px;
@@ -859,30 +854,30 @@ function openMail(email: string) {
font-size: 11px;
}
.ud-role-n {
.ud-rolefit-list__name {
color: #4b5563;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ud-role-p {
.ud-rolefit-list__percent {
text-align: right;
color: #7c3aed;
font-weight: 600;
}
.ud-match-card {
.ud-enterprise-match {
margin-top: 10px;
}
.ud-match-list {
.ud-enterprise-match__list {
display: flex;
flex-direction: column;
gap: 8px;
}
.ud-match-row {
.ud-enterprise-match__item {
display: flex;
justify-content: space-between;
gap: 10px;
@@ -892,26 +887,26 @@ function openMail(email: string) {
border-radius: 8px;
}
.ud-m-name {
.ud-enterprise-match__name {
font-weight: 600;
font-size: 13px;
color: #111827;
}
.ud-m-sub {
.ud-enterprise-match__meta {
font-size: 11px;
color: #7c3aed;
margin-top: 2px;
}
.ud-m-reason {
.ud-enterprise-match__desc {
font-size: 11px;
color: #6b7280;
margin-top: 4px;
line-height: 1.4;
}
.ud-m-contact {
.ud-enterprise-match__contact {
display: flex;
align-items: center;
gap: 4px;
@@ -924,34 +919,34 @@ function openMail(email: string) {
}
}
.ud-m-phone {
.ud-enterprise-match__phone {
font-family: ui-monospace, monospace;
color: #111827;
}
.ud-m-actions {
.ud-enterprise-match__actions {
display: flex;
flex-direction: column;
gap: 4px;
flex-shrink: 0;
}
.ud-muted {
.ud-empty-hint {
font-size: 12px;
color: #9ca3af;
padding: 8px 0;
}
.ud-enterprise-hint {
.ud-enterprise-match__hint {
margin-top: 8px;
line-height: 1.5;
}
.ud-test-tb {
.ud-test-records-table {
width: 100%;
}
.ud-ticon {
.ud-test-type-icon {
font-size: 18px;
}
.tic-mbti {
@@ -970,33 +965,33 @@ function openMail(email: string) {
color: #6b7280;
}
.ud-pager {
.ud-test-records-pagination {
margin-top: 8px;
display: flex;
justify-content: center;
}
.ud-photos {
.ud-photo-gallery {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.ud-ph {
.ud-photo-gallery__thumb {
width: 100px;
height: 100px;
border-radius: 8px;
}
@media (max-width: 900px) {
.ud-grid {
.ud-layout {
flex-direction: column;
max-height: none;
}
.ud-side {
.ud-sidebar {
width: 100%;
}
.ud-radar-row {
.ud-chart-row {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,138 @@
/**
* 人脸 / AI 分析结果:与微信小程序 pages/index/result 展示字段对齐,供后台「测试详情」使用
*/
export interface FaceFeatureItem {
label: string
description: string
}
export interface FaceBoneIceSummary {
elementType?: string
boneFleshRelation?: string
}
export interface FaceDetailView {
photos: string[]
mbti: { type?: string; title?: string } | null
disc: { primary?: string; secondary?: string } | null
pdp: { primary?: string; secondary?: string } | null
overview: string
personalitySummary: string
/** 字符串版面相(与小程序 faceAnalysisText */
faceAnalysisText: string
/** 结构化面相:面部特征列表 */
faceFeatures: FaceFeatureItem[]
/** 字符串版骨相正文 */
boneAnalysisText: string
/** 《冰鉴》结构化摘要(小程序 boneFormSummary */
boneIceSummary: FaceBoneIceSummary | null
advantages: string[]
gallupTop3: string[]
relationship: string
careerDevelopment: string
familyParenting: string
partnerCofounder: string
portrait: Record<string, any> | null
hrView: Record<string, any> | null
bossView: Record<string, any> | null
resumeHighlights: string
careers: string[]
}
function parseMaybeJsonObject(raw: unknown): Record<string, any> | null {
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
return raw as Record<string, any>
}
if (typeof raw === 'string' && raw.trim().startsWith('{')) {
try {
const o = JSON.parse(raw) as unknown
return o && typeof o === 'object' && !Array.isArray(o) ? (o as Record<string, any>) : null
} catch {
return null
}
}
return null
}
function extractFaceFeatures(raw: unknown): FaceFeatureItem[] {
const o = parseMaybeJsonObject(raw)
if (!o) return []
const fe = o.facialFeatures
if (!Array.isArray(fe)) return []
return fe
.map((item: any) => ({
label: String(item?.label ?? ''),
description: String(item?.description ?? '')
}))
.filter((f) => f.label || f.description)
}
function extractBoneIceSummary(raw: unknown): FaceBoneIceSummary | null {
const o = parseMaybeJsonObject(raw)
const sum = o?.boneFormSummary
if (sum && typeof sum === 'object') {
return {
elementType: sum.elementType != null ? String(sum.elementType) : undefined,
boneFleshRelation: sum.boneFleshRelation != null ? String(sum.boneFleshRelation) : undefined
}
}
return null
}
/** 从 AI 结果 JSON 构建后台展示模型(兼容字符串 / 结构化 / JSON 字符串) */
export function buildFaceDetailFromParsed(parsed: Record<string, any> | null | undefined): FaceDetailView | null {
if (!parsed || typeof parsed !== 'object') return null
const faceRaw = parsed.faceAnalysis
const boneRaw = parsed.boneAnalysis
const faceFeatures = extractFaceFeatures(faceRaw)
let faceAnalysisText = ''
if (typeof faceRaw === 'string') {
const tryObj = parseMaybeJsonObject(faceRaw)
if (tryObj && Array.isArray(tryObj.facialFeatures) && tryObj.facialFeatures.length) {
faceAnalysisText = ''
} else {
faceAnalysisText = faceRaw.trim()
}
}
const boneIceSummary = extractBoneIceSummary(boneRaw)
let boneAnalysisText = ''
if (typeof boneRaw === 'string') {
const tryObj = parseMaybeJsonObject(boneRaw)
if (tryObj?.boneFormSummary && typeof tryObj.boneFormSummary === 'object') {
boneAnalysisText = ''
} else {
boneAnalysisText = boneRaw.trim()
}
}
const strArr = (v: unknown): string[] =>
Array.isArray(v) ? (v as unknown[]).map((x) => String(x)).filter(Boolean) : []
return {
photos: Array.isArray(parsed.photoUrls) ? (parsed.photoUrls as string[]) : [],
mbti: parsed.mbti && typeof parsed.mbti === 'object' ? parsed.mbti : null,
disc: parsed.disc && typeof parsed.disc === 'object' ? parsed.disc : null,
pdp: parsed.pdp && typeof parsed.pdp === 'object' ? parsed.pdp : null,
overview: String(parsed.overview ?? ''),
personalitySummary: String(parsed.personalitySummary ?? ''),
faceAnalysisText,
faceFeatures,
boneAnalysisText,
boneIceSummary,
advantages: strArr(parsed.advantages),
gallupTop3: strArr(parsed.gallupTop3),
relationship: String(parsed.relationship ?? ''),
careerDevelopment: String(parsed.careerDevelopment ?? ''),
familyParenting: String(parsed.familyParenting ?? ''),
partnerCofounder: String(parsed.partnerCofounder ?? ''),
portrait: parsed.portrait && typeof parsed.portrait === 'object' ? parsed.portrait : null,
hrView: parsed.hrView && typeof parsed.hrView === 'object' ? parsed.hrView : null,
bossView: parsed.bossView && typeof parsed.bossView === 'object' ? parsed.bossView : null,
resumeHighlights: String(parsed.resumeHighlights ?? ''),
careers: strArr(parsed.careers)
}
}

View File

@@ -110,15 +110,24 @@
<div class="panel-head row">
<div>
<h2 class="panel-title">邀请小程序码</h2>
<p class="panel-desc">员工 / 客户扫码进企业测评</p>
<p class="panel-desc">企业版扫码进企业测评个人版扫码进个人首页</p>
</div>
<el-button size="small" type="primary" @click="loadInviteQrcode" :loading="inviteLoading">
{{ inviteQrcode ? '刷新' : '生成' }}
{{ inviteQrcodeEnterprise || inviteQrcodePersonal ? '刷新' : '生成' }}
</el-button>
</div>
<div class="invite-body">
<img v-if="inviteQrcode" :src="inviteQrcode" alt="邀请码" class="invite-img" />
<span v-else class="invite-placeholder">点击生成</span>
<template v-if="inviteQrcodeEnterprise || inviteQrcodePersonal">
<div v-if="inviteQrcodeEnterprise" class="invite-card">
<span class="invite-label">企业版</span>
<img :src="inviteQrcodeEnterprise" alt="企业版邀请码" class="invite-img" />
</div>
<div v-if="inviteQrcodePersonal" class="invite-card">
<span class="invite-label">个人版</span>
<img :src="inviteQrcodePersonal" alt="个人版邀请码" class="invite-img" />
</div>
</template>
<span v-else class="invite-placeholder">点击生成将同时生成两枚码</span>
</div>
</div>
</aside>
@@ -189,7 +198,8 @@ const faceSubtypeHints = ref<{
const loading = ref(false)
const inviteLoading = ref(false)
const inviteQrcode = ref<string>('')
const inviteQrcodeEnterprise = ref<string>('')
const inviteQrcodePersonal = ref<string>('')
/** 侧栏表格最大高度:单屏内滚动,不撑开整页 */
const tableMaxH = 220
@@ -388,9 +398,13 @@ const loadInviteQrcode = async () => {
inviteLoading.value = true
try {
const res: any = await request.get('/admin/invite/qrcode')
const qrcode = res?.data?.qrcode
if (qrcode && typeof qrcode === 'string') {
inviteQrcode.value = qrcode
const d = res?.data
const ent = d?.enterprise?.qrcode ?? d?.qrcode
const per = d?.personal?.qrcode
if (typeof ent === 'string' && ent) inviteQrcodeEnterprise.value = ent
if (typeof per === 'string' && per) inviteQrcodePersonal.value = per
if (inviteQrcodeEnterprise.value || inviteQrcodePersonal.value) {
ElMessage.success('已生成企业版与个人版小程序码')
} else {
ElMessage.error(res?.message || res?.msg || '生成失败,请确认企业绑定')
}
@@ -807,11 +821,26 @@ const loadInviteQrcode = async () => {
.invite-body {
display: flex;
align-items: center;
flex-wrap: wrap;
align-items: flex-start;
justify-content: center;
gap: 16px;
min-height: 112px;
}
.invite-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.invite-label {
font-size: 12px;
font-weight: 600;
color: #374151;
}
.invite-img {
width: 112px;
height: 112px;

View File

@@ -35,7 +35,16 @@
<el-table-column label="用户信息" min-width="180">
<template #default="{ row }">
<div class="user-info-cell">
<div class="user-avatar">{{ (row.username || '?')[0] }}</div>
<el-avatar
:size="36"
shape="circle"
:src="displayAvatar(row) || undefined"
fit="cover"
class="user-avatar user-el-avatar"
:style="!displayAvatar(row) ? { backgroundColor: avatarBgColor(row) } : {}"
>
<span class="user-avatar-letter-fallback">{{ avatarLetter(row) }}</span>
</el-avatar>
<div class="user-details">
<div class="username">{{ row.username || '未设置昵称' }}</div>
<div class="openid-line">{{ row.openid || '—' }}</div>
@@ -328,41 +337,169 @@
</div>
</div>
<div class="face-right">
<div class="test-detail-grid">
<div class="test-detail-block" v-if="faceDetail.mbti">
<!-- 第一组核心性格标签 (MBTI, DISC, PDP) -->
<div class="test-detail-row compact-row">
<div class="test-detail-block flex-1" v-if="faceDetail.mbti">
<h4 class="detail-subtitle">AI 识别 MBTI</h4>
<p class="test-desc">
<strong>{{ faceDetail.mbti.type }}</strong>
<span v-if="faceDetail.mbti.title"> · {{ faceDetail.mbti.title }}</span>
<strong class="text-primary">{{ faceDetail.mbti.type }}</strong>
<span v-if="faceDetail.mbti.title" class="text-secondary"> · {{ faceDetail.mbti.title }}</span>
</p>
</div>
<div class="test-detail-block" v-if="faceDetail.disc">
<div class="test-detail-block flex-1" v-if="faceDetail.disc">
<h4 class="detail-subtitle">AI 识别 DISC</h4>
<p class="test-desc">
主类型<strong>{{ faceDetail.disc.primary }}</strong>
<span v-if="faceDetail.disc.secondary">类型{{ faceDetail.disc.secondary }}</span>
主类型<strong class="text-primary">{{ faceDetail.disc.primary }}</strong>
<span v-if="faceDetail.disc.secondary" class="text-secondary">{{ faceDetail.disc.secondary }}</span>
</p>
</div>
<div class="test-detail-block" v-if="faceDetail.pdp">
<div class="test-detail-block flex-1" v-if="faceDetail.pdp">
<h4 class="detail-subtitle">AI 识别 PDP</h4>
<p class="test-desc">
主类型<strong>{{ faceDetail.pdp.primary }}</strong>
<span v-if="faceDetail.pdp.secondary">类型{{ faceDetail.pdp.secondary }}</span>
主类型<strong class="text-primary">{{ faceDetail.pdp.primary }}</strong>
<span v-if="faceDetail.pdp.secondary" class="text-secondary">{{ faceDetail.pdp.secondary }}</span>
</p>
</div>
</div>
<!-- 第二组核心优势 (主要优势, 盖洛普) -->
<div class="test-detail-grid">
<div class="test-detail-block" v-if="faceDetail.advantages?.length">
<h4 class="detail-subtitle">主要优势</h4>
<ul class="tag-list">
<li v-for="item in faceDetail.advantages" :key="item">{{ item }}</li>
</ul>
</div>
<div class="test-detail-block" v-if="faceDetail.gallupTop3?.length">
<h4 class="detail-subtitle">盖洛普前三大优势</h4>
<ol class="face-gallup-list">
<li v-for="(g, gi) in faceDetail.gallupTop3" :key="gi">{{ g }}</li>
</ol>
</div>
</div>
<!-- 第三组综述 -->
<div class="test-detail-grid">
<div class="test-detail-block" v-if="faceDetail.overview">
<h4 class="detail-subtitle">整体气质概览</h4>
<p class="test-desc">{{ faceDetail.overview }}</p>
</div>
<div class="test-detail-block" v-if="faceDetail.boneAnalysis">
<h4 class="detail-subtitle">骨相特征</h4>
<p class="test-desc">{{ faceDetail.boneAnalysis }}</p>
</div>
<div class="test-detail-block" v-if="faceDetail.personalitySummary">
<h4 class="detail-subtitle">性格总结</h4>
<p class="test-desc">{{ faceDetail.personalitySummary }}</p>
</div>
</div>
<!-- 第四组面相与骨相 -->
<div class="test-detail-grid">
<div class="test-detail-block" v-if="faceDetail.faceFeatures?.length">
<h4 class="detail-subtitle">面部特征分析</h4>
<ul class="face-feature-list">
<li v-for="(f, fi) in faceDetail.faceFeatures" :key="fi">
<strong v-if="f.label">{{ f.label }}</strong>
<span>{{ f.description }}</span>
</li>
</ul>
</div>
<div class="test-detail-block" v-else-if="faceDetail.faceAnalysisText">
<h4 class="detail-subtitle">面相分析</h4>
<p class="test-desc">{{ faceDetail.faceAnalysisText }}</p>
</div>
<div class="test-detail-block" v-if="faceDetail.boneIceSummary && (faceDetail.boneIceSummary.elementType || faceDetail.boneIceSummary.boneFleshRelation)">
<h4 class="detail-subtitle">骨相分析冰鉴</h4>
<p v-if="faceDetail.boneIceSummary.elementType" class="test-desc">
<strong>五行形相</strong>{{ faceDetail.boneIceSummary.elementType }}
</p>
<p v-if="faceDetail.boneIceSummary.boneFleshRelation" class="test-desc">
<strong>骨肉关系</strong>{{ faceDetail.boneIceSummary.boneFleshRelation }}
</p>
</div>
<div class="test-detail-block" v-if="faceDetail.boneAnalysisText">
<h4 class="detail-subtitle">骨相特征</h4>
<p class="test-desc">{{ faceDetail.boneAnalysisText }}</p>
</div>
</div>
<!-- 第五组人际与发展 -->
<div class="test-detail-grid">
<div class="test-detail-block" v-if="faceDetail.relationship">
<h4 class="detail-subtitle">人际关系与团队合作</h4>
<p class="test-desc">{{ faceDetail.relationship }}</p>
</div>
<div class="test-detail-block" v-if="faceDetail.careers?.length">
<h4 class="detail-subtitle">职业方向参考</h4>
<ul class="tag-list tag-list-info">
<li v-for="c in faceDetail.careers" :key="c">{{ c }}</li>
</ul>
</div>
</div>
<!-- 第六组生活场景 -->
<div class="test-detail-grid">
<div class="test-detail-block" v-if="faceDetail.careerDevelopment">
<h4 class="detail-subtitle">职业发展方向</h4>
<p class="test-desc">{{ faceDetail.careerDevelopment }}</p>
</div>
<div class="test-detail-block" v-if="faceDetail.familyParenting">
<h4 class="detail-subtitle">家庭亲子关系</h4>
<p class="test-desc">{{ faceDetail.familyParenting }}</p>
</div>
<div class="test-detail-block" v-if="faceDetail.partnerCofounder">
<h4 class="detail-subtitle">寻找合伙人</h4>
<p class="test-desc">{{ faceDetail.partnerCofounder }}</p>
</div>
</div>
<!-- 第七组深度职场洞察 (Portrait, Boss, HR, Resume) -->
<div class="test-detail-grid" v-if="faceDetail.portrait || faceDetail.bossView || faceDetail.hrView || faceDetail.resumeHighlights">
<template v-if="faceDetail.portrait">
<div class="test-detail-block" v-if="faceDetail.portrait.coreStrengths?.length">
<h4 class="detail-subtitle">深度·核心优势</h4>
<ul class="tag-list">
<li v-for="s in faceDetail.portrait.coreStrengths" :key="s">{{ s }}</li>
</ul>
</div>
<div class="test-detail-block" v-if="faceDetail.portrait.coreRisks?.length">
<h4 class="detail-subtitle">深度·潜在风险</h4>
<ul class="tag-list tag-list-warning">
<li v-for="r in faceDetail.portrait.coreRisks" :key="r">{{ r }}</li>
</ul>
</div>
<div class="test-detail-block" v-if="faceDetail.portrait.workStyle">
<h4 class="detail-subtitle">深度·工作风格</h4>
<p class="test-desc">{{ faceDetail.portrait.workStyle }}</p>
</div>
</template>
<div class="test-detail-block" v-if="faceDetail.resumeHighlights">
<h4 class="detail-subtitle">简历要点</h4>
<p class="test-desc">{{ faceDetail.resumeHighlights }}</p>
</div>
<template v-if="faceDetail.bossView">
<div class="test-detail-block" v-if="faceDetail.bossView.headline">
<h4 class="detail-subtitle">老板视角·一句话</h4>
<p class="test-desc">{{ faceDetail.bossView.headline }}</p>
</div>
<div class="test-detail-block" v-if="faceDetail.bossView.costInsight">
<h4 class="detail-subtitle">老板视角·成本洞察</h4>
<p class="test-desc">{{ faceDetail.bossView.costInsight }}</p>
</div>
</template>
<template v-if="faceDetail.hrView">
<div class="test-detail-block" v-if="faceDetail.hrView.roleRecommend?.bestFit?.length">
<h4 class="detail-subtitle">HR·推荐岗位</h4>
<ul class="tag-list tag-list-info">
<li v-for="r in faceDetail.hrView.roleRecommend.bestFit" :key="r">{{ r }}</li>
</ul>
</div>
<div class="test-detail-block" v-if="faceDetail.hrView.roleRecommend?.notSuitable?.length">
<h4 class="detail-subtitle">HR·不适合场景</h4>
<ul class="tag-list tag-list-warning">
<li v-for="r in faceDetail.hrView.roleRecommend.notSuitable" :key="r">{{ r }}</li>
</ul>
</div>
</template>
</div>
</div>
</div>
</div>
@@ -491,7 +628,10 @@
<div class="resume-content">{{ currentTestDescription }}</div>
</div>
<div class="detail-section" v-if="currentTestType !== 'resume' && currentTestDescription">
<div
class="detail-section"
v-if="currentTestType !== 'resume' && currentTestType !== 'face' && currentTestType !== 'ai' && currentTestDescription"
>
<h4 class="detail-subtitle">补充说明</h4>
<p class="test-desc">{{ currentTestDescription }}</p>
</div>
@@ -505,6 +645,7 @@ import { ref, computed, onMounted } from 'vue'
import { Download, Search, View } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { request } from '@/utils/request'
import { buildFaceDetailFromParsed } from '@/utils/faceResultDetail'
import UserDetailDialog from '@/components/UserDetailDialog.vue'
const loading = ref(false)
@@ -553,6 +694,24 @@ async function loadUsers() {
}
}
function displayAvatar(row: Record<string, any>) {
const a = row?.avatar ?? row?.avatarUrl ?? ''
return typeof a === 'string' ? a.trim() : ''
}
function avatarLetter(row: { username?: string; nickname?: string }) {
const name = (row?.username || row?.nickname || '?').trim()
return (name.charAt(0) || '?').toUpperCase()
}
const AVATAR_PALETTE = ['#6366f1', '#8b5cf6', '#ec4899', '#f43f5e', '#14b8a6', '#0ea5e9', '#3b82f6', '#eab308']
function avatarBgColor(row: { username?: string; nickname?: string }) {
const name = (row?.username || row?.nickname || '?').trim()
let hash = 0
for (let i = 0; i < name.length; i++) hash += name.charCodeAt(i)
return AVATAR_PALETTE[Math.abs(hash) % AVATAR_PALETTE.length]
}
function formatPhone(phone: string) {
if (!phone) return '-'
if (phone.length === 11) return phone.substring(0, 3) + '****' + phone.substring(7)
@@ -691,15 +850,7 @@ const pdpDetail = computed(() => {
const faceDetail = computed(() => {
const parsed = currentTestParsed.value
if (!parsed || (currentTestType.value !== 'face' && currentTestType.value !== 'ai')) return null
return {
mbti: parsed.mbti || null,
disc: parsed.disc || null,
pdp: parsed.pdp || null,
overview: parsed.overview ?? '',
boneAnalysis: parsed.boneAnalysis ?? '',
personalitySummary: parsed.personalitySummary ?? '',
photos: Array.isArray(parsed.photoUrls) ? parsed.photoUrls : []
}
return buildFaceDetailFromParsed(parsed)
})
const resumeDetail = computed(() => {
@@ -735,7 +886,15 @@ function extractTestDescription(parsed: any, testType: string): string {
}
if (t === 'face' || t === 'ai') {
return String(parsed.overview ?? parsed.personalitySummary ?? '')
const parts = [
parsed.relationship,
parsed.faceAnalysis && typeof parsed.faceAnalysis === 'string' ? parsed.faceAnalysis : '',
parsed.boneAnalysis && typeof parsed.boneAnalysis === 'string' ? parsed.boneAnalysis : ''
]
.map((x) => String(x || '').trim())
.filter(Boolean)
const extra = parts.length ? `\n\n${parts.join('\n\n')}` : ''
return String(parsed.overview ?? parsed.personalitySummary ?? '') + extra
}
if (t === 'resume') {
@@ -936,18 +1095,14 @@ onMounted(() => {
align-items: center;
gap: 12px;
.user-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background-color: #7c3aed;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
.user-avatar.user-el-avatar {
flex-shrink: 0;
font-size: 16px;
font-weight: 600;
flex-shrink: 0;
}
.user-avatar-letter-fallback {
color: #fff;
}
.user-details {
@@ -1313,6 +1468,9 @@ onMounted(() => {
.face-left {
width: 220px;
flex-shrink: 0;
position: sticky;
top: 0;
height: fit-content;
}
.face-photos {
@@ -1332,6 +1490,53 @@ onMounted(() => {
flex: 1;
}
.test-detail-row {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
.compact-row .test-detail-block {
margin-bottom: 0;
padding: 12px;
background-color: #f9fafb;
border-radius: 8px;
}
.flex-1 {
flex: 1;
}
.text-primary {
color: #4f46e5;
font-weight: 600;
}
.text-secondary {
color: #6b7280;
font-weight: normal;
}
.face-gallup-list {
margin: 6px 0 0 18px;
padding: 0;
font-size: 13px;
color: #374151;
line-height: 1.6;
}
.face-feature-list {
margin: 6px 0 0;
padding-left: 18px;
font-size: 13px;
color: #4b5563;
line-height: 1.65;
li {
margin-bottom: 6px;
}
}
.test-desc {
font-size: 13px;
color: #4b5563;

View File

@@ -29,7 +29,7 @@
>
<template #title>数据库归并可选</template>
<p class="migrate-text">
若需把库里的 enterpriseId记录<strong>永久写入</strong>存客宝企业可点下方按钮先预览条数再二次确认仅统计展示时不必执行接口已把个人池并入存客宝卡片
将把无企业归属的用户写入存客宝补齐其 <code>test_results</code> 的企业字段并把 <code>user_profile</code> personal 行与该企业 enterprise <strong>合并归档</strong>计数相加后删除 personal先预览条数再二次确认
</p>
<el-button type="primary" plain size="small" :loading="migrateLoading" @click="runOrphanMigrate">
预览并归并到存客宝
@@ -72,10 +72,15 @@ async function runOrphanMigrate() {
})
const d = res.data ?? res
const name = d.enterpriseName || '存客宝'
const tr = d.testResultsRows ?? 0
const wu = d.wechatUsersRows ?? 0
const tr = d.testResultsRows ?? 0
const pr = d.userProfilePersonalRows ?? 0
if (wu === 0 && tr === 0 && pr === 0) {
ElMessage.info((d.hint as string) || '当前无需归并')
return
}
await ElMessageBox.confirm(
`把无企业归属的数据写入「${name}」:测试记录约 ${tr},小程序用户表${wu} 行。此操作会修改数据库,是否继续?`,
`将写入「${name}」:小程序用户约 ${wu} 人;待补全企业的测试记录约 ${tr};待合并的 personal 画像行${pr} 条。将修改 wechat_users、test_results、user_profile,是否继续?`,
'确认归并',
{ type: 'warning', confirmButtonText: '写入', cancelButtonText: '取消' }
)

File diff suppressed because it is too large Load Diff

View File

@@ -136,7 +136,7 @@
<el-icon class="header-icon"><Setting /></el-icon>
<span>系统基础配置</span>
</div>
<p class="header-description">管理网站名称维护模式等基础设置</p>
<p class="header-description">管理网站名称等基础设置</p>
</div>
</template>
<div class="card-content">
@@ -255,16 +255,7 @@
</div>
</div>
<!-- 开关项 -->
<div class="switch-section">
<div class="switch-item">
<div class="switch-info">
<p class="switch-title">维护模式</p>
<p class="switch-desc">开启后前台将显示维护页面</p>
</div>
<el-switch v-model="systemConfig.maintenanceMode" />
</div>
</div>
<el-button
type="primary"

View File

@@ -102,19 +102,16 @@
<el-table-column label="用户信息" min-width="220">
<template #default="{ row }">
<div class="user-info-cell">
<img
v-if="row.avatar"
:src="row.avatar"
class="user-avatar user-avatar-img"
referrerpolicy="no-referrer"
/>
<div
v-else
class="user-avatar user-avatar-letter"
:style="{ backgroundColor: avatarBgColor(row) }"
<el-avatar
:size="36"
shape="circle"
:src="displayAvatar(row) || undefined"
fit="cover"
class="user-avatar user-el-avatar"
:style="!displayAvatar(row) ? { backgroundColor: avatarBgColor(row) } : {}"
>
{{ avatarLetter(row) }}
</div>
<span class="user-avatar-letter-fallback">{{ avatarLetter(row) }}</span>
</el-avatar>
<div class="user-details">
<div class="username">{{ row.username || '未设置昵称' }}</div>
<div class="openid-line">{{ row.openid || '—' }}</div>
@@ -427,41 +424,169 @@
</div>
</div>
<div class="face-right">
<div class="test-detail-grid">
<div class="test-detail-block" v-if="faceDetail.mbti">
<!-- 第一组核心性格标签 (MBTI, DISC, PDP) -->
<div class="test-detail-row compact-row">
<div class="test-detail-block flex-1" v-if="faceDetail.mbti">
<h4 class="detail-subtitle">AI 识别 MBTI</h4>
<p class="test-desc">
<strong>{{ faceDetail.mbti.type }}</strong>
<span v-if="faceDetail.mbti.title"> · {{ faceDetail.mbti.title }}</span>
<strong class="text-primary">{{ faceDetail.mbti.type }}</strong>
<span v-if="faceDetail.mbti.title" class="text-secondary"> · {{ faceDetail.mbti.title }}</span>
</p>
</div>
<div class="test-detail-block" v-if="faceDetail.disc">
<div class="test-detail-block flex-1" v-if="faceDetail.disc">
<h4 class="detail-subtitle">AI 识别 DISC</h4>
<p class="test-desc">
主类型<strong>{{ faceDetail.disc.primary }}</strong>
<span v-if="faceDetail.disc.secondary">类型{{ faceDetail.disc.secondary }}</span>
主类型<strong class="text-primary">{{ faceDetail.disc.primary }}</strong>
<span v-if="faceDetail.disc.secondary" class="text-secondary">{{ faceDetail.disc.secondary }}</span>
</p>
</div>
<div class="test-detail-block" v-if="faceDetail.pdp">
<div class="test-detail-block flex-1" v-if="faceDetail.pdp">
<h4 class="detail-subtitle">AI 识别 PDP</h4>
<p class="test-desc">
主类型<strong>{{ faceDetail.pdp.primary }}</strong>
<span v-if="faceDetail.pdp.secondary">类型{{ faceDetail.pdp.secondary }}</span>
主类型<strong class="text-primary">{{ faceDetail.pdp.primary }}</strong>
<span v-if="faceDetail.pdp.secondary" class="text-secondary">{{ faceDetail.pdp.secondary }}</span>
</p>
</div>
</div>
<!-- 第二组核心优势 (主要优势, 盖洛普) -->
<div class="test-detail-grid">
<div class="test-detail-block" v-if="faceDetail.advantages?.length">
<h4 class="detail-subtitle">主要优势</h4>
<ul class="tag-list">
<li v-for="item in faceDetail.advantages" :key="item">{{ item }}</li>
</ul>
</div>
<div class="test-detail-block" v-if="faceDetail.gallupTop3?.length">
<h4 class="detail-subtitle">盖洛普前三大优势</h4>
<ol class="face-gallup-list">
<li v-for="(g, gi) in faceDetail.gallupTop3" :key="gi">{{ g }}</li>
</ol>
</div>
</div>
<!-- 第三组综述 -->
<div class="test-detail-grid">
<div class="test-detail-block" v-if="faceDetail.overview">
<h4 class="detail-subtitle">整体气质概览</h4>
<p class="test-desc">{{ faceDetail.overview }}</p>
</div>
<div class="test-detail-block" v-if="faceDetail.boneAnalysis">
<h4 class="detail-subtitle">骨相特征</h4>
<p class="test-desc">{{ faceDetail.boneAnalysis }}</p>
</div>
<div class="test-detail-block" v-if="faceDetail.personalitySummary">
<h4 class="detail-subtitle">性格总结</h4>
<p class="test-desc">{{ faceDetail.personalitySummary }}</p>
</div>
</div>
<!-- 第四组面相与骨相 -->
<div class="test-detail-grid">
<div class="test-detail-block" v-if="faceDetail.faceFeatures?.length">
<h4 class="detail-subtitle">面部特征分析</h4>
<ul class="face-feature-list">
<li v-for="(f, fi) in faceDetail.faceFeatures" :key="fi">
<strong v-if="f.label">{{ f.label }}</strong>
<span>{{ f.description }}</span>
</li>
</ul>
</div>
<div class="test-detail-block" v-else-if="faceDetail.faceAnalysisText">
<h4 class="detail-subtitle">面相分析</h4>
<p class="test-desc">{{ faceDetail.faceAnalysisText }}</p>
</div>
<div class="test-detail-block" v-if="faceDetail.boneIceSummary && (faceDetail.boneIceSummary.elementType || faceDetail.boneIceSummary.boneFleshRelation)">
<h4 class="detail-subtitle">骨相分析冰鉴</h4>
<p v-if="faceDetail.boneIceSummary.elementType" class="test-desc">
<strong>五行形相</strong>{{ faceDetail.boneIceSummary.elementType }}
</p>
<p v-if="faceDetail.boneIceSummary.boneFleshRelation" class="test-desc">
<strong>骨肉关系</strong>{{ faceDetail.boneIceSummary.boneFleshRelation }}
</p>
</div>
<div class="test-detail-block" v-if="faceDetail.boneAnalysisText">
<h4 class="detail-subtitle">骨相特征</h4>
<p class="test-desc">{{ faceDetail.boneAnalysisText }}</p>
</div>
</div>
<!-- 第五组人际与发展 -->
<div class="test-detail-grid">
<div class="test-detail-block" v-if="faceDetail.relationship">
<h4 class="detail-subtitle">人际关系与团队合作</h4>
<p class="test-desc">{{ faceDetail.relationship }}</p>
</div>
<div class="test-detail-block" v-if="faceDetail.careers?.length">
<h4 class="detail-subtitle">职业方向参考</h4>
<ul class="tag-list tag-list-info">
<li v-for="c in faceDetail.careers" :key="c">{{ c }}</li>
</ul>
</div>
</div>
<!-- 第六组生活场景 -->
<div class="test-detail-grid">
<div class="test-detail-block" v-if="faceDetail.careerDevelopment">
<h4 class="detail-subtitle">职业发展方向</h4>
<p class="test-desc">{{ faceDetail.careerDevelopment }}</p>
</div>
<div class="test-detail-block" v-if="faceDetail.familyParenting">
<h4 class="detail-subtitle">家庭亲子关系</h4>
<p class="test-desc">{{ faceDetail.familyParenting }}</p>
</div>
<div class="test-detail-block" v-if="faceDetail.partnerCofounder">
<h4 class="detail-subtitle">寻找合伙人</h4>
<p class="test-desc">{{ faceDetail.partnerCofounder }}</p>
</div>
</div>
<!-- 第七组深度职场洞察 (Portrait, Boss, HR, Resume) -->
<div class="test-detail-grid" v-if="faceDetail.portrait || faceDetail.bossView || faceDetail.hrView || faceDetail.resumeHighlights">
<template v-if="faceDetail.portrait">
<div class="test-detail-block" v-if="faceDetail.portrait.coreStrengths?.length">
<h4 class="detail-subtitle">深度·核心优势</h4>
<ul class="tag-list">
<li v-for="s in faceDetail.portrait.coreStrengths" :key="s">{{ s }}</li>
</ul>
</div>
<div class="test-detail-block" v-if="faceDetail.portrait.coreRisks?.length">
<h4 class="detail-subtitle">深度·潜在风险</h4>
<ul class="tag-list tag-list-warning">
<li v-for="r in faceDetail.portrait.coreRisks" :key="r">{{ r }}</li>
</ul>
</div>
<div class="test-detail-block" v-if="faceDetail.portrait.workStyle">
<h4 class="detail-subtitle">深度·工作风格</h4>
<p class="test-desc">{{ faceDetail.portrait.workStyle }}</p>
</div>
</template>
<div class="test-detail-block" v-if="faceDetail.resumeHighlights">
<h4 class="detail-subtitle">简历要点</h4>
<p class="test-desc">{{ faceDetail.resumeHighlights }}</p>
</div>
<template v-if="faceDetail.bossView">
<div class="test-detail-block" v-if="faceDetail.bossView.headline">
<h4 class="detail-subtitle">老板视角·一句话</h4>
<p class="test-desc">{{ faceDetail.bossView.headline }}</p>
</div>
<div class="test-detail-block" v-if="faceDetail.bossView.costInsight">
<h4 class="detail-subtitle">老板视角·成本洞察</h4>
<p class="test-desc">{{ faceDetail.bossView.costInsight }}</p>
</div>
</template>
<template v-if="faceDetail.hrView">
<div class="test-detail-block" v-if="faceDetail.hrView.roleRecommend?.bestFit?.length">
<h4 class="detail-subtitle">HR·推荐岗位</h4>
<ul class="tag-list tag-list-info">
<li v-for="r in faceDetail.hrView.roleRecommend.bestFit" :key="r">{{ r }}</li>
</ul>
</div>
<div class="test-detail-block" v-if="faceDetail.hrView.roleRecommend?.notSuitable?.length">
<h4 class="detail-subtitle">HR·不适合场景</h4>
<ul class="tag-list tag-list-warning">
<li v-for="r in faceDetail.hrView.roleRecommend.notSuitable" :key="r">{{ r }}</li>
</ul>
</div>
</template>
</div>
</div>
</div>
</div>
@@ -582,8 +707,11 @@
<div class="resume-content">{{ currentTestDescription }}</div>
</div>
<!-- 通用补充说明 -->
<div class="detail-section" v-if="currentTestType !== 'resume' && currentTestDescription">
<!-- 通用补充说明人脸/AI 已在上方分块展示避免重复堆砌 -->
<div
class="detail-section"
v-if="currentTestType !== 'resume' && currentTestType !== 'face' && currentTestType !== 'ai' && currentTestDescription"
>
<h4 class="detail-subtitle">补充说明</h4>
<p class="test-desc">{{ currentTestDescription }}</p>
</div>
@@ -597,6 +725,7 @@ import { ref, computed, onMounted } from 'vue'
import { Download, Search, View, UserFilled, User, OfficeBuilding, DataLine } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { request } from '@/utils/request'
import { buildFaceDetailFromParsed } from '@/utils/faceResultDetail'
import UserDetailDialog from '@/components/UserDetailDialog.vue'
withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
@@ -709,6 +838,12 @@ function formatPhone(phone: string) {
return phone
}
/** 列表头像 URL兼容 avatar / avatarUrl避免字段名或空白异常导致整列不显示 */
function displayAvatar(row: Record<string, any>) {
const a = row?.avatar ?? row?.avatarUrl ?? ''
return typeof a === 'string' ? a.trim() : ''
}
/** 文字头像:根据昵称取首字 */
function avatarLetter(row: { username?: string; nickname?: string }) {
const name = (row?.username || row?.nickname || '?').trim()
@@ -856,15 +991,7 @@ const pdpDetail = computed(() => {
const faceDetail = computed(() => {
const parsed = currentTestParsed.value
if (!parsed || (currentTestType.value !== 'face' && currentTestType.value !== 'ai')) return null
return {
mbti: parsed.mbti || null,
disc: parsed.disc || null,
pdp: parsed.pdp || null,
overview: parsed.overview ?? '',
boneAnalysis: parsed.boneAnalysis ?? '',
personalitySummary: parsed.personalitySummary ?? '',
photos: Array.isArray(parsed.photoUrls) ? parsed.photoUrls : []
}
return buildFaceDetailFromParsed(parsed)
})
const resumeDetail = computed(() => {
@@ -899,7 +1026,13 @@ function extractTestDescription(parsed: any, testType: string): string {
}
if (t === 'face' || t === 'ai') {
return String(parsed.overview ?? parsed.personalitySummary ?? '')
const parts = [
parsed.relationship,
parsed.faceAnalysis && typeof parsed.faceAnalysis === 'string' ? parsed.faceAnalysis : '',
parsed.boneAnalysis && typeof parsed.boneAnalysis === 'string' ? parsed.boneAnalysis : ''
].map((x) => String(x || '').trim()).filter(Boolean)
const extra = parts.length ? `\n\n${parts.join('\n\n')}` : ''
return String(parsed.overview ?? parsed.personalitySummary ?? '') + extra
}
if (t === 'resume') {
@@ -1361,6 +1494,16 @@ onMounted(() => {
color: #fff;
}
.user-el-avatar {
flex-shrink: 0;
font-size: 16px;
font-weight: 600;
}
.user-avatar-letter-fallback {
color: #fff;
}
.user-details {
.username {
font-weight: 600;
@@ -1744,6 +1887,9 @@ onMounted(() => {
.face-left {
width: 220px;
flex-shrink: 0;
position: sticky;
top: 0;
height: fit-content;
}
.face-photos {
@@ -1763,6 +1909,53 @@ onMounted(() => {
flex: 1;
}
.test-detail-row {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
.compact-row .test-detail-block {
margin-bottom: 0 !important;
padding: 12px;
background-color: #f9fafb;
border-radius: 8px;
}
.flex-1 {
flex: 1;
}
.text-primary {
color: #4f46e5;
font-weight: 600;
}
.text-secondary {
color: #6b7280;
font-weight: normal;
}
.face-gallup-list {
margin: 6px 0 0 18px;
padding: 0;
font-size: 13px;
color: #374151;
line-height: 1.6;
}
.face-feature-list {
margin: 6px 0 0;
padding-left: 18px;
font-size: 13px;
color: #4b5563;
line-height: 1.65;
li {
margin-bottom: 6px;
}
}
.test-desc {
font-size: 13px;
color: #4b5563;

View File

@@ -78,9 +78,28 @@ class AppUser extends BaseController
$baseQuery->where($where);
}
// 默认不展示「从未有过测试记录」的用户(企业后台按本企业 test_results 判定);?includeZeroTests=1 显示全部
$includeZeroTests = Request::param('includeZeroTests', '');
$showUntested = ($includeZeroTests === '1' || $includeZeroTests === 'true' || $includeZeroTests === true);
if (!$showUntested) {
$trQ = Db::name('test_results');
if ($enterpriseId) {
$trQ->where('enterpriseId', (int) $enterpriseId);
}
$testedUserIds = $trQ->distinct(true)->column('userId');
$testedUserIds = array_values(array_unique(array_filter(array_map('intval', $testedUserIds))));
if (empty($testedUserIds)) {
return paginate_response([], 0, $page, $pageSize);
}
$baseQuery->whereIn('id', $testedUserIds);
}
$total = (int) $baseQuery->count();
$list = (clone $baseQuery)
->field('id,nickname,openid,avatar,phone,gender,country,province,city,status,lastLoginAt,createdAt')
->field([
'id', 'nickname', 'openid', 'avatar', 'phone', 'gender',
'country', 'province', 'city', 'status', 'lastLoginAt', 'createdAt',
])
->order('createdAt', 'desc')
->page($page, $pageSize)
->select()
@@ -155,6 +174,9 @@ class AppUser extends BaseController
foreach ($list as &$row) {
$id = $row['id'];
$av = $row['avatar'] ?? '';
$row['avatar'] = is_scalar($av) ? trim((string) $av) : '';
$row['avatarUrl'] = $row['avatar'];
$testsForUser = $testTypes[$id] ?? [];
$row['username'] = $row['nickname'] ?? ('用户' . $id);
$row['testCount'] = (int) ($testCounts[$id] ?? 0);
@@ -172,6 +194,7 @@ class AppUser extends BaseController
$row['paidOrders'] = $pay ? (int) ($pay['paidOrders'] ?? 0) : 0;
$row['totalPaidAmount'] = $pay ? (int) ($pay['totalPaidAmount'] ?? 0) : 0;
}
unset($row);
return paginate_response($list, $total, $page, $pageSize);
}

View File

@@ -6,16 +6,16 @@ use app\common\service\WechatService;
use think\facade\Db;
/**
* 管理端 - 小程序邀请二维码(企业参数
* 管理端 - 小程序邀请二维码(企业版 + 个人版
*/
class Invite extends BaseController
{
/**
* 生成专属邀请小程序码scene 带企业 ID扫码进入 pages/enterprise/index 可解析
* 一次生成两枚小程序码:企业测评入口 + 个人版首页入口
* GET /api/v1/admin/invite/qrcode
* 可选:?enterpriseId=1 仅普通管理员指定企业时传;企业管理员用自身 enterpriseId
*
* 返回 data:image/png;base64,... 形式的图片地址
* 返回 enterprise / personal 各含 data:image/png;base64,... ;兼容旧字段 qrcode=企业版图
*/
public function qrcode()
{
@@ -38,26 +38,48 @@ class Invite extends BaseController
return error('无法确定企业,仅企业管理员或指定 enterpriseId 可生成邀请码', 400);
}
// 场景值e_企业ID小程序 onLoad(options.scene) 可解析
$scene = 'e_' . $enterpriseId;
$page = 'pages/enterprise/index';
$sceneEnterprise = 'e_' . $enterpriseId;
$pageEnterprise = 'pages/enterprise/index';
$result = WechatService::getWxacodeUnlimited($scene, $page, 430);
if (isset($result['errcode'])) {
return error('获取小程序码失败:' . ($result['errmsg'] ?? ''), 500);
$resultEnt = WechatService::getWxacodeUnlimited($sceneEnterprise, $pageEnterprise, 430);
if (isset($resultEnt['errcode'])) {
return error('企业版小程序码失败:' . ($resultEnt['errmsg'] ?? ''), 500);
}
$binary = $result['binary'] ?? '';
if ($binary === '') {
return error('小程序码生成失败', 500);
$binEnt = $resultEnt['binary'] ?? '';
if ($binEnt === '') {
return error('企业版小程序码生成失败', 500);
}
$b64Ent = 'data:image/png;base64,' . base64_encode($binEnt);
$base64 = 'data:image/png;base64,' . base64_encode($binary);
// 个人版首页scene 使用短串 pindex 无 eid 则留在个人版流程)
$scenePersonal = 'p';
$pagePersonal = 'pages/index/index';
$resultPer = WechatService::getWxacodeUnlimited($scenePersonal, $pagePersonal, 430);
if (isset($resultPer['errcode'])) {
return error('个人版小程序码失败:' . ($resultPer['errmsg'] ?? ''), 500);
}
$binPer = $resultPer['binary'] ?? '';
if ($binPer === '') {
return error('个人版小程序码生成失败', 500);
}
$b64Per = 'data:image/png;base64,' . base64_encode($binPer);
return success([
'qrcode' => $base64,
'scene' => $scene,
'page' => $page,
'qrcode' => $b64Ent,
'scene' => $sceneEnterprise,
'page' => $pageEnterprise,
'enterprise' => [
'qrcode' => $b64Ent,
'scene' => $sceneEnterprise,
'page' => $pageEnterprise,
'label' => '企业版',
],
'personal' => [
'qrcode' => $b64Per,
'scene' => $scenePersonal,
'page' => $pagePersonal,
'label' => '个人版',
],
]);
}
}

View File

@@ -25,11 +25,24 @@ class Analytics extends BaseController
$userId = null;
$openid = null;
$source = null;
$token = JwtService::getTokenFromRequest($this->request);
if ($token) {
$payload = JwtService::verifyToken($token);
if ($payload && ($payload['source'] ?? '') === 'wechat') {
$userId = (int) ($payload['userId'] ?? $payload['user_id'] ?? 0) ?: null;
if ($payload) {
$source = $payload['source'] ?? null;
if (in_array($source, ['wechat', 'douyin'], true)) {
$userId = (int) ($payload['userId'] ?? $payload['user_id'] ?? 0) ?: null;
}
}
}
$deviceJson = null;
$deviceRaw = $body['device'] ?? null;
if (is_array($deviceRaw) && !empty($deviceRaw)) {
$deviceJson = json_encode($deviceRaw, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
if (strlen($deviceJson) > 2000) {
$deviceJson = mb_substr($deviceJson, 0, 2000);
}
}
@@ -45,6 +58,25 @@ class Analytics extends BaseController
}
$pagePath = isset($ev['page_path']) ? mb_substr(trim((string) $ev['page_path']), 0, 255) : '';
$props = $ev['props'] ?? null;
$platform = isset($ev['platform']) ? mb_substr(trim((string) $ev['platform']), 0, 16) : ($source ?: null);
$sessionId = isset($ev['session_id']) ? mb_substr(trim((string) $ev['session_id']), 0, 64) : null;
if ($props !== null && $props !== []) {
if ($deviceJson) {
$props['_device'] = $deviceRaw;
}
if (isset($ev['network'])) {
$props['_network'] = $ev['network'];
}
if (isset($ev['scene'])) {
$props['_scene'] = $ev['scene'];
}
} else {
$props = [];
if ($deviceJson) $props['_device'] = $deviceRaw;
if (isset($ev['network'])) $props['_network'] = $ev['network'];
if (isset($ev['scene'])) $props['_scene'] = $ev['scene'];
if (empty($props)) $props = null;
}
$propsJson = null;
if ($props !== null && $props !== []) {
$propsJson = json_encode($props, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
@@ -64,6 +96,8 @@ class Analytics extends BaseController
'pagePath' => $pagePath ?: null,
'propsJson' => $propsJson,
'clientTs' => $clientTs ?: null,
'platform' => $platform,
'sessionId' => $sessionId,
'createdAt' => $now,
];
}

View File

@@ -171,12 +171,15 @@ class Analyze extends BaseController
if ($testResultId) {
UserProfileModel::recordTest($userId, 'face', $testResultId, $writeEnterpriseId, $now);
// 仅当 enterpriseId 来自请求体(企业分享链接)时才更新绑定关系
if ($enterpriseFromRequest && $enterpriseId !== null && $enterpriseId > 0) {
Db::name('wechat_users')->where('id', $userId)->update([
'enterpriseId' => $enterpriseId,
'updatedAt' => $now,
]);
// 用户尚无企业归属时,从本次测试上下文补写
if ($writeEnterpriseId !== null && (int) $writeEnterpriseId > 0) {
$curEid = Db::name('wechat_users')->where('id', $userId)->value('enterpriseId');
if ($curEid === null || $curEid === '' || (int) $curEid === 0) {
Db::name('wechat_users')->where('id', $userId)->update([
'enterpriseId' => (int) $writeEnterpriseId,
'updatedAt' => $now,
]);
}
}
// 面相分析由本接口直接写入 test_results也要补触发测试完成佣金
try {
@@ -334,12 +337,15 @@ class Analyze extends BaseController
if ($testResultId > 0) {
UserProfileModel::recordTest($userId, 'resume', $testResultId, $enterpriseId > 0 ? $enterpriseId : null, $now);
// 如果是企业分享链接进入,更新用户绑定关系
if ($enterpriseIdFromRequest && $enterpriseId > 0) {
Db::name('wechat_users')->where('id', $userId)->update([
'enterpriseId' => $enterpriseId,
'updatedAt' => $now,
]);
// 用户尚无企业归属时,从本次测试上下文补写
if ($enterpriseId > 0) {
$curEid = Db::name('wechat_users')->where('id', $userId)->value('enterpriseId');
if ($curEid === null || $curEid === '' || (int) $curEid === 0) {
Db::name('wechat_users')->where('id', $userId)->update([
'enterpriseId' => $enterpriseId,
'updatedAt' => $now,
]);
}
}
}
} catch (\Throwable $e) {

View File

@@ -230,17 +230,27 @@ class Auth extends BaseController
$now = time();
$ip = Request::ip();
$loginEnterpriseId = isset($input['enterpriseId']) && (int) $input['enterpriseId'] > 0
? (int) $input['enterpriseId']
: null;
if ($wechatUser) {
Db::name('wechat_users')->where('id', $wechatUser['id'])->update([
$updateFields = [
'sessionKey' => $sessionKey,
'unionid' => $unionid,
'lastLoginAt' => $now,
'lastLoginIp' => $ip,
'updatedAt' => $now,
]);
];
// 老用户未绑定企业时,从本次登录上下文补写
$existingEid = $wechatUser['enterpriseId'] ?? null;
if (($existingEid === null || $existingEid === '' || (int) $existingEid === 0) && $loginEnterpriseId !== null) {
$updateFields['enterpriseId'] = $loginEnterpriseId;
}
Db::name('wechat_users')->where('id', $wechatUser['id'])->update($updateFields);
$wechatUser = Db::name('wechat_users')->where('id', $wechatUser['id'])->find();
} else {
$id = Db::name('wechat_users')->insertGetId([
$insertData = [
'openid' => $openid,
'unionid' => $unionid,
'sessionKey' => $sessionKey,
@@ -256,7 +266,11 @@ class Auth extends BaseController
'lastLoginIp' => $ip,
'createdAt' => $now,
'updatedAt' => $now,
]);
];
if ($loginEnterpriseId !== null) {
$insertData['enterpriseId'] = $loginEnterpriseId;
}
$id = Db::name('wechat_users')->insertGetId($insertData);
$wechatUser = Db::name('wechat_users')->where('id', $id)->find();
}

View File

@@ -0,0 +1,119 @@
<?php
namespace app\controller\api;
use app\BaseController;
use think\facade\Db;
use think\facade\Request;
/**
* 小程序端当前登录用户的支付订单列表mbti_orders.userId = wechat_users.id
*/
class Order extends BaseController
{
/**
* GET /api/orders?page=1&pageSize=20
*/
public function index()
{
$user = $this->request->user ?? null;
if (!$user) {
return error('未登录', 401);
}
$source = $user['source'] ?? '';
if (!in_array($source, ['wechat', 'douyin'], true)) {
return error('仅支持小程序用户', 403);
}
$userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0);
if ($userId <= 0) {
return error('用户不存在', 400);
}
$page = (int) Request::param('page', 1);
$pageSize = (int) Request::param('pageSize', 20);
$pageSize = min(max($pageSize, 1), 100);
$query = Db::name('orders')
->where('userId', $userId)
->order('createdAt', 'desc');
$total = (int) (clone $query)->count();
$rows = (clone $query)->page($page, $pageSize)->select()->toArray();
$list = [];
foreach ($rows as $row) {
$list[] = $this->formatOrderRow($row);
}
return paginate_response($list, $total, $page, $pageSize);
}
private function formatOrderRow(array $row): array
{
$amountFen = (int) ($row['amount'] ?? 0);
$productType = (string) ($row['productType'] ?? '');
$title = trim((string) ($row['productTitle'] ?? ''));
if ($title === '') {
$title = $this->productTypeLabel($productType);
}
return [
'id' => (int) ($row['id'] ?? 0),
'orderNo' => (string) ($row['orderNo'] ?? ''),
'productType' => $productType,
'productTitle' => $title,
'amountFen' => $amountFen,
'amountYuan' => number_format($amountFen / 100, 2, '.', ''),
'status' => (string) ($row['status'] ?? ''),
'statusText' => $this->statusLabel((string) ($row['status'] ?? '')),
'payMethod' => (string) ($row['payMethod'] ?? ''),
'payTime' => isset($row['payTime']) ? (int) $row['payTime'] : null,
'payTimeStr' => $this->formatTs($row['payTime'] ?? null),
'createdAt' => isset($row['createdAt']) ? (int) $row['createdAt'] : null,
'createdAtStr' => $this->formatTs($row['createdAt'] ?? null),
];
}
private function formatTs($ts): string
{
if ($ts === null || $ts === '') {
return '';
}
$t = (int) $ts;
if ($t <= 0) {
return '';
}
return date('Y-m-d H:i', $t);
}
private function statusLabel(string $s): string
{
$map = [
'pending' => '待支付',
'paid' => '已支付',
'completed' => '已完成',
'cancelled' => '已取消',
];
return $map[$s] ?? ($s !== '' ? $s : '未知');
}
private function productTypeLabel(string $t): string
{
$map = [
'face' => '人脸分析',
'mbti' => 'MBTI测试',
'disc' => 'DISC测试',
'pdp' => 'PDP测试',
'report' => '完整报告',
'resume' => '简历分析',
'recharge' => '企业充值',
'vip' => '会员服务',
'deep_personal' => '深度服务(个人)',
'deep_team' => '深度服务(团队)',
'single_test' => '单次测试',
'test_count' => '测试次数包',
'team_analysis' => '团队分析',
];
return $map[$t] ?? ($t !== '' ? $t : '订单');
}
}

View File

@@ -495,12 +495,15 @@ class Test extends BaseController
if ($id > 0) {
UserProfileModel::recordTest($userId, $testType, $id, $writeEnterpriseId, $now);
// 仅当 enterpriseId 来自请求体(企业分享链接)时才更新绑定关系
if ($enterpriseFromRequest && $enterpriseId !== null && $enterpriseId > 0) {
Db::name('wechat_users')->where('id', $userId)->update([
'enterpriseId' => $enterpriseId,
'updatedAt' => $now,
]);
// 用户尚无企业归属时,从本次测试上下文补写(请求体带 eid 或已绑定企业回落值)
if ($writeEnterpriseId !== null && (int) $writeEnterpriseId > 0) {
$curEid = Db::name('wechat_users')->where('id', $userId)->value('enterpriseId');
if ($curEid === null || $curEid === '' || (int) $curEid === 0) {
Db::name('wechat_users')->where('id', $userId)->update([
'enterpriseId' => (int) $writeEnterpriseId,
'updatedAt' => $now,
]);
}
}
// 测试完成佣金结算(无需付款,异步不影响主流程)
try {

View File

@@ -35,24 +35,31 @@ class AppUser extends BaseController
return error('无权限访问', 403);
}
// 用户数按 openid 去重
// ========== 统计全部基于 wechat_users.enterpriseId而非 test_results.enterpriseId ==========
// openid 去重:每个 openid 只保留 id 最大的一条
try {
$totalUsers = (int) Db::name('wechat_users')->count('openid', true);
$dedupIds = Db::name('wechat_users')->field('openid, MAX(id) as mid')->group('openid')->column('mid');
$dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
} catch (\Throwable $e) {
$totalUsers = (int) Db::name('wechat_users')->count();
$dedupIds = Db::name('wechat_users')->column('id');
$dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
}
$totalUsers = count($dedupIds);
$last30d = time() - 30 * 86400;
// 全部池:去重后的测试用户 & 近 30 天活跃用户(按 userId 去重
// 这里使用逻辑表名 test_results底层会自动加前缀生成 mbti_test_results
// 已测试用户(在 test_results 有记录的 userId 与 dedupIds 取交集
$testedUserIds = Db::name('test_results')->distinct(true)->column('userId');
$testedUsers = count(array_filter($testedUserIds));
$testedUserIds = array_values(array_unique(array_filter(array_map('intval', $testedUserIds))));
$testedUsers = count(array_intersect($testedUserIds, $dedupIds));
// 近 30 天活跃用户
$activeUserIds = Db::name('test_results')
->where('createdAt', '>=', $last30d)
->distinct(true)
->column('userId');
$activeUsers = count(array_filter($activeUserIds));
$activeUserIds = array_values(array_unique(array_filter(array_map('intval', $activeUserIds))));
$activeUsers = count(array_intersect($activeUserIds, $dedupIds));
$userCards = [
[
@@ -64,73 +71,44 @@ class AppUser extends BaseController
]
];
$individualTotal = 0;
$individualActive = 0;
try {
$individualIds = Db::name('test_results')
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', '')->whereOr('enterpriseId', 0);
})
->distinct(true)
->column('userId');
$individualTotal = count(array_filter($individualIds));
$individualActiveIds = Db::name('test_results')
->where('createdAt', '>=', $last30d)
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', '')->whereOr('enterpriseId', 0);
})
->distinct(true)
->column('userId');
$individualActive = count(array_filter($individualActiveIds));
} catch (\Throwable $e) {
$individualTotal = 0;
$individualActive = 0;
}
$cunkbaoId = $this->resolveCunkbaoEnterpriseId();
// 按 wechat_users.enterpriseId 分组统计
$userEidMap = Db::name('wechat_users')->where('id', 'in', $dedupIds)->column('enterpriseId', 'id');
// 按企业统计:从注册表统计 total再交叉 test_results 统计 active/tested
$enterprises = Db::name('enterprises')->field('id,name')->select()->toArray();
foreach ($enterprises as $e) {
$eid = $e['id'];
try {
$ids = Db::name('test_results')
->where('enterpriseId', $eid)
->distinct(true)
->column('userId');
$total = count(array_filter($ids));
$eid = (int) $e['id'];
$eidUsers = array_keys(array_filter($userEidMap, function ($v) use ($eid) {
return (int) $v === $eid;
}));
$total = count($eidUsers);
$active = empty($eidUsers) ? 0 : count(array_intersect($activeUserIds, $eidUsers));
$tested = empty($eidUsers) ? 0 : count(array_intersect($testedUserIds, $eidUsers));
$activeIds = Db::name('test_results')
->where('enterpriseId', $eid)
->where('createdAt', '>=', $last30d)
->distinct(true)
->column('userId');
$active = count(array_filter($activeIds));
} catch (\Throwable $ex) {
$total = 0;
$active = 0;
}
if ($cunkbaoId !== null && (int) $eid === $cunkbaoId) {
$total += $individualTotal;
$active += $individualActive;
}
$userCards[] = [
'type' => 'enterprise',
'enterpriseId' => $eid,
'name' => $e['name'] ?? ('企业' . $eid),
'total' => $total,
'active' => $active,
'tested' => $total
'tested' => $tested
];
}
if ($cunkbaoId === null && ($individualTotal > 0 || $individualActive > 0)) {
// 无企业归属的个人用户
$individualUsers = array_keys(array_filter($userEidMap, function ($v) {
return $v === null || $v === '' || (int) $v === 0;
}));
$individualTotal = count($individualUsers);
if ($individualTotal > 0) {
$individualActive = count(array_intersect($activeUserIds, $individualUsers));
$individualTested = count(array_intersect($testedUserIds, $individualUsers));
$userCards[] = [
'type' => 'individual',
'name' => '个人用户(无企业)',
'total' => $individualTotal,
'active' => $individualActive,
'tested' => $individualTotal
'tested' => $individualTested
];
}
@@ -189,7 +167,7 @@ class AppUser extends BaseController
/**
* 测试用户列表分页、关键词、池筛选、MBTI 筛选
* GET /api/v1/superadmin/app-users?page=1&pageSize=20&keyword=&pool=all|individual|enterprise&enterpriseId=&mbti=
* GET /api/v1/superadmin/app-users?page=1&pageSize=20&keyword=&pool=all|individual|enterprise&enterpriseId=&mbti=&includeZeroTests=
*/
public function index()
{
@@ -206,53 +184,12 @@ class AppUser extends BaseController
$enterpriseId = Request::param('enterpriseId', '');
$mbti = trim(Request::param('mbti', ''));
$cunkbaoEnterpriseId = $this->resolveCunkbaoEnterpriseId();
if ($pool === 'individual' && $cunkbaoEnterpriseId) {
$pool = 'enterprise';
$enterpriseId = (string) $cunkbaoEnterpriseId;
}
$where = [];
if ($keyword !== '') {
$where[] = ['nickname|phone|city|province', 'like', '%' . $keyword . '%'];
}
$wechatIds = null;
if ($pool === 'individual' || ($pool === 'enterprise' && $enterpriseId !== '')) {
try {
if ($pool === 'individual') {
$trQuery = Db::name('test_results')->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', '')->whereOr('enterpriseId', 0);
});
$wechatIds = $trQuery->distinct(true)->column('userId');
} else {
$eid = (int) $enterpriseId;
if ($cunkbaoEnterpriseId !== null && $eid === $cunkbaoEnterpriseId) {
$idsEnt = Db::name('test_results')->where('enterpriseId', $eid)->distinct(true)->column('userId');
$idsOrphan = Db::name('test_results')->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', '')->whereOr('enterpriseId', 0);
})->distinct(true)->column('userId');
$wechatIds = array_values(array_unique(array_filter(array_merge($idsEnt, $idsOrphan))));
} else {
$wechatIds = Db::name('test_results')->where('enterpriseId', $eid)->distinct(true)->column('userId');
}
}
$wechatIds = array_values(array_unique(array_filter($wechatIds)));
} catch (\Throwable $e) {
$wechatIds = null;
}
}
if ($mbti !== '') {
$mbtiUserIds = Db::name('test_results')->where('testType', 'mbti')->distinct(true)->column('userId');
$mbtiUserIds = array_values(array_unique(array_filter($mbtiUserIds)));
if ($wechatIds !== null) {
$wechatIds = array_values(array_intersect($wechatIds, $mbtiUserIds));
} else {
$wechatIds = $mbtiUserIds;
}
}
// 按 openid 去重:每个 openid 只保留 id 最大的一条;失败则不去重
// 按 openid 去重:每个 openid 只保留 id 最大的一条
try {
$dedupIds = Db::name('wechat_users')->field('openid, MAX(id) as mid')->group('openid')->column('mid');
$dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : [];
@@ -268,15 +205,45 @@ class AppUser extends BaseController
if ($where) {
$baseQuery->where($where);
}
if ($wechatIds !== null && !empty($wechatIds)) {
$baseQuery->where('id', 'in', array_intersect($dedupIds, $wechatIds));
} elseif ($wechatIds !== null && empty($wechatIds)) {
return paginate_response([], 0, $page, $pageSize);
// 池筛选:直接基于 wechat_users.enterpriseId
if ($pool === 'individual') {
$baseQuery->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', '')->whereOr('enterpriseId', 0);
});
} elseif ($pool === 'enterprise' && $enterpriseId !== '') {
$baseQuery->where('enterpriseId', (int) $enterpriseId);
}
// 默认不展示「从未有过测试记录」的用户;?includeZeroTests=1 可显示全部(排查用)
$includeZeroTests = Request::param('includeZeroTests', '');
$showUntested = ($includeZeroTests === '1' || $includeZeroTests === 'true' || $includeZeroTests === true);
if (!$showUntested) {
$testedUserIds = Db::name('test_results')->distinct(true)->column('userId');
$testedUserIds = array_values(array_unique(array_filter(array_map('intval', $testedUserIds))));
if (empty($testedUserIds)) {
return paginate_response([], 0, $page, $pageSize);
}
$baseQuery->whereIn('id', $testedUserIds);
}
// MBTI 筛选:保留旧逻辑,从 test_results 取有 mbti 测试的用户
if ($mbti !== '') {
$mbtiUserIds = Db::name('test_results')->where('testType', 'mbti')->distinct(true)->column('userId');
$mbtiUserIds = array_values(array_unique(array_filter($mbtiUserIds)));
if (!empty($mbtiUserIds)) {
$baseQuery->where('id', 'in', $mbtiUserIds);
} else {
return paginate_response([], 0, $page, $pageSize);
}
}
$total = $baseQuery->count();
$list = (clone $baseQuery)
->field('id,openid,nickname,avatar,phone,gender,country,province,city,status,lastLoginAt,createdAt')
->field([
'id', 'openid', 'nickname', 'avatar', 'phone', 'gender',
'country', 'province', 'city', 'status', 'lastLoginAt', 'createdAt',
])
->order('createdAt', 'desc')
->page($page, $pageSize)
->select()
@@ -310,38 +277,20 @@ class AppUser extends BaseController
'createdAt' => $row['createdAt'],
];
}
try {
$trWithE = Db::name('test_results')
->where('userId', 'in', $ids)
->where('enterpriseId', '<>', null)
->where('enterpriseId', '<>', '')
->field('userId, enterpriseId')
->select();
$eids = array_unique(array_filter(array_column($trWithE, 'enterpriseId')));
$enterpriseNames = [];
if (!empty($eids)) {
$enterpriseNames = Db::name('enterprises')->where('id', 'in', $eids)->column('name', 'id');
}
foreach ($trWithE as $r) {
if (!isset($userEnterprise[$r['userId']])) {
$userEnterprise[$r['userId']] = $enterpriseNames[$r['enterpriseId']] ?? ('企业' . $r['enterpriseId']);
}
}
} catch (\Throwable $e) {
// test_results 可能无 enterpriseId 列
}
$cunkbaoName = null;
if ($cunkbaoEnterpriseId) {
$cunkbaoName = (string) (Db::name('enterprises')->where('id', $cunkbaoEnterpriseId)->value('name') ?: '存客宝');
// 所属企业直接从 wechat_users.enterpriseId 读取
$userEids = Db::name('wechat_users')->where('id', 'in', $ids)->column('enterpriseId', 'id');
$allEids = array_values(array_unique(array_filter(array_map('intval', $userEids))));
$enterpriseNames = [];
if (!empty($allEids)) {
$enterpriseNames = Db::name('enterprises')->where('id', 'in', $allEids)->column('name', 'id');
}
foreach ($ids as $uid) {
if (!isset($userEnterprise[$uid])) {
$tc = (int) ($testCounts[$uid] ?? 0);
if ($cunkbaoName !== null && $tc > 0) {
$userEnterprise[$uid] = $cunkbaoName;
} else {
$userEnterprise[$uid] = '个人用户(无企业)';
}
$eid = isset($userEids[$uid]) ? (int) $userEids[$uid] : 0;
if ($eid > 0 && isset($enterpriseNames[$eid])) {
$userEnterprise[$uid] = $enterpriseNames[$eid];
} else {
$userEnterprise[$uid] = '个人用户(无企业)';
}
}
@@ -370,6 +319,9 @@ class AppUser extends BaseController
foreach ($list as &$row) {
$id = $row['id'];
$av = $row['avatar'] ?? '';
$row['avatar'] = is_scalar($av) ? trim((string) $av) : '';
$row['avatarUrl'] = $row['avatar'];
$testsForUser = $testTypes[$id] ?? [];
$row['username'] = $row['nickname'] ?? ('用户' . $id);
$row['testCount'] = (int) ($testCounts[$id] ?? 0);
@@ -390,6 +342,7 @@ class AppUser extends BaseController
$row['totalPaidAmount'] = $totalPaidFen;
$row['totalPaidAmountYuan'] = $totalPaidFen > 0 ? round($totalPaidFen / 100, 2) : 0;
}
unset($row);
return paginate_response($list, $total, $page, $pageSize);
}

View File

@@ -11,6 +11,81 @@ use think\facade\Request;
*/
class DataMigration extends BaseController
{
/**
* 小程序用户「未绑定企业」的 SQL 条件:仅 NULL 或 0。
* 不使用 enterpriseId = ''(整型列 + ORM 易产生歧义),也不把已绑定企业(>0算入。
*/
private function sqlWechatUserUnassignedEnterprise(): string
{
return '(`enterpriseId` IS NULL OR `enterpriseId` = 0)';
}
private function sqlTestResultUnassignedEnterprise(): string
{
return '(`enterpriseId` IS NULL OR `enterpriseId` = 0)';
}
/**
* 将同一 userId 下 personal + enterprise(目标企业) 两条 user_profile 合并为一行:
* 计数类字段相加last* 类字段取 lastTestAt 更新的一侧(相同时优先企业行)
*/
private function mergePersonalUserProfileIntoEnterprise(int $userId, int $enterpriseId, int $now): void
{
if ($userId <= 0 || $enterpriseId <= 0) {
return;
}
$personal = Db::name('user_profile')
->where('userId', $userId)
->where('userType', 'personal')
->whereRaw('(`enterpriseId` IS NULL OR `enterpriseId` = 0)')
->find();
if (!$personal) {
return;
}
$enterprise = Db::name('user_profile')
->where('userId', $userId)
->where('userType', 'enterprise')
->where('enterpriseId', $enterpriseId)
->find();
if (!$enterprise) {
Db::name('user_profile')->where('id', (int) $personal['id'])->update([
'userType' => 'enterprise',
'enterpriseId' => $enterpriseId,
'updatedAt' => $now,
]);
return;
}
$sumKeys = [
'testsTotal', 'testsMbti', 'testsDisc', 'testsPdp', 'testsFace',
'ordersTotal', 'paidOrders', 'totalPaidAmount',
];
$update = [];
foreach ($sumKeys as $k) {
$update[$k] = (int) ($personal[$k] ?? 0) + (int) ($enterprise[$k] ?? 0);
}
$pAt = (int) ($personal['lastTestAt'] ?? 0);
$eAt = (int) ($enterprise['lastTestAt'] ?? 0);
$pickPersonal = $pAt > $eAt;
$src = $pickPersonal ? $personal : $enterprise;
foreach (['lastTestResultId', 'lastTestType', 'lastTestAt', 'lastMbtiResultId', 'lastDiscResultId', 'lastPdpResultId', 'lastFaceResultId'] as $k) {
if (array_key_exists($k, $src)) {
$update[$k] = $src[$k];
}
}
$update['updatedAt'] = $now;
Db::name('user_profile')->where('id', (int) $enterprise['id'])->update($update);
Db::name('user_profile')->where('id', (int) $personal['id'])->delete();
}
/**
* POST /api/v1/superadmin/data-migration/attach-orphan-orders
*
@@ -92,9 +167,7 @@ class DataMigration extends BaseController
if ($syncWechatUsers && !empty($userIdsFromOrders)) {
$wechatPatchCount = (int) Db::name('wechat_users')
->whereIn('id', $userIdsFromOrders)
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
})
->whereRaw($this->sqlWechatUserUnassignedEnterprise())
->count();
}
@@ -169,11 +242,10 @@ class DataMigration extends BaseController
if ($syncWechatUsers && !empty($userIdsFromOrders)) {
Db::name('wechat_users')
->whereIn('id', $userIdsFromOrders)
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
})
->whereRaw($this->sqlWechatUserUnassignedEnterprise())
->update([
'enterpriseId' => $targetEnterpriseId,
'updatedAt' => $now,
]);
}
@@ -277,10 +349,13 @@ class DataMigration extends BaseController
}
/**
* 将全平台「无 enterpriseId」的 test_results 与 wechat_users 归属到存客宝(或指定企业)
* 存客宝归并wechat_users 无企业用户写入目标企业;
* 已/将归属该企业的用户下test_results 中 enterpriseId 为空的记录补写目标企业;
* user_profile 中 personal(NULL) 与 enterprise(目标) 合并计数后删除 personal 行。
*
* POST /api/v1/superadmin/data-migration/attach-orphans-to-cunkbao
*
* Body: targetEnterpriseId (可选)、dryRun (默认 true)、confirm、clonePersonalProfile (默认 true)
* Body: targetEnterpriseId (可选)、dryRun (默认 true)、confirm
*/
public function attachOrphansToCunkbao()
{
@@ -296,7 +371,6 @@ class DataMigration extends BaseController
$dryRun = array_key_exists('dryRun', $body) ? (bool) $body['dryRun'] : true;
$confirm = !empty($body['confirm']);
$clonePersonalProfile = array_key_exists('clonePersonalProfile', $body) ? (bool) $body['clonePersonalProfile'] : true;
$targetEnterpriseId = (int) ($body['targetEnterpriseId'] ?? 0);
if ($targetEnterpriseId <= 0) {
@@ -312,71 +386,84 @@ class DataMigration extends BaseController
return error('目标企业不存在', 404);
}
$testAffected = (int) Db::name('test_results')
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0)->whereOr('enterpriseId', '');
})
->count();
$wechatAffected = (int) Db::name('wechat_users')
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
})
->count();
$userIdsFromTests = Db::name('test_results')
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0)->whereOr('enterpriseId', '');
})
->distinct(true)
->column('userId');
$userIdsFromWechat = Db::name('wechat_users')
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
})
$orphanUserIds = Db::name('wechat_users')
->whereRaw($this->sqlWechatUserUnassignedEnterprise())
->column('id');
$distinctUserIds = array_values(array_unique(array_filter(array_merge($userIdsFromTests, $userIdsFromWechat))));
$orphanUserIds = array_values(array_unique(array_filter(array_map('intval', $orphanUserIds))));
$wechatAffected = count($orphanUserIds);
$boundNow = Db::name('wechat_users')
->where('enterpriseId', $targetEnterpriseId)
->column('id');
$boundNow = array_values(array_unique(array_filter(array_map('intval', $boundNow))));
$usersInScope = array_values(array_unique(array_merge($boundNow, $orphanUserIds)));
$testResultsAffected = empty($usersInScope) ? 0 : (int) Db::name('test_results')
->whereIn('userId', $usersInScope)
->whereRaw($this->sqlTestResultUnassignedEnterprise())
->count();
$userProfilePersonalRows = empty($usersInScope) ? 0 : (int) Db::name('user_profile')
->where('userType', 'personal')
->whereRaw('(`enterpriseId` IS NULL OR `enterpriseId` = 0)')
->whereIn('userId', $usersInScope)
->count();
$preview = [
'targetEnterpriseId' => $targetEnterpriseId,
'enterpriseName' => $ent['name'] ?? '',
'testResultsRows' => $testAffected,
'wechatUsersRows' => $wechatAffected,
'distinctUserIds' => $distinctUserIds,
'dryRun' => $dryRun,
'targetEnterpriseId' => $targetEnterpriseId,
'enterpriseName' => $ent['name'] ?? '',
'wechatUsersRows' => $wechatAffected,
'testResultsRows' => $testResultsAffected,
'userProfilePersonalRows' => $userProfilePersonalRows,
'dryRun' => $dryRun,
];
$nothingToDo = $wechatAffected === 0 && $testResultsAffected === 0 && $userProfilePersonalRows === 0;
if ($dryRun || !$confirm) {
$preview['hint'] = $dryRun
? '当前为预览。写入请传 dryRun=false 且 confirm=true。'
: '未写入:请同时传 dryRun=false 与 confirm=true。';
$preview['hint'] = $nothingToDo
? '当前无需归并:归属用户、测试记录与画像均无待处理项。'
: ($dryRun
? '当前为预览。写入请传 dryRun=false 且 confirm=true。'
: '未写入:请同时传 dryRun=false 与 confirm=true。');
return success($preview);
}
if ($nothingToDo) {
$preview['hint'] = '无需写入。';
return success($preview, '无需归并');
}
$now = time();
Db::startTrans();
try {
Db::name('test_results')
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0)->whereOr('enterpriseId', '');
})
->update([
'enterpriseId' => $targetEnterpriseId,
'testScope' => 'enterprise',
'updatedAt' => $now,
]);
if (!empty($orphanUserIds)) {
Db::name('wechat_users')
->whereIn('id', $orphanUserIds)
->whereRaw($this->sqlWechatUserUnassignedEnterprise())
->update([
'enterpriseId' => $targetEnterpriseId,
'updatedAt' => $now,
]);
}
Db::name('wechat_users')
->where(function ($q) {
$q->whereNull('enterpriseId')->whereOr('enterpriseId', 0);
})
->update([
'enterpriseId' => $targetEnterpriseId,
]);
$boundAfter = Db::name('wechat_users')
->where('enterpriseId', $targetEnterpriseId)
->column('id');
$boundAfter = array_values(array_unique(array_filter(array_map('intval', $boundAfter))));
if ($clonePersonalProfile && !empty($distinctUserIds)) {
foreach ($distinctUserIds as $uid) {
$this->ensureEnterpriseProfileFromPersonal((int) $uid, $targetEnterpriseId, $now);
if (!empty($boundAfter)) {
Db::name('test_results')
->whereIn('userId', $boundAfter)
->whereRaw($this->sqlTestResultUnassignedEnterprise())
->update([
'enterpriseId' => $targetEnterpriseId,
'testScope' => 'enterprise',
'updatedAt' => $now,
]);
foreach ($boundAfter as $uid) {
$this->mergePersonalUserProfileIntoEnterprise((int) $uid, $targetEnterpriseId, $now);
}
}
@@ -387,7 +474,7 @@ class DataMigration extends BaseController
}
$preview['executed'] = true;
$preview['hint'] = '已写入。无企业归属的测试与用户已归属到目标企业。';
$preview['hint'] = '已写入:小程序用户归属、无企业测试记录、画像 personal 行合并已完成。';
return success($preview, '归并完成');
}
}

View File

@@ -113,6 +113,13 @@ class Enterprise extends BaseController
return error('企业不存在', 404);
}
$wechatPage = max(1, (int) Request::param('wechatPage', 1));
$wechatPageSize = min(100, max(1, (int) Request::param('wechatPageSize', 10)));
$testPage = max(1, (int) Request::param('testPage', 1));
$testPageSize = min(100, max(1, (int) Request::param('testPageSize', 10)));
$orderPage = max(1, (int) Request::param('orderPage', 1));
$orderPageSize = min(100, max(1, (int) Request::param('orderPageSize', 10)));
$data = $enterprise->toArray();
// 获取企业下的所有用户ID只统计未删除的用户
@@ -152,13 +159,14 @@ class Enterprise extends BaseController
->alias('tr')
->leftJoin('users u', 'tr.userId = u.id')
->where('tr.userId', 'in', $userIds)
->field('tr.id,tr.testType,tr.createdAt,u.username')
->field('tr.id,tr.testType,tr.createdAt,tr.userId,tr.resultData,u.username')
->order('tr.createdAt', 'desc')
->limit(50) // 限制返回数量
->select()
->toArray();
}
$data['testResults'] = $testResults;
$this->attachResultSummaries($data['testResults']);
// 统计测试用量
if (!empty($userIds)) {
@@ -177,39 +185,46 @@ class Enterprise extends BaseController
} catch (\Throwable $e) {
$wechatIds = [];
}
$data['wechatUserCount'] = count($wechatIds);
$data['wechatUsers'] = [];
if (!empty($wechatIds)) {
try {
$data['wechatUsers'] = Db::name('wechat_users')
->where('id', 'in', $wechatIds)
->field('id,openid,nickname,phone,avatar,status,lastLoginAt,createdAt')
->order('createdAt', 'desc')
->limit(120)
->select()
->toArray();
} catch (\Throwable $e) {
$data['wechatUsers'] = [];
}
$wechatUserTotalCount = 0;
try {
$wechatUserTotalCount = (int) Db::name('wechat_users')->where('enterpriseId', $id)->count();
} catch (\Throwable $e) {
$wechatUserTotalCount = 0;
}
$data['wechatUserCount'] = $wechatUserTotalCount;
$data['wechatUsersTotal'] = $wechatUserTotalCount;
$data['wechatUsers'] = [];
try {
$data['wechatUsers'] = Db::name('wechat_users')
->where('enterpriseId', $id)
->field('id,openid,nickname,phone,avatar,status,lastLoginAt,createdAt')
->order('createdAt', 'desc')
->page($wechatPage, $wechatPageSize)
->select()
->toArray();
} catch (\Throwable $e) {
$data['wechatUsers'] = [];
}
// 该企业下、带 enterpriseId 的小程序测试记录
$data['miniprogramTestResults'] = [];
$data['miniprogramTestResults'] = [];
$data['miniprogramTestResultsTotal'] = 0;
try {
$data['miniprogramTestResultsTotal'] = (int) Db::name('test_results')->where('enterpriseId', $id)->count();
$data['miniprogramTestResults'] = Db::name('test_results')
->alias('tr')
->leftJoin('wechat_users w', 'tr.userId = w.id')
->where('tr.enterpriseId', $id)
->field('tr.id,tr.testType,tr.createdAt,tr.userId,w.nickname as wechatNickname')
->field('tr.id,tr.testType,tr.createdAt,tr.userId,tr.resultData,w.nickname as wechatNickname')
->order('tr.createdAt', 'desc')
->limit(60)
->page($testPage, $testPageSize)
->select()
->toArray();
$this->attachResultSummaries($data['miniprogramTestResults']);
} catch (\Throwable $e) {
$data['miniprogramTestResults'] = [];
}
// 订单与消耗(金额分)
// 订单与消耗(金额分)
$paidStatuses = ['paid', 'completed'];
try {
$data['orderStats'] = [
@@ -217,10 +232,11 @@ class Enterprise extends BaseController
'paidCount' => (int) Db::name('orders')->where('enterpriseId', $id)->whereIn('status', $paidStatuses)->count(),
'paidAmountFen' => (int) (Db::name('orders')->where('enterpriseId', $id)->whereIn('status', $paidStatuses)->sum('amount') ?? 0),
];
$data['recentOrdersTotal'] = (int) Db::name('orders')->where('enterpriseId', $id)->count();
$data['recentOrders'] = Db::name('orders')
->where('enterpriseId', $id)
->order('createdAt', 'desc')
->limit(25)
->page($orderPage, $orderPageSize)
->field('id,orderNo,status,amount,productType,userId,createdAt')
->select()
->toArray();
@@ -230,7 +246,8 @@ class Enterprise extends BaseController
'paidCount' => 0,
'paidAmountFen' => 0,
];
$data['recentOrders'] = [];
$data['recentOrders'] = [];
$data['recentOrdersTotal'] = 0;
}
// 埋点:近 30 天,归属该企业的小程序用户
@@ -547,5 +564,74 @@ class Enterprise extends BaseController
return success($enterprise, '操作成功');
}
/**
* 为列表行附加 resultSummary并移除原始 resultData减小响应体积
*/
private function attachResultSummaries(array &$rows): void
{
foreach ($rows as &$r) {
$type = (string) ($r['testType'] ?? '');
$r['resultSummary'] = $this->summarizeTestResultForAdmin($type, $r['resultData'] ?? null);
unset($r['resultData']);
}
unset($r);
}
/**
* 从 test_results.resultData 解析超管可读短摘要
*/
private function summarizeTestResultForAdmin(string $testType, $resultData): string
{
if ($resultData === null || $resultData === '') {
return '—';
}
$decoded = is_string($resultData) ? json_decode($resultData, true) : $resultData;
if (!is_array($decoded)) {
return '—';
}
$testType = strtolower($testType);
switch ($testType) {
case 'mbti':
if (!empty($decoded['mbtiType'])) {
return (string) $decoded['mbtiType'];
}
if (!empty($decoded['mbti']['type'])) {
return (string) $decoded['mbti']['type'];
}
return '—';
case 'disc':
$d = trim((string) ($decoded['dominantType'] ?? ''));
$s = trim((string) ($decoded['secondaryType'] ?? ''));
$line = $d . ($d !== '' && $s !== '' ? ' + ' : '') . $s;
return $line !== '' ? $line : '—';
case 'pdp':
$d = trim((string) ($decoded['dominantType'] ?? ''));
return $d !== '' ? $d : '—';
case 'face':
case 'ai':
$parts = [];
if (!empty($decoded['mbti']['type'])) {
$parts[] = 'MBTI ' . $decoded['mbti']['type'];
}
if (!empty($decoded['pdp']['primary'])) {
$parts[] = 'PDP ' . $decoded['pdp']['primary'];
}
if (!empty($decoded['disc']['primary'])) {
$parts[] = 'DISC ' . $decoded['disc']['primary'];
}
if ($parts !== []) {
return implode(' · ', $parts);
}
$sum = $decoded['personalitySummary'] ?? $decoded['overview'] ?? '';
$sum = is_string($sum) ? trim($sum) : '';
if ($sum !== '') {
return mb_strlen($sum) > 48 ? mb_substr($sum, 0, 48) . '…' : $sum;
}
return '面相/智能分析';
default:
return '—';
}
}
}

View File

@@ -2,14 +2,24 @@
-- ThinkPHPDb::name('analytics_events') + 前缀 mbti_ => mbti_analytics_events
CREATE TABLE IF NOT EXISTS `mbti_analytics_events` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`userId` int(10) unsigned DEFAULT NULL COMMENT 'wechat_users.id',
`userId` int(10) unsigned DEFAULT NULL COMMENT 'wechat_users.id / douyin_users.id',
`openid` varchar(64) DEFAULT NULL,
`eventName` varchar(128) NOT NULL DEFAULT '',
`pagePath` varchar(255) DEFAULT NULL,
`propsJson` text COMMENT 'JSON 字符串',
`propsJson` text COMMENT 'JSON 字符串(含 _device/_network/_scene',
`clientTs` bigint(20) DEFAULT NULL COMMENT '客户端毫秒时间戳',
`platform` varchar(16) DEFAULT NULL COMMENT 'wechat / douyin',
`sessionId` varchar(64) DEFAULT NULL COMMENT '单次启动的会话标识',
`createdAt` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_event_created` (`eventName`,`createdAt`),
KEY `idx_created` (`createdAt`)
KEY `idx_created` (`createdAt`),
KEY `idx_platform` (`platform`,`createdAt`),
KEY `idx_session` (`sessionId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='小程序行为埋点';
-- 若已有表需加列,执行以下 ALTER幂等写法
-- ALTER TABLE `mbti_analytics_events` ADD COLUMN `platform` varchar(16) DEFAULT NULL COMMENT 'wechat / douyin' AFTER `clientTs`;
-- ALTER TABLE `mbti_analytics_events` ADD COLUMN `sessionId` varchar(64) DEFAULT NULL COMMENT '单次启动的会话标识' AFTER `platform`;
-- ALTER TABLE `mbti_analytics_events` ADD INDEX `idx_platform` (`platform`,`createdAt`);
-- ALTER TABLE `mbti_analytics_events` ADD INDEX `idx_session` (`sessionId`);

View File

@@ -57,6 +57,8 @@ Route::group('api', function () {
Route::post('payment/create', 'api.Payment/create');
Route::post('payment/notify', 'api.Payment/notify');
Route::get('payment/query', 'api.Payment/query');
// 当前用户订单列表(小程序「我的订单」)
Route::get('orders', 'api.Order/index');
// 分销
Route::post('distribution/bind', 'api.Distribution/bind');
Route::get('distribution/stats', 'api.Distribution/stats');

View File

@@ -1,6 +1,29 @@
// 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)
}
}
return _OrigPage(pageConfig)
}
App({
globalData: {
userInfo: null,
@@ -21,10 +44,16 @@ App({
reviewMode: true
},
onLaunch() {
onLaunch(launchOptions) {
this.globalData.scene = launchOptions && launchOptions.scene ? launchOptions.scene : ''
this.loadStoredData()
this.silentLogin()
try {
const analytics = require('./utils/analytics.js')
analytics.reportAppLaunch(launchOptions)
} catch (e) {}
this.getRuntimeConfig().then((cfg) => {
if (cfg) {
if (cfg.siteTitle) this.globalData.siteTitle = cfg.siteTitle
@@ -100,14 +129,17 @@ App({
}
const url = `${this.globalData.apiBase}/api/auth/douyin`
const loginData = { code: code || '', anonymousCode: anonymousCode || '' }
try {
const gd = this.globalData || {}
const eid = gd.enterpriseIdFromScene || (gd.userInfo && gd.userInfo.enterpriseId) || gd.defaultEnterpriseId
if (eid && Number(eid) > 0) loginData.enterpriseId = Number(eid)
} catch (e) {}
tt.request({
url,
method: 'POST',
header: { 'Content-Type': 'application/json' },
data: {
code: code || '',
anonymousCode: anonymousCode || ''
},
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;
}
@@ -59,18 +59,18 @@
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

@@ -125,6 +125,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] || '拍摄完成'
@@ -225,11 +226,13 @@ Page({
tt.hideLoading()
tt.setStorageSync('aiPhotos', urls)
this.setData({ uploading: false })
try { require('../../utils/analytics').track('photo_upload_success', { count: urls.length }) } catch (e) {}
tt.navigateTo({ url: '/pages/index/result' })
})
.catch((err) => {
tt.hideLoading()
this.setData({ uploading: false })
try { require('../../utils/analytics').track('photo_upload_fail', { error: (err && err.message) || '' }) } catch (e) {}
tt.showToast({ title: err.message || '上传失败', icon: 'none' })
})
},

View File

@@ -101,6 +101,7 @@ Page({
},
onLoad(options) {
this._payInfoSetByDetail = false
const id = options && options.id
const type = options && options.type
@@ -156,13 +157,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 {
tt.showToast({ title: res.data?.message || '加载失败', icon: 'none' })
setTimeout(() => tt.navigateBack(), 1500)
@@ -260,6 +262,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)
@@ -348,7 +351,8 @@ Page({
amountYuan
}
})
} else {
} else if (!this._payInfoSetByDetail) {
// 历史详情入口已由 initPayInfoFromRuntime(detailPayload) 处理过,不再覆盖
this.initPayInfoFromRuntime()
}
},
@@ -374,6 +378,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,
@@ -433,6 +439,7 @@ Page({
},
onTapUnlockPay() {
try { require('../../utils/analytics').track('tap_unlock_pay', { amount: this.data.payInfo.amountYuan, testResultId: this.data.testResultId }) } catch (e) {}
this.unlockFullReport()
},
@@ -509,16 +516,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)
}
@@ -553,10 +558,6 @@ Page({
},
// 跳转到详情性格测试选择页MBTI / PDP / DISC 三选一)
goToMBTI() {
tt.navigateTo({ url: '/pages/test-select/index' })
},
shareResult() {
tt.showShareMenu({ withShareTicket: true, menus: ['shareAppMessage'] })
},

View File

@@ -72,7 +72,6 @@
<text class="card-title">性格类型分析</text>
<text class="personality-card-sub">MBTI · PDP · DISC 联合速览</text>
<!-- MBTI类型 -->
<view class="personality-type">
<text class="type-code">{{result.mbti}}</text>
<text class="type-name">{{result.title}}</text>
@@ -83,7 +82,6 @@
<text class="type-desc">{{result.summary}}</text>
</view>
<!-- PDP和DISC -->
<view class="type-details">
<view class="type-box pdp-box">
<text class="type-emoji" tt:if="{{result.pdpEmoji}}">{{result.pdpEmoji}}</text>
@@ -99,7 +97,6 @@
</view>
</view>
<!-- 主要优势 -->
<view class="advantages-box" tt:if="{{result.traits.length > 0}}">
<text class="advantages-label">主要优势</text>
<view class="advantages-list">
@@ -109,13 +106,8 @@
</view>
</view>
</view>
<view class="next-btn" bindtap="goToMBTI">
<text class="next-btn-text">下一步:详细性格测试 →</text>
</view>
</view>
<!-- 性格概述 -->
<view class="card" tt:if="{{!hasError && result.summary}}">
<text class="card-title">性格概述</text>
<text class="card-text">{{result.summary}}</text>

View File

@@ -0,0 +1,79 @@
// 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(() => tt.stopPullDownRefresh()).catch(() => tt.stopPullDownRefresh())
},
loadList(reset) {
const token = app.globalData.token || tt.getStorageSync('token')
if (!token) {
tt.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) tt.showToast({ title: body.message, icon: 'none' })
}
resolve()
},
fail: () => {
this.setData({ loading: false })
tt.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.ttml-->
<view class="page">
<view class="loading-wrap" tt:if="{{loading && list.length === 0}}">
<text class="loading-text">加载中...</text>
</view>
<view class="empty" tt:elif="{{empty && !loading}}">
<text class="empty-icon">🧾</text>
<text class="empty-title">暂无订单</text>
<text class="empty-desc">购买专业报告或测试服务后,订单会显示在这里</text>
</view>
<view class="list" tt:else>
<view class="order-card" tt:for="{{list}}" tt: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" tt:if="{{hasMore}}" bindtap="loadMore">
<text class="load-more-text">{{loading ? '加载中...' : '加载更多'}}</text>
</view>
<view class="list-end" tt: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

@@ -276,6 +276,14 @@ Page({
try { require('../../utils/analytics').track('tap_deep_service', {}) } catch (e) {}
tt.navigateTo({ url: '/pages/purchase/index' })
},
goToOrders() {
if (!this.data.hasLogin) {
tt.showToast({ title: '请先登录', icon: 'none' })
return
}
try { require('../../utils/analytics').track('tap_my_orders', {}) } catch (e) {}
tt.navigateTo({ url: '/pages/order/index' })
},
goToPurchase() { tt.navigateTo({ url: '/pages/purchase/index' }) },
goToPurchasePersonal() { tt.navigateTo({ url: '/pages/purchase/index?tab=personal' }) },
goToPurchaseEnterprise() { tt.navigateTo({ url: '/pages/purchase/index?tab=enterprise' }) },
@@ -313,9 +321,9 @@ Page({
if (id) tt.navigateTo({ url: `/pages/index/result?id=${id}&type=ai` })
else tt.switchTab({ url: '/pages/index/camera' })
},
goToActionTest() {
try { require('../../utils/analytics').track('tap_action_test_camera', {}) } catch (e) {}
tt.switchTab({ url: '/pages/index/camera' })
goToTestSelect() {
try { require('../../utils/analytics').track('tap_test_select_from_profile', {}) } catch (e) {}
tt.navigateTo({ url: '/pages/test-select/index' })
},
logout() {

View File

@@ -128,20 +128,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" tt: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" tt: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>
@@ -152,6 +152,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" tt:if="{{hasEnterprise}}"></view>
<view class="menu-item menu-item--flat" tt:if="{{hasEnterprise}}" bindtap="goToMyResume">
<view class="menu-icon-wrap menu-icon-indigo">

View File

@@ -591,6 +591,7 @@ page {
.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 = tt.getStorageSync('discResult')
if (result) {
this.setData({ result: withPercentagesInt(result) })
this.initPayInfoFromRuntime('disc')
} else {
tt.showToast({ title: '暂无测试结果', icon: 'none' })
setTimeout(() => tt.navigateBack(), 1500)
}
},
loadDetail(id) {
const apiBase = app.globalData?.apiBase || ''
const token = app.globalData?.token || tt.getStorageSync('token') || ''
if (!apiBase) { tt.showToast({ title: '配置异常', icon: 'none' }); return }
tt.showLoading({ title: '加载中...' })
tt.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 {
tt.showToast({ title: res.data?.message || '加载失败', icon: 'none' })
}
},
fail: () => tt.showToast({ title: '网络错误', icon: 'none' }),
complete: () => tt.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) { tt.showToast({ title: '请先登录', icon: 'none' }); return }
payment.purchaseDiscTest({
testResultId: testResultId || undefined,
success: () => {
tt.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) {
tt.removeStorageSync('discResult')
}
tt.navigateTo({ url: '/pages/test/disc' })
},
goHome() {
const scope = (getApp().globalData && getApp().globalData.appScope) || 'personal'
if (scope === 'enterprise') {
tt.navigateTo({ url: '/pages/enterprise/index' })
} else {
tt.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 = tt.getStorageSync('discResult')
if (result) {
this.setData({ result: withPercentagesInt(result) })
this.initPayInfoFromRuntime('disc')
} else {
tt.showToast({ title: '暂无测试结果', icon: 'none' })
setTimeout(() => tt.navigateBack(), 1500)
}
},
loadDetail(id) {
const apiBase = app.globalData?.apiBase || ''
const token = app.globalData?.token || tt.getStorageSync('token') || ''
if (!apiBase) { tt.showToast({ title: '配置异常', icon: 'none' }); return }
tt.showLoading({ title: '加载中...' })
tt.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 {
tt.showToast({ title: res.data?.message || '加载失败', icon: 'none' })
}
},
fail: () => tt.showToast({ title: '网络错误', icon: 'none' }),
complete: () => tt.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) { tt.showToast({ title: '请先登录', icon: 'none' }); return }
payment.purchaseDiscTest({
testResultId: testResultId || undefined,
success: () => {
tt.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) {
tt.removeStorageSync('discResult')
}
tt.navigateTo({ url: '/pages/test/disc' })
},
goHome() {
const scope = (getApp().globalData && getApp().globalData.appScope) || 'personal'
if (scope === 'enterprise') {
tt.navigateTo({ url: '/pages/enterprise/index' })
} else {
tt.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

@@ -1,224 +1,224 @@
// pages/result/mbti.js - MBTI结果页支持付费墙 + 历史详情拉取)
const app = getApp()
const payment = require('../../utils/payment')
const { hasPhone, bindPhoneByCode, ensureProfileCompleteAndRedirect } = require('../../utils/phoneAuth.js')
Page({
data: {
result: null,
dimensions: [],
mbtiDesc: {
title: '',
description: '',
strengths: [],
weaknesses: [],
careers: [],
relationships: ''
},
payInfo: {
requiresPayment: false,
isPaid: false,
amountYuan: 0
},
testResultId: null,
hasReloadedAfterPay: false,
hasPhone: false
},
onLoad(options) {
const id = options && options.id
const type = options && options.type
if (id && type === 'mbti') {
this.setData({ testResultId: id })
this.loadDetail(id)
return
}
const result = tt.getStorageSync('mbtiResult')
if (result) {
this.applyResult(result)
this.initPayInfoFromRuntime('mbti')
} else {
tt.showToast({ title: '暂无测试结果', icon: 'none' })
setTimeout(() => tt.navigateBack(), 1500)
}
},
onShow() {
if (!ensureProfileCompleteAndRedirect()) return
this.setData({ hasPhone: hasPhone() })
},
loadDetail(id) {
const apiBase = app.globalData?.apiBase || ''
const token = app.globalData?.token || tt.getStorageSync('token') || ''
if (!apiBase) {
tt.showToast({ title: '配置异常', icon: 'none' })
return
}
tt.showLoading({ title: '加载中...' })
tt.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.applyResult(data)
const payInfo = {
requiresPayment: needPaymentToUnlock,
isPaid,
amountYuan: needPaymentToUnlock ? amountYuan : 0
}
this.setData({ payInfo })
} else {
tt.showToast({ title: res.data?.message || '加载失败', icon: 'none' })
}
},
fail: () => tt.showToast({ title: '网络错误', icon: 'none' }),
complete: () => tt.hideLoading()
})
},
applyResult(result) {
if (!result) return
const desc = result.description || {}
const dimensions = (result.dimensionScores && !result.locked)
? [
{ key: 'EI', left: '外向(E)', right: '内向(I)', ...result.dimensionScores.EI },
{ key: 'SN', left: '感觉(S)', right: '直觉(N)', ...result.dimensionScores.SN },
{ key: 'TF', left: '思考(T)', right: '情感(F)', ...result.dimensionScores.TF },
{ key: 'JP', left: '判断(J)', right: '知觉(P)', ...result.dimensionScores.JP }
]
: []
this.setData({
result,
dimensions,
mbtiDesc: {
title: desc.name || '',
description: desc.description || '',
strengths: desc.strengths || [],
weaknesses: desc.weaknesses || [],
careers: desc.careers || [],
relationships: desc.relationships || ''
}
})
},
initPayInfoFromRuntime(testType) {
app.getRuntimeConfig()
.then((cfg) => {
const pricing = cfg.pricing || {}
const reportRequires = cfg.reportRequiresPayment || {}
const requiresPayment = !!(reportRequires && reportRequires[testType])
const amountYuan = Number(pricing[testType]) || (requiresPayment ? 1.98 : 0)
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) {
tt.showToast({ title: '请先登录', icon: 'none' })
return
}
payment.purchaseMbtiTest({
testResultId: testResultId || undefined,
success: () => {
tt.showToast({ title: '已解锁完整报告', icon: 'success' })
this.setData({ 'payInfo.isPaid': true })
if (testResultId && !hasReloadedAfterPay) {
this.setData({ hasReloadedAfterPay: true })
setTimeout(() => this.loadDetail(testResultId), 500)
}
},
fail: () => {}
})
})
},
// 付费解锁按钮:就地触发微信手机号授权,然后调用 unlockFullReport
onGetPhoneNumberForMbtiPay(e) {
if (!ensureProfileCompleteAndRedirect()) return
const { code, errMsg } = e.detail || {}
if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) {
if (!hasPhone()) {
tt.showToast({ title: '需要授权手机号才能继续', icon: 'none' })
return
}
this.unlockFullReport()
return
}
if (!code) {
if (hasPhone()) {
this.unlockFullReport()
} else {
tt.showToast({ title: '获取手机号失败', icon: 'none' })
}
return
}
bindPhoneByCode(code)
.then(() => {
this.setData({ hasPhone: true })
this.unlockFullReport()
})
.catch(() => {
// 保持在当前页,等待用户重新点击
})
},
retakeTest() {
if (!this.data.testResultId) {
tt.removeStorageSync('mbtiResult')
}
tt.navigateTo({ url: '/pages/test/mbti' })
},
goHome() {
const scope = (getApp().globalData && getApp().globalData.appScope) || 'personal'
if (scope === 'enterprise') {
tt.navigateTo({ url: '/pages/enterprise/index' })
} else {
tt.switchTab({ url: '/pages/index/index' })
}
},
onShareAppMessage() {
const result = this.data.result
const { getSharePathByScope } = require('../../utils/share')
return {
title: `我的MBTI类型是${result?.mbtiType}${result?.description?.name}),来测测你的吧!`,
path: getSharePathByScope('/pages/index/index'),
imageUrl: '/images/share-mbti.png'
}
},
onShareTimeline() {
const result = this.data.result
const { buildShareQuery } = require('../../utils/share')
return {
title: `我的MBTI类型是${result?.mbtiType}${result?.description?.name}),来测测你的吧!`,
query: buildShareQuery()
}
}
})
// pages/result/mbti.js - MBTI结果页支持付费墙 + 历史详情拉取)
const app = getApp()
const payment = require('../../utils/payment')
const { hasPhone, bindPhoneByCode, ensureProfileCompleteAndRedirect } = require('../../utils/phoneAuth.js')
Page({
data: {
result: null,
dimensions: [],
mbtiDesc: {
title: '',
description: '',
strengths: [],
weaknesses: [],
careers: [],
relationships: ''
},
payInfo: {
requiresPayment: false,
isPaid: false,
amountYuan: 0
},
testResultId: null,
hasReloadedAfterPay: false,
hasPhone: false
},
onLoad(options) {
const id = options && options.id
const type = options && options.type
if (id && type === 'mbti') {
this.setData({ testResultId: id })
this.loadDetail(id)
return
}
const result = tt.getStorageSync('mbtiResult')
if (result) {
this.applyResult(result)
this.initPayInfoFromRuntime('mbti')
} else {
tt.showToast({ title: '暂无测试结果', icon: 'none' })
setTimeout(() => tt.navigateBack(), 1500)
}
},
onShow() {
if (!ensureProfileCompleteAndRedirect()) return
this.setData({ hasPhone: hasPhone() })
},
loadDetail(id) {
const apiBase = app.globalData?.apiBase || ''
const token = app.globalData?.token || tt.getStorageSync('token') || ''
if (!apiBase) {
tt.showToast({ title: '配置异常', icon: 'none' })
return
}
tt.showLoading({ title: '加载中...' })
tt.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.applyResult(data)
const payInfo = {
requiresPayment: needPaymentToUnlock,
isPaid,
amountYuan: needPaymentToUnlock ? amountYuan : 0
}
this.setData({ payInfo })
} else {
tt.showToast({ title: res.data?.message || '加载失败', icon: 'none' })
}
},
fail: () => tt.showToast({ title: '网络错误', icon: 'none' }),
complete: () => tt.hideLoading()
})
},
applyResult(result) {
if (!result) return
const desc = result.description || {}
const dimensions = (result.dimensionScores && !result.locked)
? [
{ key: 'EI', left: '外向(E)', right: '内向(I)', ...result.dimensionScores.EI },
{ key: 'SN', left: '感觉(S)', right: '直觉(N)', ...result.dimensionScores.SN },
{ key: 'TF', left: '思考(T)', right: '情感(F)', ...result.dimensionScores.TF },
{ key: 'JP', left: '判断(J)', right: '知觉(P)', ...result.dimensionScores.JP }
]
: []
this.setData({
result,
dimensions,
mbtiDesc: {
title: desc.name || '',
description: desc.description || '',
strengths: desc.strengths || [],
weaknesses: desc.weaknesses || [],
careers: desc.careers || [],
relationships: desc.relationships || ''
}
})
},
initPayInfoFromRuntime(testType) {
app.getRuntimeConfig()
.then((cfg) => {
const pricing = cfg.pricing || {}
const reportRequires = cfg.reportRequiresPayment || {}
const requiresPayment = !!(reportRequires && reportRequires[testType])
const amountYuan = Number(pricing[testType]) || (requiresPayment ? 1.98 : 0)
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) {
tt.showToast({ title: '请先登录', icon: 'none' })
return
}
payment.purchaseMbtiTest({
testResultId: testResultId || undefined,
success: () => {
tt.showToast({ title: '已解锁完整报告', icon: 'success' })
this.setData({ 'payInfo.isPaid': true })
if (testResultId && !hasReloadedAfterPay) {
this.setData({ hasReloadedAfterPay: true })
setTimeout(() => this.loadDetail(testResultId), 500)
}
},
fail: () => {}
})
})
},
// 付费解锁按钮:就地触发微信手机号授权,然后调用 unlockFullReport
onGetPhoneNumberForMbtiPay(e) {
if (!ensureProfileCompleteAndRedirect()) return
const { code, errMsg } = e.detail || {}
if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) {
if (!hasPhone()) {
tt.showToast({ title: '需要授权手机号才能继续', icon: 'none' })
return
}
this.unlockFullReport()
return
}
if (!code) {
if (hasPhone()) {
this.unlockFullReport()
} else {
tt.showToast({ title: '获取手机号失败', icon: 'none' })
}
return
}
bindPhoneByCode(code)
.then(() => {
this.setData({ hasPhone: true })
this.unlockFullReport()
})
.catch(() => {
// 保持在当前页,等待用户重新点击
})
},
retakeTest() {
if (!this.data.testResultId) {
tt.removeStorageSync('mbtiResult')
}
tt.navigateTo({ url: '/pages/test/mbti' })
},
goHome() {
const scope = (getApp().globalData && getApp().globalData.appScope) || 'personal'
if (scope === 'enterprise') {
tt.navigateTo({ url: '/pages/enterprise/index' })
} else {
tt.switchTab({ url: '/pages/index/index' })
}
},
onShareAppMessage() {
const result = this.data.result
const { getSharePathByScope } = require('../../utils/share')
return {
title: `我的MBTI类型是${result?.mbtiType}${result?.description?.name}),来测测你的吧!`,
path: getSharePathByScope('/pages/index/index'),
imageUrl: '/images/share-mbti.png'
}
},
onShareTimeline() {
const result = this.data.result
const { buildShareQuery } = require('../../utils/share')
return {
title: `我的MBTI类型是${result?.mbtiType}${result?.description?.name}),来测测你的吧!`,
query: buildShareQuery()
}
}
})

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 = tt.getStorageSync('pdpResult')
if (result) {
this.setData({ result: withPercentagesInt(result) })
this.initPayInfoFromRuntime('pdp')
} else {
tt.showToast({ title: '暂无测试结果', icon: 'none' })
setTimeout(() => tt.navigateBack(), 1500)
}
},
loadDetail(id) {
const apiBase = app.globalData?.apiBase || ''
const token = app.globalData?.token || tt.getStorageSync('token') || ''
if (!apiBase) { tt.showToast({ title: '配置异常', icon: 'none' }); return }
tt.showLoading({ title: '加载中...' })
tt.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 {
tt.showToast({ title: res.data?.message || '加载失败', icon: 'none' })
}
},
fail: () => tt.showToast({ title: '网络错误', icon: 'none' }),
complete: () => tt.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) { tt.showToast({ title: '请先登录', icon: 'none' }); return }
payment.purchasePdpTest({
testResultId: testResultId || undefined,
success: () => {
tt.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) {
tt.removeStorageSync('pdpResult')
}
tt.navigateTo({ url: '/pages/test/pdp' })
},
goHome() {
const scope = (getApp().globalData && getApp().globalData.appScope) || 'personal'
if (scope === 'enterprise') {
tt.navigateTo({ url: '/pages/enterprise/index' })
} else {
tt.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 = tt.getStorageSync('pdpResult')
if (result) {
this.setData({ result: withPercentagesInt(result) })
this.initPayInfoFromRuntime('pdp')
} else {
tt.showToast({ title: '暂无测试结果', icon: 'none' })
setTimeout(() => tt.navigateBack(), 1500)
}
},
loadDetail(id) {
const apiBase = app.globalData?.apiBase || ''
const token = app.globalData?.token || tt.getStorageSync('token') || ''
if (!apiBase) { tt.showToast({ title: '配置异常', icon: 'none' }); return }
tt.showLoading({ title: '加载中...' })
tt.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 {
tt.showToast({ title: res.data?.message || '加载失败', icon: 'none' })
}
},
fail: () => tt.showToast({ title: '网络错误', icon: 'none' }),
complete: () => tt.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) { tt.showToast({ title: '请先登录', icon: 'none' }); return }
payment.purchasePdpTest({
testResultId: testResultId || undefined,
success: () => {
tt.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) {
tt.removeStorageSync('pdpResult')
}
tt.navigateTo({ url: '/pages/test/pdp' })
},
goHome() {
const scope = (getApp().globalData && getApp().globalData.appScope) || 'personal'
if (scope === 'enterprise') {
tt.navigateTo({ url: '/pages/enterprise/index' })
} else {
tt.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

@@ -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,26 +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()
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]
@@ -125,6 +152,7 @@ Page({
// 本地缓存 + 全局缓存
tt.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()
},
@@ -40,6 +41,10 @@ Page({
},
onUnload() {
if (this._advanceTimer) {
clearTimeout(this._advanceTimer)
this._advanceTimer = null
}
if (this.timer) {
clearInterval(this.timer)
}
@@ -63,33 +68,50 @@ Page({
},
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]
@@ -169,6 +191,7 @@ Page({
timestamp: new Date().toISOString()
}
tt.setStorageSync('mbtiResult', resultData)
try { require('../../utils/analytics').track('test_complete', { type: 'mbti', result: result.mbtiType, confidence: result.confidence, duration: resultData.testDuration }) } catch (e) {}
app.saveTestResult('mbti', resultData)
tt.redirectTo({

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,26 +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()
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]
@@ -128,6 +155,7 @@ Page({
// 本地缓存 + 全局缓存
tt.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
* - 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 = 'dy_' + 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 = tt.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 {
tt.getNetworkType({
success(res) { _networkType = res.networkType || 'unknown' }
})
} catch (e) {}
}
refreshNetwork()
function getAppSafe() {
try { return getApp() } catch (e) { return null }
}
function getCurrentRoute() {
@@ -21,34 +55,40 @@ 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: 'douyin',
network: _networkType,
scene: getScene()
})
if (queue.length >= MAX_BATCH) {
flush()
}
}
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() {
@@ -58,16 +98,54 @@ function flush() {
url: '/api/analytics/events',
method: 'POST',
needAuth: true,
data: { events },
data: { events, device: getDeviceInfo() },
allow401: false,
success() {},
fail() {}
})
}
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

@@ -105,6 +105,7 @@ function douyinPay(options) {
if (payRes.code === 0) {
pollOrderStatus(orderId, 5, 1000, (ok, order) => {
if (ok) {
try { require('./analytics').reportPayResult(true, { productType: productType || '', orderId, amount }) } catch (e) {}
tt.showToast({
title: '支付成功',
icon: 'success',
@@ -112,6 +113,7 @@ function douyinPay(options) {
})
success && success({ payRes, order })
} else {
try { require('./analytics').reportPayResult(true, { productType: productType || '', orderId, amount, note: 'poll_pending' }) } catch (e) {}
tt.showToast({
title: '支付结果处理中,请稍后查看',
icon: 'none',
@@ -121,6 +123,7 @@ function douyinPay(options) {
}
})
} else if (payRes.code === 4) {
try { require('./analytics').reportPayResult(false, { productType: productType || '', orderId, reason: 'cancel' }) } catch (e) {}
tt.showToast({
title: '支付已取消',
icon: 'none'

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'