feat: 管理端与用户端批量更新(订单/邀请双码/tabBar/审核与分销等)
- 管理端:用户详情、企业看板双小程序码、超管设置与用户列表等 - 后端:订单接口、邀请码企业版+个人版、分析与测试相关调整 - 微信小程序/抖音:自定义 tabBar 居中与拍摄钮上移、相机页与结果页、订单页、支付与统计 - 新增 faceResultDetail 工具与 mp 分析迁移脚本 Made-with: Cursor
This commit is contained in:
@@ -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 {
|
||||
/* 整行布局:侧栏与主区之间保留视觉空隙(与设计稿一致约 16~24px) */
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
138
admin/src/utils/faceResultDetail.ts
Normal file
138
admin/src/utils/faceResultDetail.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 使用短串 p(index 无 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' => '个人版',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
119
api/app/controller/api/Order.php
Normal file
119
api/app/controller/api/Order.php
Normal 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 : '订单');
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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, '归并完成');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 '—';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,24 @@
|
||||
-- ThinkPHP:Db::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`);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
// app.js - MBTI抖音小程序主入口
|
||||
const { request } = require('./utils/request.js')
|
||||
|
||||
// 全局注入:所有页面 onShow 自动上报 page_view;onShareAppMessage 自动上报 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 || {}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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'] })
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
79
douyin-miniprogram/pages/order/index.js
Normal file
79
douyin-miniprogram/pages/order/index.js
Normal 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)
|
||||
})
|
||||
}
|
||||
})
|
||||
7
douyin-miniprogram/pages/order/index.json
Normal file
7
douyin-miniprogram/pages/order/index.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"navigationBarTitleText": "我的订单",
|
||||
"navigationBarBackgroundColor": "#FFFFFF",
|
||||
"navigationBarTextStyle": "black",
|
||||
"enablePullDownRefresh": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
32
douyin-miniprogram/pages/order/index.ttml
Normal file
32
douyin-miniprogram/pages/order/index.ttml
Normal 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>
|
||||
134
douyin-miniprogram/pages/order/index.ttss
Normal file
134
douyin-miniprogram/pages/order/index.ttss
Normal 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;
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1,6 +1,36 @@
|
||||
// app.js - MBTI小程序主入口
|
||||
const { request } = require('./utils/request.js')
|
||||
|
||||
// 全局注入:所有页面 onShow 自动上报 page_view;onShareAppMessage 自动上报 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 || {}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
},
|
||||
|
||||
@@ -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'] })
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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%,
|
||||
|
||||
81
miniprogram/pages/order/index.js
Normal file
81
miniprogram/pages/order/index.js
Normal 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)
|
||||
})
|
||||
}
|
||||
})
|
||||
7
miniprogram/pages/order/index.json
Normal file
7
miniprogram/pages/order/index.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"navigationBarTitleText": "我的订单",
|
||||
"navigationBarBackgroundColor": "#FFFFFF",
|
||||
"navigationBarTextStyle": "black",
|
||||
"enablePullDownRefresh": true,
|
||||
"usingComponents": {}
|
||||
}
|
||||
32
miniprogram/pages/order/index.wxml
Normal file
32
miniprogram/pages/order/index.wxml
Normal 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>
|
||||
134
miniprogram/pages/order/index.wxss
Normal file
134
miniprogram/pages/order/index.wxss
Normal 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;
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user