fix: 仪表盘与用户管理、分析/测试接口、双端结果页与个人中心展示调整

- admin:Dashboard/Users 与超管 Users 联动后端统计
- api:Dashboard、Analyze、Test、超管 AppUser 逻辑更新
- 微信/抖音小程序:result、profile 页面与样式
- 新增 admin 公共组件与需求文档截图

Made-with: Cursor
This commit is contained in:
卡若
2026-03-27 18:26:03 +08:00
parent aca2e263bb
commit 68c73f6ca9
23 changed files with 2706 additions and 437 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -19,11 +19,53 @@
</div>
</div>
<div class="dash-catalog" v-if="testCatalog.length">
<div
v-for="(row, i) in catalogRows"
:key="row.key"
class="catalog-card"
:style="{ animationDelay: `${120 + i * 40}ms` }"
>
<div :class="['catalog-icon', row.tone]">
<el-icon><component :is="row.icon" /></el-icon>
</div>
<div class="catalog-body">
<div class="catalog-label">{{ row.label }}</div>
<div class="catalog-metrics">
<span><em>{{ row.records }}</em> 人次</span>
<span class="sep">·</span>
<span><em>{{ row.uniqueUsers }}</em> </span>
</div>
</div>
</div>
</div>
<div class="dash-main">
<section class="panel panel-chart">
<div class="panel-head">
<h2 class="panel-title"> 14 日测试趋势</h2>
<p class="panel-desc">人脸 · MBTI · PDP · DISC 完成量</p>
<p class="panel-desc">人脸 · MBTI · PDP · DISC 完成量下方为性格结果分布与本企业测评数据一致</p>
</div>
<div v-if="hasDistribution" class="distr-band">
<div v-for="block in distributionBlocks" :key="block.key" class="distr-col">
<div class="distr-col-head">
<el-icon class="distr-head-ic"><component :is="block.icon" /></el-icon>
<span>{{ block.title }}</span>
</div>
<div class="distr-list">
<div v-for="it in block.items" :key="block.key + it.label" class="distr-row-line">
<span class="distr-lab" :title="it.label">{{ it.label }}</span>
<div class="distr-bar-track">
<div
class="distr-bar-fill"
:class="block.barClass"
:style="{ width: barWidthPct(block.max, it.count) }"
/>
</div>
<span class="distr-num">{{ it.count }}</span>
</div>
</div>
</div>
</div>
<div class="chart-box">
<VChart v-if="testTrends.length" class="trend-chart" :option="chartOption" autoresize />
@@ -87,7 +129,15 @@
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { User, Document, TrendCharts } from '@element-plus/icons-vue'
import {
User,
Document,
TrendCharts,
Camera,
Reading,
Histogram,
Medal
} from '@element-plus/icons-vue'
import { request } from '@/utils/request'
import { ElMessage } from 'element-plus'
import { use } from 'echarts/core'
@@ -125,6 +175,18 @@ const testTrends = ref<
Array<{ date: string; face: number; mbti: number; pdp: number; disc: number; total: number }>
>([])
const topTestUsers = ref<TopUserRow[]>([])
const testCatalog = ref<
Array<{ key: string; label: string; records: number; uniqueUsers: number }>
>([])
const distributionMbti = ref<Array<{ label: string; count: number }>>([])
const distributionDisc = ref<Array<{ label: string; count: number }>>([])
const distributionPdp = ref<Array<{ label: string; count: number }>>([])
const faceSubtypeHints = ref<{
mbti: Array<{ label: string; count: number }>
disc: Array<{ label: string; count: number }>
pdp: Array<{ label: string; count: number }>
}>({ mbti: [], disc: [], pdp: [] })
const loading = ref(false)
const inviteLoading = ref(false)
const inviteQrcode = ref<string>('')
@@ -139,6 +201,69 @@ const kpiCards = computed(() => [
{ key: 'p', label: '待审核', value: stats.pendingReviews, icon: User, tone: 'orange' }
])
const catalogIconMap: Record<string, { icon: typeof Camera; tone: string }> = {
face: { icon: Camera, tone: 'teal' },
mbti: { icon: Reading, tone: 'blue' },
disc: { icon: Histogram, tone: 'indigo' },
pdp: { icon: Medal, tone: 'amber' }
}
const catalogRows = computed(() =>
testCatalog.value.map(row => {
const m = catalogIconMap[row.key] || { icon: Document, tone: 'blue' }
return {
...row,
icon: m.icon,
tone: m.tone,
records: row.records ?? 0,
uniqueUsers: row.uniqueUsers ?? 0
}
})
)
function sliceItems(items: Array<{ label: string; count: number }>, n: number) {
return (items || []).slice(0, n)
}
const distributionBlocks = computed(() => {
const mbti = sliceItems(distributionMbti.value, 6)
const disc = sliceItems(distributionDisc.value, 6)
const pdp = sliceItems(distributionPdp.value, 6)
const faceMerged: Array<{ label: string; count: number }> = []
const fh = faceSubtypeHints.value || { mbti: [], disc: [], pdp: [] }
const pushPref = (prefix: string, arr: Array<{ label: string; count: number }>, max: number) => {
let k = 0
for (const it of arr || []) {
if (k >= max) break
faceMerged.push({ label: `${prefix}${it.label}`, count: it.count })
k++
}
}
pushPref('面·MBTI ', fh.mbti, 2)
pushPref('面·DISC ', fh.disc, 2)
pushPref('面·PDP ', fh.pdp, 2)
const blocks = [
{ key: 'mbti', title: 'MBTI答题', items: mbti, icon: Reading, barClass: 'bar-mbti' },
{ key: 'disc', title: 'DISC答题', items: disc, icon: Histogram, barClass: 'bar-disc' },
{ key: 'pdp', title: 'PDP答题', items: pdp, icon: Medal, barClass: 'bar-pdp' },
{ key: 'face', title: '面相推测', items: faceMerged, icon: Camera, barClass: 'bar-face' }
]
return blocks.map(b => ({
...b,
max: Math.max(1, ...b.items.map(i => i.count))
}))
})
const hasDistribution = computed(() =>
distributionBlocks.value.some(b => b.items.length > 0)
)
function barWidthPct(max: number, count: number) {
if (!max || !count) return '0%'
return `${Math.round((count / max) * 100)}%`
}
const chartOption = computed(() => {
const dates = testTrends.value.map(d => d.date.slice(5))
return {
@@ -226,6 +351,25 @@ const loadData = async () => {
stats.pendingReviews = response.data.pendingReviews || 0
testTrends.value = response.data.testTrends || []
topTestUsers.value = Array.isArray(response.data.topTestUsers) ? response.data.topTestUsers : []
testCatalog.value = Array.isArray(response.data.testCatalog) ? response.data.testCatalog : []
distributionMbti.value = Array.isArray(response.data.distributionMbti)
? response.data.distributionMbti
: []
distributionDisc.value = Array.isArray(response.data.distributionDisc)
? response.data.distributionDisc
: []
distributionPdp.value = Array.isArray(response.data.distributionPdp)
? response.data.distributionPdp
: []
const fh = response.data.faceSubtypeHints
faceSubtypeHints.value =
fh && typeof fh === 'object'
? {
mbti: Array.isArray(fh.mbti) ? fh.mbti : [],
disc: Array.isArray(fh.disc) ? fh.disc : [],
pdp: Array.isArray(fh.pdp) ? fh.pdp : []
}
: { mbti: [], disc: [], pdp: [] }
}
} catch (error: any) {
console.error('加载数据失败:', error)
@@ -307,7 +451,94 @@ const loadInviteQrcode = async () => {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-bottom: 10px;
margin-bottom: 8px;
}
.dash-catalog {
flex: 0 0 auto;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-bottom: 8px;
}
.catalog-card {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: #fff;
border-radius: 10px;
border: 1px solid #e5e7eb;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
animation: dashFadeUp 0.45s ease-out both;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.06);
}
}
.catalog-icon {
width: 34px;
height: 34px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 17px;
flex-shrink: 0;
&.teal {
background: #f0fdfa;
color: #0d9488;
}
&.blue {
background: #eff6ff;
color: #3b82f6;
}
&.indigo {
background: #eef2ff;
color: #6366f1;
}
&.amber {
background: #fffbeb;
color: #d97706;
}
}
.catalog-body {
min-width: 0;
}
.catalog-label {
font-size: 13px;
font-weight: 600;
color: #111827;
margin-bottom: 2px;
}
.catalog-metrics {
font-size: 11px;
color: #6b7280;
em {
font-style: normal;
font-weight: 700;
color: #374151;
font-variant-numeric: tabular-nums;
}
.sep {
margin: 0 4px;
color: #d1d5db;
}
}
.stat-card {
@@ -396,6 +627,97 @@ const loadInviteQrcode = async () => {
min-height: 0;
}
.distr-band {
flex: 0 0 auto;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid #f3f4f6;
}
.distr-col {
min-width: 0;
background: #fafafa;
border-radius: 8px;
padding: 8px 10px;
border: 1px solid #f3f4f6;
}
.distr-col-head {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
color: #374151;
margin-bottom: 6px;
.distr-head-ic {
font-size: 14px;
color: #6b7280;
}
}
.distr-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 108px;
overflow-y: auto;
}
.distr-row-line {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(36px, 42%) 22px;
align-items: center;
gap: 6px;
font-size: 10px;
}
.distr-lab {
color: #4b5563;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.distr-bar-track {
height: 5px;
background: #e5e7eb;
border-radius: 3px;
overflow: hidden;
}
.distr-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.35s ease;
&.bar-mbti {
background: #3b82f6;
}
&.bar-disc {
background: #6366f1;
}
&.bar-pdp {
background: #f97316;
}
&.bar-face {
background: #14b8a6;
}
}
.distr-num {
text-align: right;
color: #6b7280;
font-variant-numeric: tabular-nums;
}
.panel-side {
gap: 10px;
padding: 10px;
@@ -435,7 +757,7 @@ const loadInviteQrcode = async () => {
.trend-chart {
width: 100%;
height: 100%;
min-height: 160px;
min-height: 140px;
}
.panel-empty {
@@ -524,5 +846,13 @@ const loadInviteQrcode = async () => {
.dash-kpis {
grid-template-columns: repeat(2, 1fr);
}
.dash-catalog {
grid-template-columns: repeat(2, 1fr);
}
.distr-band {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@@ -164,80 +164,13 @@
</div>
</div>
<!-- 用户详情对话框测试记录 -->
<el-dialog
<UserDetailDialog
v-model="showDetailDialog"
title="测试记录"
width="55%"
destroy-on-close
v-loading="detailLoading"
>
<template v-if="detailUser">
<div class="detail-section" v-if="testTableData.length">
<el-table :data="paginatedTests" size="small" max-height="500" class="test-table">
<el-table-column prop="createdAt" label="时间" width="140">
<template #default="{ row }">{{ formatDate(row.createdAt) }}</template>
</el-table-column>
<el-table-column prop="testType" label="类型" width="120">
<template #default="{ row }">{{ formatTestType(row.testType) }}</template>
</el-table-column>
<el-table-column prop="testScope" label="版本" width="90" align="center">
<template #default="{ row }">
<el-tag
size="small"
:type="row.testScope === 'enterprise' ? 'primary' : 'info'"
>
{{ row.testScope === 'enterprise' ? '企业版' : '个人版' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="summary" label="结果摘要" min-width="160" show-overflow-tooltip />
<el-table-column label="需付费" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.requiresPayment ? 'warning' : 'info'">
{{ row.requiresPayment ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="已付费" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.isPaid ? 'success' : 'info'">
{{ row.isPaid ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="付款金额" width="110" align="center">
<template #default="{ row }">
<span class="payment-cell">
{{ row.isPaid ? formatAmount(row.paidAmount) : '-' }}
</span>
</template>
</el-table-column>
<el-table-column label="付款时间" width="140" align="center">
<template #default="{ row }">
<span class="time-cell">
{{ row.isPaid ? formatDate(row.paidAt) : '-' }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button link type="primary" @click="handleViewTest(row)">查看</el-button>
</template>
</el-table-column>
</el-table>
<div class="test-pagination" v-if="testTableData.length > testPageSize">
<el-pagination
v-model:current-page="testPage"
:page-size="testPageSize"
:total="testTableData.length"
layout="prev, pager, next, total"
@current-change="handleTestPageChange"
/>
</div>
</div>
</template>
</el-dialog>
:user="detailUser"
:loading="detailLoading"
:show-enterprise-match="false"
@view-test="handleViewTest"
/>
<!-- 单次测试详情对话框 -->
<el-dialog
@@ -572,6 +505,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 UserDetailDialog from '@/components/UserDetailDialog.vue'
const loading = ref(false)
const detailLoading = ref(false)
@@ -583,8 +517,6 @@ const searchTerm = ref('')
const showDetailDialog = ref(false)
const detailUser = ref<Record<string, any> | null>(null)
const testPage = ref(1)
const testPageSize = 10
const showTestDetailDialog = ref(false)
const currentTest = ref<any | null>(null)
@@ -594,18 +526,6 @@ const rawTests = computed<any[]>(() => {
return (u.testList || []) as any[]
})
const testTableData = computed(() =>
rawTests.value.map(t => ({
...t,
summary: extractTestSummary(t)
}))
)
const paginatedTests = computed(() => {
const start = (testPage.value - 1) * testPageSize
return testTableData.value.slice(start, start + testPageSize)
})
const currentTestType = computed(() => (currentTest.value?.testType || '').toLowerCase())
const users = ref<any[]>([])
@@ -833,10 +753,6 @@ function handlePageChange() {
loadUsers()
}
function handleTestPageChange(page: number) {
testPage.value = page
}
function exportData() {
ElMessage.info('导出功能开发中')
}
@@ -874,7 +790,6 @@ async function loadDetailUser(userId: number) {
}
async function handleView(row: any) {
testPage.value = 1
detailUser.value = null
showDetailDialog.value = true
@@ -892,7 +807,6 @@ function handleViewTest(row: any) {
async function handleClickTestTag(row: any, testType: string) {
// 仅加载用户测试记录,弹出单次测试详情对话框并显示加载动画
testPage.value = 1
currentTest.value = null
showTestDetailDialog.value = true
testDetailLoading.value = true

View File

@@ -262,70 +262,13 @@
</div>
</div>
<!-- 用户详情对话框 -->
<el-dialog
<UserDetailDialog
v-model="showDetailDialog"
title="测试记录"
width="55%"
destroy-on-close
v-loading="detailLoading"
>
<template v-if="detailUser">
<div class="detail-section" v-if="testTableData.length">
<el-table :data="paginatedTests" size="small" max-height="500" class="test-table">
<el-table-column prop="createdAt" label="时间" width="140">
<template #default="{ row }">{{ formatDate(row.createdAt) }}</template>
</el-table-column>
<el-table-column prop="testType" label="类型" width="120">
<template #default="{ row }">{{ formatTestType(row.testType) }}</template>
</el-table-column>
<el-table-column prop="summary" label="结果摘要" min-width="160" show-overflow-tooltip />
<el-table-column label="需付费" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.requiresPayment ? 'warning' : 'info'">
{{ row.requiresPayment ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="已付费" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.isPaid ? 'success' : 'info'">
{{ row.isPaid ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="付款金额" width="110" align="center">
<template #default="{ row }">
<span class="payment-cell">
{{ row.isPaid ? formatAmount(row.paidAmount) : '-' }}
</span>
</template>
</el-table-column>
<el-table-column label="付款时间" width="140" align="center">
<template #default="{ row }">
<span class="time-cell">
{{ row.isPaid ? formatDate(row.paidAt) : '-' }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button link type="primary" @click="handleViewTest(row)">查看</el-button>
</template>
</el-table-column>
</el-table>
<div class="test-pagination" v-if="testTableData.length > testPageSize">
<el-pagination
v-model:current-page="testPage"
:page-size="testPageSize"
:total="testTableData.length"
layout="prev, pager, next, total"
@current-change="handleTestPageChange"
/>
</div>
</div>
</template>
</el-dialog>
:user="detailUser"
:loading="detailLoading"
:show-enterprise-match="true"
@view-test="handleViewTest"
/>
<!-- 单次测试详情对话框 -->
<el-dialog
@@ -654,6 +597,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 UserDetailDialog from '@/components/UserDetailDialog.vue'
withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
@@ -670,8 +614,6 @@ const selectedMbti = ref('')
const showDetailDialog = ref(false)
const detailUser = ref<Record<string, any> | null>(null)
const testPage = ref(1)
const testPageSize = 10
const showTestDetailDialog = ref(false)
const currentTest = ref<any | null>(null)
@@ -682,18 +624,6 @@ const rawTests = computed<any[]>(() => {
return (u.testList || []) as any[]
})
const testTableData = computed(() =>
rawTests.value.map(t => ({
...t,
summary: extractTestSummary(t)
}))
)
const paginatedTests = computed(() => {
const start = (testPage.value - 1) * testPageSize
return testTableData.value.slice(start, start + testPageSize)
})
const currentTestType = computed(() => (currentTest.value?.testType || '').toLowerCase())
const userCards = ref<Array<{ type: string; name: string; total: number; active: number; tested: number; enterpriseId?: number }>>([
@@ -988,10 +918,6 @@ function handlePageChange() {
loadUsers()
}
function handleTestPageChange(page: number) {
testPage.value = page
}
function exportData() {
ElMessage.info('导出功能开发中')
}
@@ -1042,7 +968,6 @@ async function loadDetailUser(userId: number) {
}
async function handleView(row: any) {
testPage.value = 1
detailUser.value = null
showDetailDialog.value = true
@@ -1060,7 +985,6 @@ function handleViewTest(row: any) {
async function handleClickTestTag(row: any, testType: string) {
// 仅加载用户测试记录,弹出单次测试详情对话框并显示加载动画
testPage.value = 1
currentTest.value = null
showTestDetailDialog.value = true
testDetailLoading.value = true

View File

@@ -142,6 +142,12 @@ class Dashboard extends BaseController
$topTestUsers = $this->buildTopTestUsers($enterpriseId, 10);
$testCatalog = $this->buildTestCatalog($enterpriseId);
$distributionMbti = $this->aggregateTestLabels($enterpriseId, 'mbti', 14);
$distributionDisc = $this->aggregateTestLabels($enterpriseId, 'disc', 12);
$distributionPdp = $this->aggregateTestLabels($enterpriseId, 'pdp', 12);
$faceSubtypeHints = $this->aggregateFaceSubtypeHints($enterpriseId, 8);
return success([
'totalUsers' => $totalUsers,
'testsCompleted' => $testsCompleted,
@@ -149,6 +155,11 @@ class Dashboard extends BaseController
'pendingReviews' => $pendingReviews,
'testTrends' => $trendData,
'topTestUsers' => $topTestUsers,
'testCatalog' => $testCatalog,
'distributionMbti' => $distributionMbti,
'distributionDisc' => $distributionDisc,
'distributionPdp' => $distributionPdp,
'faceSubtypeHints' => $faceSubtypeHints,
]);
} catch (\Exception $e) {
return error('获取统计数据失败:' . $e->getMessage(), 500);
@@ -248,6 +259,148 @@ class Dashboard extends BaseController
return $out;
}
/**
* 四类测评完成人次 / 参与人数(本企业口径)
*
* @return array<int, array{key:string,label:string,records:int,uniqueUsers:int}>
*/
private function buildTestCatalog(?int $enterpriseId): array
{
$defs = [
['key' => 'face', 'label' => '人脸分析'],
['key' => 'mbti', 'label' => 'MBTI'],
['key' => 'disc', 'label' => 'DISC'],
['key' => 'pdp', 'label' => 'PDP'],
];
$out = [];
foreach ($defs as $def) {
$tt = $def['key'];
$q = Db::name('test_results')->where('testType', $tt);
if ($enterpriseId) {
$q->where('enterpriseId', $enterpriseId);
}
$records = (int) $q->count();
$q2 = Db::name('test_results')->where('testType', $tt);
if ($enterpriseId) {
$q2->where('enterpriseId', $enterpriseId);
}
$uniqueUsers = (int) $q2->distinct(true)->count('userId');
$out[] = [
'key' => $tt,
'label' => $def['label'],
'records' => $records,
'uniqueUsers' => $uniqueUsers,
];
}
return $out;
}
/**
* 按结果标签聚合单类测评(与列表摘要同一解析逻辑)
*
* @return array<int, array{label:string,count:int}>
*/
private function aggregateTestLabels(?int $enterpriseId, string $testType, int $topN): array
{
$counts = [];
$query = Db::name('test_results')
->where('testType', $testType)
->field('id,resultData');
if ($enterpriseId) {
$query->where('enterpriseId', $enterpriseId);
}
$query->chunk(400, function ($rows) use (&$counts, $testType) {
foreach ($rows as $row) {
$raw = $row['resultData'] ?? '';
$label = $this->labelFromResultRow($testType, $raw);
if ($label === '') {
$label = '未识别';
}
$counts[$label] = ($counts[$label] ?? 0) + 1;
}
});
arsort($counts);
return $this->countsToTopNWithOther($counts, $topN);
}
/**
* 人脸结果中推测的 MBTI / DISC / PDP 标签分布(辅助「面相」侧报告)
*
* @return array{mbti:array,disc:array,pdp:array}
*/
private function aggregateFaceSubtypeHints(?int $enterpriseId, int $topN): array
{
$subMaps = ['mbti' => [], 'disc' => [], 'pdp' => []];
$query = Db::name('test_results')
->where('testType', 'face')
->field('id,resultData');
if ($enterpriseId) {
$query->where('enterpriseId', $enterpriseId);
}
$query->chunk(400, function ($rows) use (&$subMaps) {
foreach ($rows as $row) {
$raw = $row['resultData'] ?? '';
$str = is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE);
if ($str === '' || $str === 'null') {
continue;
}
foreach (['mbti', 'disc', 'pdp'] as $sub) {
$label = $this->extractFaceSubType([['testType' => 'face', 'result' => $str]], $sub);
if ($label === '') {
continue;
}
$subMaps[$sub][$label] = ($subMaps[$sub][$label] ?? 0) + 1;
}
}
});
$out = [];
foreach ($subMaps as $k => $counts) {
arsort($counts);
$out[$k] = $this->countsToTopNWithOther($counts, $topN);
}
return $out;
}
/**
* @param array<string,int> $counts
* @return array<int, array{label:string,count:int}>
*/
private function countsToTopNWithOther(array $counts, int $topN): array
{
$topN = min(max($topN, 1), 50);
$items = [];
$i = 0;
$other = 0;
foreach ($counts as $label => $c) {
$c = (int) $c;
if ($i < $topN) {
$items[] = ['label' => (string) $label, 'count' => $c];
$i++;
} else {
$other += $c;
}
}
if ($other > 0) {
$items[] = ['label' => '其他', 'count' => $other];
}
return $items;
}
private function labelFromResultRow(string $testType, $raw): string
{
$str = is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE);
if ($str === '' || $str === 'null') {
return '';
}
return $this->extractResultType([['testType' => $testType, 'result' => $str]], $testType);
}
/**
* 格式化时间
* @param int $timestamp

View File

@@ -626,6 +626,9 @@ PROMPT;
if (!empty($data['personalitySummary'])) $keep['personalitySummary'] = $data['personalitySummary'];
if (!empty($data['faceAnalysis'])) $keep['faceAnalysis'] = $data['faceAnalysis'];
if (!empty($data['boneAnalysis'])) $keep['boneAnalysis'] = $data['boneAnalysis'];
if (!empty($data['careerDevelopment'])) $keep['careerDevelopment'] = $data['careerDevelopment'];
if (!empty($data['familyParenting'])) $keep['familyParenting'] = $data['familyParenting'];
if (!empty($data['partnerCofounder'])) $keep['partnerCofounder'] = $data['partnerCofounder'];
return $keep;
case 'mbti':
@@ -917,12 +920,15 @@ PROMPT;
. "6. 骨相分析结合《冰鉴》八骨约100字\n"
. "7. 主要优势3个关键词\n"
. "8. 性格概述50字以内\n"
. "9. 人际关系与团队合作风格50字以内\n";
. "9. 人际关系与团队合作风格50字以内\n"
. "10. 职业发展方向分析80字以内结合 MBTI 给出路径与阶段建议)\n"
. "11. 家庭与亲子关系分析80字以内沟通与期待管理\n"
. "12. 寻找合伙人/合作搭档分析80字以内互补、分工与风险点\n";
if ($isEnterprise) {
$analysisItems .= "10. 职业画像核心优势3项、潜在风险2项、一句话工作风格\n"
. "11. HR视角最适合岗位3个、不适合场景2个、入职/试用/成长期预测、绩效潜力(高潜/中潜/稳健)、合规风险(低/中/高)、团队适配建议\n"
. "12. 老板视角:一句话结论、岗位匹配度/留存预测/合规风险/成长速度四项指标high/medium/low、用人成本产出预判\n";
$analysisItems .= "13. 职业画像核心优势3项、潜在风险2项、一句话工作风格\n"
. "14. HR视角最适合岗位3个、不适合场景2个、入职/试用/成长期预测、绩效潜力(高潜/中潜/稳健)、合规风险(低/中/高)、团队适配建议\n"
. "15. 老板视角:一句话结论、岗位匹配度/留存预测/合规风险/成长速度四项指标high/medium/low、用人成本产出预判\n";
}
$defaultJsonTemplate = $analysisItems;
@@ -934,7 +940,8 @@ PROMPT;
. 'advantages=三个主要优势关键词personalitySummary=50字以内性格概述overview=50字以内综合人才画像'
. 'faceAnalysis=面相五官详细描述(额头/眼睛/耳朵/鼻子/嘴巴/下巴约100字'
. 'boneAnalysis=《冰鉴》八骨骨相描述(颧骨/驿马骨/将军骨/日角骨/月角骨/龙宫骨/伏犀骨/龙角骨约100字'
. 'relationship=人际关系与团队合作风格约50字gallupTop3=盖洛普前三大优势主题名称' . "\n"
. 'relationship=人际关系与团队合作风格约50字gallupTop3=盖洛普前三大优势主题名称'
. 'careerDevelopment=职业发展方向分析约80字familyParenting=家庭与亲子关系分析约80字partnerCofounder=寻找合伙人合作搭档分析约80字。' . "\n"
. '【第一步-人脸检测】先判断图片中是否有清晰可见的人脸:'
. '若无人脸/图片模糊/非人像,只返回 {"hasFace":false},不要其他内容。'
. '若检测到清晰人脸,直接给出结论,返回以下完整 JSON所有字段必填参考示例格式'
@@ -944,7 +951,10 @@ PROMPT;
. '"faceAnalysis":"额头宽阔平整,眼神专注深邃,耳廓厚实饱满,鼻头圆润有肉,嘴唇紧闭有力,下巴方正坚毅,整体气质沉稳内敛",'
. '"boneAnalysis":"颧骨适度有权势,驿马骨平稳利于坚守,将军骨有力主领导,日角骨平整主贵气,月角骨匀称主柔韧,龙宫骨丰隆主聪慧,伏犀骨突显主谋略,龙角骨匀称主志向",'
. '"relationship":"人际关系中注重深度交流,团队中承担规划与执行角色,重承诺守规则",'
. '"gallupTop3":["执行","责任","分析"]}'
. '"gallupTop3":["执行","责任","分析"],'
. '"careerDevelopment":"职业上宜走专业纵深或项目管理路线,前几年夯实流程与协作,中期可争取主导关键模块或小团队。",'
. '"familyParenting":"亲子沟通宜多倾听少评判,给孩子清晰边界也保留讨论空间;避免把职场高标准直接迁移为对孩子的苛责。",'
. '"partnerCofounder":"合伙宜找执行力或对外沟通互补型,股权与分工尽早书面化,退出机制与决策规则写清比口头默契更稳。"}'
. "\n【重要】只返回 JSON 对象,不得有任何额外文字、注释或 markdown 代码块。";
// 企业版在个人版基础上追加 portrait / hrView / bossView / resumeHighlights 字段说明和示例
@@ -954,6 +964,7 @@ PROMPT;
. 'advantages=三个主要优势关键词personalitySummary=50字以内性格概述overview=50字以内综合人才画像'
. 'faceAnalysis=面相五官详细描述约100字boneAnalysis=《冰鉴》八骨骨相描述约100字'
. 'relationship=人际关系与团队合作风格约50字gallupTop3=盖洛普前三大优势主题名称,'
. 'careerDevelopment=职业发展方向分析约80字familyParenting=家庭与亲子关系分析约80字partnerCofounder=寻找合伙人合作搭档分析约80字'
. 'portrait=职业画像coreStrengths/coreRisks/workStyle'
. 'hrView=HR视角roleRecommend/lifecycle/performance/complianceRisk/teamFit'
. 'bossView=老板视角headline/metrics/costInsight'
@@ -968,6 +979,9 @@ PROMPT;
. '"boneAnalysis":"颧骨适度有权势,驿马骨平稳利于坚守,将军骨有力主领导,日角骨平整主贵气,月角骨匀称主柔韧,龙宫骨丰隆主聪慧,伏犀骨突显主谋略,龙角骨匀称主志向",'
. '"relationship":"人际关系中注重深度交流,团队中承担规划与执行角色,重承诺守规则",'
. '"gallupTop3":["执行","责任","分析"],'
. '"careerDevelopment":"职业上宜走专业纵深或项目管理路线,前几年夯实流程与协作,中期可争取主导关键模块或小团队。",'
. '"familyParenting":"亲子沟通宜多倾听少评判,给孩子清晰边界也保留讨论空间;避免把职场高标准直接迁移为对孩子的苛责。",'
. '"partnerCofounder":"合伙宜找执行力或对外沟通互补型,股权与分工尽早书面化,退出机制与决策规则写清比口头默契更稳。",'
. '"portrait":{"coreStrengths":["执行力强","逻辑缜密","责任心高"],"coreRisks":["灵活性不足","沟通偏封闭"],"workStyle":"偏独立作战,需要清晰目标和充分授权"},'
. '"hrView":{"roleRecommend":{"bestFit":["项目管理","运营执行","技术主管"],"notSuitable":["高创意策划","销售BD"]},'
. '"lifecycle":{"onboarding":"适应期约1-2个月建议配备清晰的工作手册","probation":"试用期执行稳定,交付质量高","growth":"6-12个月可承担独立模块负责人","retention":"核心留人因素是稳定的工作环境与晋升通道"},'
@@ -1334,6 +1348,9 @@ PROMPT;
'personalitySummary' => $p['personalitySummary'] ?? '',
'relationship' => $p['relationship'] ?? '',
'gallupTop3' => $gallupTop3,
'careerDevelopment' => isset($p['careerDevelopment']) ? trim((string) $p['careerDevelopment']) : '',
'familyParenting' => isset($p['familyParenting']) ? trim((string) $p['familyParenting']) : '',
'partnerCofounder' => isset($p['partnerCofounder']) ? trim((string) $p['partnerCofounder']) : '',
];
// 企业版额外字段AI 返回则透传,未返回则不输出

View File

@@ -281,6 +281,7 @@ class Test extends BaseController
$resultText = '';
$emoji = '';
$typeName = '';
$gallupPreview = '';
switch ($testType) {
case 'mbti':
@@ -313,7 +314,17 @@ class Test extends BaseController
break;
}
return [
if (in_array($testType, ['face', 'ai'], true)) {
$g = $data['gallupTop3'] ?? null;
if (is_array($g) && $g !== []) {
$slice = array_slice($g, 0, 3);
$gallupPreview = implode('、', array_map(static function ($x) {
return (string) $x;
}, $slice));
}
}
$out = [
'id' => (int) $row['id'],
'testType' => ($testType === 'face') ? 'ai' : $testType,
'emoji' => $emoji,
@@ -323,6 +334,11 @@ class Test extends BaseController
'isPaid' => (int) ($row['isPaid'] ?? 0),
'requiresPayment' => (int) ($row['requiresPayment'] ?? 0),
];
if ($gallupPreview !== '') {
$out['gallupPreview'] = $gallupPreview;
}
return $out;
}
/**
@@ -620,6 +636,9 @@ class Test extends BaseController
$out['hrView'] = null;
$out['bossView'] = null;
$out['resumeHighlights'] = '';
$out['careerDevelopment'] = '';
$out['familyParenting'] = '';
$out['partnerCofounder'] = '';
if (isset($out['careers'])) {
$out['careers'] = [];
}

View File

@@ -448,9 +448,185 @@ class AppUser extends BaseController
$data['faceDiscType'] = $this->extractFaceSubType($tests, 'disc');
$data['facePdpType'] = $this->extractFaceSubType($tests, 'pdp');
$data['matchingEnterprises'] = $this->buildMatchingEnterprises(
(int) $id,
(string) ($data['mbtiType'] ?? ''),
(string) ($data['pdpType'] ?? ''),
(string) ($data['discType'] ?? '')
);
return success($data);
}
/**
* 按企业测评池内与用户 MBTI/PDP/DISC 的同质比例推荐企业,并附带登记负责人联系方式。
* 仅超级管理后台使用;无测评维度时按池内活跃人数近似排序。
*
* @return array<int, array<string, mixed>>
*/
private function buildMatchingEnterprises(int $userId, string $userMbti, string $userPdp, string $userDisc): array
{
$userMbtiU = strtoupper(preg_replace('/[^A-Z]/', '', $userMbti));
$userPdpN = $this->normalizePoolTypeKey($userPdp);
$userDiscN = $this->normalizePoolTypeKey($userDisc);
try {
$entRows = Db::name('enterprises')
->whereNull('deletedAt')
->whereIn('status', ['operating', 'trial'])
->field('id,name,code,contactName,contactPhone,contactEmail,status')
->select()
->toArray();
} catch (\Throwable $e) {
return [];
}
if (!$entRows) {
return [];
}
$scored = [];
foreach ($entRows as $e) {
$eid = (int) ($e['id'] ?? 0);
if ($eid <= 0) {
continue;
}
$mbtiS = $this->enterprisePoolTypeHistogram($eid, 'mbti');
$pdpS = $this->enterprisePoolTypeHistogram($eid, 'pdp');
$discS = $this->enterprisePoolTypeHistogram($eid, 'disc');
$score = 36;
$reasons = [];
if ($userMbtiU !== '' && $mbtiS['total'] > 0) {
$hit = (int) ($mbtiS['byKey'][$userMbtiU] ?? 0);
$ratio = $hit / $mbtiS['total'];
$part = (int) round(44 * $ratio);
$score += $part;
$pct = (int) round($ratio * 100);
$reasons[] = 'MBTI 同质 ' . $pct . '%(池内 ' . $mbtiS['total'] . ' 人有效结果)';
}
if ($userPdpN !== '' && $pdpS['total'] > 0) {
$hit = (int) ($pdpS['byKey'][$userPdpN] ?? 0);
$ratio = $hit / $pdpS['total'];
$score += (int) round(12 * $ratio);
if ($ratio > 0) {
$reasons[] = 'PDP 同质 ' . (int) round($ratio * 100) . '%';
}
}
if ($userDiscN !== '' && $discS['total'] > 0) {
$hit = (int) ($discS['byKey'][$userDiscN] ?? 0);
$ratio = $hit / $discS['total'];
$score += (int) round(12 * $ratio);
if ($ratio > 0) {
$reasons[] = 'DISC 同质 ' . (int) round($ratio * 100) . '%';
}
}
$tested = max($mbtiS['total'], $pdpS['total'], $discS['total']);
if ($userMbtiU === '' && $userPdpN === '' && $userDiscN === '') {
$score = 40 + (int) min(38, $tested * 2);
$reasons[] = $tested > 0 ? '按池内测评活跃度推荐' : '暂无同质维度,展示登记企业';
}
$score = max(30, min(99, $score));
$typeLabel = '综合型';
if ($userMbtiU !== '' && $mbtiS['total'] > 0 && (($mbtiS['byKey'][$userMbtiU] ?? 0) / $mbtiS['total']) >= 0.25) {
$typeLabel = '文化相近MBTI 分布)';
} elseif ($userPdpN !== '' && $pdpS['total'] > 0) {
$typeLabel = '行为风格相近PDP 分布)';
} elseif ($userDiscN !== '' && $discS['total'] > 0) {
$typeLabel = '协作风格相近DISC 分布)';
}
$scored[] = [
'id' => $eid,
'name' => (string) ($e['name'] ?? ''),
'code' => (string) ($e['code'] ?? ''),
'contactName' => (string) ($e['contactName'] ?? ''),
'contactPhone' => (string) ($e['contactPhone'] ?? ''),
'contactEmail' => (string) ($e['contactEmail'] ?? ''),
'status' => (string) ($e['status'] ?? ''),
'matchScore' => $score,
'matchTypeLabel' => $typeLabel,
'matchReason' => $reasons ? implode('', $reasons) : '可与负责人沟通用人匹配',
'poolTestedUsers' => $tested,
];
}
usort($scored, static function ($a, $b) {
return ($b['matchScore'] ?? 0) <=> ($a['matchScore'] ?? 0);
});
return array_slice($scored, 0, 10);
}
/**
* 企业池内各用户对某测评类型的「最新一条」结果类型分布
*
* @return array{total:int,byKey:array<string,int>}
*/
private function enterprisePoolTypeHistogram(int $enterpriseId, string $testType): array
{
$targetType = strtolower($testType);
try {
$rows = Db::name('test_results')
->where('enterpriseId', $enterpriseId)
->where('testType', $testType)
->order('createdAt', 'desc')
->field('userId,resultData')
->select()
->toArray();
} catch (\Throwable $e) {
return ['total' => 0, 'byKey' => []];
}
$seen = [];
$byKey = [];
foreach ($rows as $r) {
$uid = (int) ($r['userId'] ?? 0);
if ($uid <= 0 || isset($seen[$uid])) {
continue;
}
$raw = $r['resultData'] ?? '';
$result = is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE);
$mock = [['result' => $result, 'testType' => $testType]];
$label = $this->extractResultType($mock, $testType);
if ($label === '' || ($targetType === 'face' && $label === '人脸分析')) {
continue;
}
$key = $targetType === 'mbti'
? strtoupper(preg_replace('/[^A-Z]/', '', $label))
: $this->normalizePoolTypeKey($label);
if ($key === '') {
continue;
}
$seen[$uid] = true;
$byKey[$key] = ($byKey[$key] ?? 0) + 1;
}
$totalTyped = array_sum($byKey);
return ['total' => $totalTyped, 'byKey' => $byKey];
}
private function normalizePoolTypeKey(string $s): string
{
$s = trim($s);
if ($s === '') {
return '';
}
$s = str_replace([' ', ' '], '', $s);
return mb_strtolower($s, 'UTF-8');
}
private function parseMbtiFromResult($result): string
{
if (!is_string($result)) return '';

View File

@@ -1,7 +1,47 @@
// pages/index/result.js - 分析结果页
const app = getApp()
const payment = require('../../utils/payment')
const { hasPhone, bindPhoneByCode, ensureProfileCompleteAndRedirect } = require('../../utils/phoneAuth.js')
const { hasPhone, bindPhoneByCode, isProfileComplete } = require('../../utils/phoneAuth.js')
const { mbtiDescriptions } = require('../../utils/descriptions')
function buildSceneFallback(baseResult) {
const mbti = baseResult.mbti || ''
const d = mbtiDescriptions[mbti] || {}
const jobs = (d.careers || []).slice(0, 2).join('、') || '专业岗位'
const name = d.name || baseResult.title || ''
return {
careerDevelopment: `结合 ${mbti}${name ? '' + name + '' : ''} 特质,${jobs} 等与系统性、长期主义更契合的路径往往更容易做出成绩;建议前 13 年夯实基本功与协作习惯,再向骨干或专家角色过渡。`,
familyParenting: '家庭互动中可减少「对错评判」、增加情感确认;给孩子清晰边界的同时也留出讨论与试错空间,更利于信任感与自驱力。',
partnerCofounder: '寻找合伙人时建议重点考察责任感与信息透明度,角色分工、决策机制与退出规则尽量书面化;互补型搭档通常比同特质堆叠更有效。'
}
}
function mergeSceneBlocks(apiData, baseResult) {
const trim = (s) => (s && String(s).trim()) || ''
const apiC = trim(apiData.careerDevelopment)
const apiF = trim(apiData.familyParenting)
const apiP = trim(apiData.partnerCofounder)
const fa = apiData.faceAnalysis
const bone = apiData.boneAnalysis
const rel = typeof apiData.relationship === 'string' ? apiData.relationship.trim() : ''
const hasUnlockedReport =
rel.length > 8 ||
(typeof fa === 'string' && fa.length > 40) ||
(fa && typeof fa === 'object' && !Array.isArray(fa)) ||
(typeof apiData.faceAnalysisText === 'string' && apiData.faceAnalysisText.length > 40) ||
(typeof bone === 'string' && bone.length > 40) ||
(bone && typeof bone === 'object' && !Array.isArray(bone)) ||
(typeof apiData.boneAnalysisText === 'string' && apiData.boneAnalysisText.length > 40)
if (!hasUnlockedReport) {
return { careerDevelopment: '', familyParenting: '', partnerCofounder: '' }
}
const fb = buildSceneFallback(baseResult)
return {
careerDevelopment: apiC || fb.careerDevelopment,
familyParenting: apiF || fb.familyParenting,
partnerCofounder: apiP || fb.partnerCofounder
}
}
Page({
data: {
@@ -42,7 +82,10 @@ Page({
portrait: null,
hrView: null,
bossView: null,
resumeHighlights: ''
resumeHighlights: '',
careerDevelopment: '',
familyParenting: '',
partnerCofounder: ''
},
// 当前这次AI分析对应的测试记录IDmbti_test_results.id
testResultId: null,
@@ -50,6 +93,7 @@ Page({
hasReloadedAfterPay: false,
// 是否已在本地拥有手机号(决定是否还需要弹出微信手机号授权)
hasPhone: false,
isProfileComplete: false,
analyzingTitle: '正在分析中',
reportTitle: '分析报告',
aiAnalysisText: '分析',
@@ -138,8 +182,7 @@ Page({
},
onShow() {
if (!ensureProfileCompleteAndRedirect()) return
this.setData({ hasPhone: hasPhone() })
this.setData({ hasPhone: hasPhone(), isProfileComplete: isProfileComplete() })
const tc = app.globalData.textConfig
if (tc) {
this.setData({
@@ -244,7 +287,7 @@ Page({
// 处理API返回结果
processResult(apiData) {
const result = {
const base = {
mbti: apiData.mbti?.type || '',
title: apiData.mbti?.title || '',
summary: apiData.personalitySummary || apiData.overview || '',
@@ -258,7 +301,12 @@ Page({
boneAnalysisText: typeof apiData.boneAnalysis === 'string' ? apiData.boneAnalysis : '',
careers: Array.isArray(apiData.careers) ? apiData.careers : [],
relationship: apiData.relationship || '',
gallupTop3: Array.isArray(apiData.gallupTop3) ? apiData.gallupTop3 : [],
gallupTop3: Array.isArray(apiData.gallupTop3) ? apiData.gallupTop3 : []
}
const scene = mergeSceneBlocks(apiData, base)
const result = {
...base,
...scene,
faceAnalysis: null,
boneAnalysis: null,
portrait: apiData.portrait || null,
@@ -289,17 +337,20 @@ Page({
// 优先使用后端 /api/analyze 返回的价格信息,避免二次请求
if (apiData._payment) {
const p = apiData._payment || {}
let amountYuan = typeof p.amountYuan === 'number'
? p.amountYuan
: (p.amountFen ? (p.amountFen / 100) : 0)
if (p.requiresPayment && amountYuan <= 0) amountYuan = 1
this.setData({
payInfo: {
requiresPayment: !!p.requiresPayment,
isPaid: false,
amountYuan: typeof p.amountYuan === 'number'
? p.amountYuan
: (p.amountFen ? (p.amountFen / 100) : 0)
amountYuan
}
})
} else {
this.initPayInfoFromRuntime()
}
// 无 _payment 时(如从历史详情进入)不在这里调 initPayInfoFromRuntime避免异步 getRuntimeConfig 后覆盖详情接口返回的 needPaymentToUnlock由调用方用 detail 的 payload 单独设置 payInfo
},
@@ -328,7 +379,9 @@ Page({
requiresPayment: needPay,
isPaid: !!detailPayload.isPaid,
amountYuan: needPay ? amountYuan : 0
}
},
hasPhone: hasPhone(),
isProfileComplete: isProfileComplete()
})
return
}
@@ -342,18 +395,24 @@ Page({
: Number(facePriceRaw || 0)
const requiresByConfig = !!(reportRequires && reportRequires.face)
const requiresPayment =
let requiresPayment =
typeof recordRequires === 'boolean' ? recordRequires : requiresByConfig
// 系统设置需付款但金额为0 则直接可查看,不展示付费墙
const needPay = requiresPayment && facePrice > 0
const amountYuan = facePrice > 0 ? facePrice : 0
let amountYuan = facePrice > 0 ? facePrice : 0
let needPay = requiresPayment && amountYuan > 0
if (requiresByConfig && !needPay) {
amountYuan = 1
needPay = true
requiresPayment = true
}
this.setData({
payInfo: {
requiresPayment: needPay,
isPaid: !!recordIsPaid,
amountYuan
}
amountYuan: needPay ? amountYuan : 0
},
hasPhone: hasPhone(),
isProfileComplete: isProfileComplete()
})
})
.catch(() => {
@@ -362,11 +421,21 @@ Page({
requiresPayment: false,
isPaid: !!recordIsPaid,
amountYuan: 0
}
},
hasPhone: hasPhone(),
isProfileComplete: isProfileComplete()
})
})
},
goToCompleteProfile() {
tt.navigateTo({ url: '/pages/user-profile/index' })
},
onTapUnlockPay() {
this.unlockFullReport()
},
// 解锁完整报告:发起人脸测试付费
unlockFullReport() {
const { payInfo, testResultId, hasReloadedAfterPay } = this.data
@@ -384,12 +453,12 @@ Page({
success: () => {
tt.showToast({ title: '已解锁完整报告', icon: 'success' })
// 本地先标记已付费,避免按钮仍然提示“需要解锁”
this.setData({
'payInfo.isPaid': true
'payInfo.isPaid': true,
hasPhone: hasPhone(),
isProfileComplete: isProfileComplete()
})
// 避免重复触发刷新:只在还没刷新的情况下,延迟 0.5s 拉一次详情
if (testResultId && !hasReloadedAfterPay) {
this.setData({ hasReloadedAfterPay: true })
setTimeout(() => {
@@ -397,41 +466,27 @@ Page({
}, 500)
}
},
fail: () => {
// 支付失败或取消,这里不做额外处理
}
fail: () => {}
})
})
},
// AI结果页付费按钮就地触发微信手机号授权然后调用 unlockFullReport
onGetPhoneNumberForFacePay(e) {
if (!ensureProfileCompleteAndRedirect()) return
onPostPayBindPhone(e) {
const { code, errMsg } = e.detail || {}
if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) {
if (!hasPhone()) {
tt.showToast({ title: '需要授权手机号才能继续', icon: 'none' })
return
}
this.unlockFullReport()
tt.showToast({ title: '需要手机号以便保存报告', icon: 'none' })
return
}
if (!code) {
if (hasPhone()) {
this.unlockFullReport()
} else {
tt.showToast({ title: '获取手机号失败', icon: 'none' })
}
tt.showToast({ title: '获取手机号失败', icon: 'none' })
return
}
bindPhoneByCode(code)
.then(() => {
this.setData({ hasPhone: true })
this.unlockFullReport()
})
.catch(() => {
// 保持在当前页,等待用户重新点击
this.setData({ hasPhone: true, isProfileComplete: isProfileComplete() })
tt.showToast({ title: '已绑定手机号', icon: 'success' })
})
.catch(() => {})
},
// 解锁成功后根据测试记录ID重新拉取完整详情

View File

@@ -44,6 +44,20 @@
<text class="status-text">{{hasError ? '分析出现异常' : '分析完成,以下是您的个性化报告'}}</text>
</view>
<view class="post-pay-guide" tt:if="{{!hasError && payInfo.isPaid && (!hasPhone || !isProfileComplete)}}">
<text class="post-pay-guide-title">再完成两步,报告更好归属到您的账号</text>
<text class="post-pay-guide-desc">① 授权手机号 ② 完善头像与昵称(「我的」- 个人资料)</text>
<view class="post-pay-guide-actions">
<button
tt:if="{{!hasPhone}}"
class="post-pay-guide-btn phone"
open-type="getPhoneNumber"
bindgetphonenumber="onPostPayBindPhone"
>授权手机号</button>
<view tt:if="{{hasPhone && !isProfileComplete}}" class="post-pay-guide-btn profile" bindtap="goToCompleteProfile">去完善资料</view>
</view>
</view>
<!-- 错误内容 -->
<view class="error-content" tt:if="{{hasError}}">
<text class="error-title">错误信息</text>
@@ -56,22 +70,29 @@
<!-- 性格类型分析卡片 -->
<view class="card personality-card" tt:if="{{!hasError}}">
<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>
<view class="type-chip-row" tt:if="{{result.pdp || result.disc}}">
<text class="type-chip" tt:if="{{result.pdp}}">{{result.pdpEmoji}}{{result.pdp}}</text>
<text class="type-chip type-chip--disc" tt:if="{{result.disc}}">DISC {{result.disc}}</text>
</view>
<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>
<text class="type-label">PDP主性格</text>
<text class="type-value">{{result.pdp || '--'}}</text>
<text class="type-sub" tt:if="{{result.pdpAux}}">辅助: {{result.pdpAux}}</text>
</view>
<view class="type-box disc-box">
<text class="type-emoji type-emoji--disc">◎</text>
<text class="type-label">DISC类型</text>
<text class="type-value">{{result.disc || '--'}}型</text>
<text class="type-sub" tt:if="{{result.discAux}}">辅助: {{result.discAux}}</text>
@@ -111,37 +132,60 @@
</view>
</view>
<!-- 付费解锁完整报告(不展示虚拟假数据) -->
<!-- 未解锁:三大场景标题预览 -->
<view class="card scene-teaser-bundle" tt:if="{{!hasError && payInfo.requiresPayment && !payInfo.isPaid}}">
<text class="scene-bundle-hint">以下版块解锁后展示完整正文</text>
<view class="scene-teaser-item">
<text class="scene-teaser-title">职业发展方向</text>
<view class="scene-teaser-lines">
<view class="scene-teaser-line"></view>
<view class="scene-teaser-line scene-teaser-line--short"></view>
</view>
</view>
<view class="scene-teaser-item">
<text class="scene-teaser-title">家庭亲子关系</text>
<view class="scene-teaser-lines">
<view class="scene-teaser-line"></view>
<view class="scene-teaser-line scene-teaser-line--short"></view>
</view>
</view>
<view class="scene-teaser-item scene-teaser-item--last">
<text class="scene-teaser-title">寻找合伙人</text>
<view class="scene-teaser-lines">
<view class="scene-teaser-line"></view>
<view class="scene-teaser-line scene-teaser-line--short"></view>
</view>
</view>
</view>
<!-- 付费解锁:先支付,再引导手机号与资料 -->
<view class="card paywall-card" tt:if="{{!hasError && payInfo.requiresPayment && !payInfo.isPaid}}">
<view class="paywall-content">
<text class="paywall-fake-title">完整版性格深度解析</text>
<text class="paywall-tip">解锁后将展示完整报告内容</text>
<!-- 中间解锁按钮:未有手机号时就地触发微信手机号授权 -->
<button
class="paywall-btn"
tt:if="{{!hasPhone}}"
open-type="getPhoneNumber"
bindgetphonenumber="onGetPhoneNumberForFacePay"
>
<text class="paywall-btn-main">解锁完整报告</text>
<text class="paywall-btn-price">¥{{payInfo.amountYuan || 0}} / 次</text>
</button>
<!-- 已有手机号时:普通按钮,直接解锁 -->
<button
class="paywall-btn"
tt:elif="{{hasPhone}}"
bindtap="unlockFullReport"
>
<text class="paywall-btn-main">解锁完整报告</text>
<text class="paywall-btn-price">¥{{payInfo.amountYuan || 0}} / 次</text>
</button>
<text class="paywall-tip">一次性解锁本次{{reportTitle || '分析报告'}},永久保存在「历史记录」中</text>
<text class="paywall-fake-title">完整版深度解析</text>
<text class="paywall-tip">含面相、骨相、人际与职业 / 家庭 / 合伙三大场景</text>
<view class="paywall-btn paywall-btn--compact" bindtap="onTapUnlockPay">
<text class="paywall-btn-main">{{payInfo.amountYuan || 1}} 元解锁</text>
</view>
<text class="paywall-tip">解锁后可在「历史记录」再次查看;绑定手机与资料见上方绿色提示条</text>
</view>
</view>
<!-- ========== 详细分析区(解锁后可见) ========== -->
<block tt:if="{{!hasError && (!payInfo.requiresPayment || payInfo.isPaid)}}">
<view class="card scene-detail-card" tt:if="{{result.careerDevelopment}}">
<text class="card-title">职业发展方向</text>
<text class="card-text">{{result.careerDevelopment}}</text>
</view>
<view class="card scene-detail-card" tt:if="{{result.familyParenting}}">
<text class="card-title">家庭亲子关系</text>
<text class="card-text">{{result.familyParenting}}</text>
</view>
<view class="card scene-detail-card" tt:if="{{result.partnerCofounder}}">
<text class="card-title">寻找合伙人</text>
<text class="card-text">{{result.partnerCofounder}}</text>
</view>
<!-- 面相分析(审核模式隐藏) -->
<block tt:if="{{!reviewMode && result.faceAnalysis}}">
<view class="card" tt:if="{{result.faceAnalysis.facialFeatures && result.faceAnalysis.facialFeatures.length > 0}}">
@@ -282,18 +326,5 @@
</block>
<!-- 操作按钮 -->
<view class="action-section" tt:if="{{!hasError}}">
<button class="btn btn-primary" open-type="share">
<text class="btn-text">分享我的分析报告</text>
</button>
<view class="btn btn-outline" bindtap="retake">
<text class="btn-text-outline">重新分析</text>
</view>
<view class="btn btn-outline" bindtap="goHome">
<text class="btn-text-outline">返回首页</text>
</view>
</view>
</view>
</view>

View File

@@ -202,6 +202,8 @@
.paywall-content {
position: relative;
padding: 32rpx 24rpx 36rpx;
box-sizing: border-box;
}
.paywall-blur {
@@ -254,6 +256,21 @@
gap: 12rpx;
}
/* 新版:按钮在文档流内(非 absolute */
.paywall-card .paywall-content .paywall-btn {
position: relative;
left: auto;
right: auto;
bottom: auto;
width: 100%;
margin-top: 28rpx;
flex-direction: column;
align-items: center;
gap: 10rpx;
padding: 28rpx 20rpx;
border-radius: 999rpx;
}
.paywall-btn-main {
font-size: 30rpx;
color: #fff;
@@ -273,11 +290,176 @@
text-align: center;
}
.paywall-card .paywall-content .paywall-btn--compact {
flex-direction: row !important;
justify-content: center !important;
padding: 32rpx 40rpx !important;
}
.paywall-btn--compact .paywall-btn-main {
font-size: 34rpx;
letter-spacing: 2rpx;
}
.scene-teaser-bundle {
text-align: left;
padding-bottom: 28rpx !important;
}
.scene-bundle-hint {
display: block;
font-size: 22rpx;
color: #94a3b8;
margin-bottom: 20rpx;
}
.scene-teaser-item {
padding: 20rpx 0;
border-bottom: 1rpx solid #f1f5f9;
}
.scene-teaser-item--last {
border-bottom: none;
padding-bottom: 8rpx;
}
.scene-teaser-title {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #334155;
margin-bottom: 16rpx;
}
.scene-teaser-lines {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.scene-teaser-line {
height: 14rpx;
border-radius: 8rpx;
background: linear-gradient(90deg, #e2e8f0 0%, #f1f5f9 100%);
width: 100%;
}
.scene-teaser-line--short {
width: 62%;
}
.scene-detail-card {
border-left: 6rpx solid #e63946;
padding-left: 28rpx !important;
}
.post-pay-guide {
margin: 16rpx 24rpx 0;
padding: 24rpx 28rpx;
background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%);
border-radius: 20rpx;
border: 1rpx solid rgba(16, 185, 129, 0.35);
}
.post-pay-guide-title {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #065f46;
margin-bottom: 8rpx;
}
.post-pay-guide-desc {
display: block;
font-size: 24rpx;
color: #047857;
line-height: 1.45;
margin-bottom: 20rpx;
}
.post-pay-guide-actions {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.post-pay-guide-btn.phone {
margin: 0;
padding: 0 36rpx;
height: 72rpx;
line-height: 72rpx;
font-size: 28rpx;
font-weight: 600;
color: #fff;
background: linear-gradient(135deg, #10b981 0%, #34d399 100%);
border-radius: 36rpx;
border: none;
}
.post-pay-guide-btn.phone::after {
border: none;
}
.post-pay-guide-btn.profile {
padding: 0 36rpx;
height: 72rpx;
line-height: 72rpx;
font-size: 28rpx;
font-weight: 600;
color: #065f46;
background: #fff;
border-radius: 36rpx;
border: 2rpx solid #10b981;
text-align: center;
}
/* ========== 性格类型卡片 ========== */
.personality-card {
text-align: center;
}
.personality-card-sub {
display: block;
font-size: 22rpx;
color: #94a3b8;
margin: -8rpx 0 24rpx;
letter-spacing: 1rpx;
}
.type-chip-row {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12rpx;
margin: 12rpx 0 8rpx;
}
.type-chip {
font-size: 22rpx;
color: #64748b;
background: #f8fafc;
border: 1rpx solid #e2e8f0;
padding: 8rpx 20rpx;
border-radius: 999rpx;
}
.type-chip--disc {
color: #2563eb;
background: #eff6ff;
border-color: #bfdbfe;
}
.type-emoji {
display: block;
font-size: 36rpx;
margin-bottom: 4rpx;
}
.type-emoji--disc {
opacity: 0.35;
font-size: 28rpx;
}
.personality-type {
margin-bottom: 32rpx;
}

View File

@@ -14,6 +14,7 @@ Page({
discType: '',
pdpType: '',
aiType: '',
gallupPreview: '',
mbtiTime: '',
discTime: '',
pdpTime: '',
@@ -158,6 +159,7 @@ Page({
// DISC resultText 后端已含「型」type badge 只显示字母,去掉「型」
const discType = r.disc ? r.disc.resultText.replace(/型$/, '') : ''
const gallupPreview = (r.ai && r.ai.gallupPreview) ? String(r.ai.gallupPreview) : ''
this.setData({
testCount: totalCount,
hasResults: !!(r.mbti || r.disc || r.pdp || r.ai),
@@ -165,6 +167,7 @@ Page({
discType,
pdpType: r.pdp ? r.pdp.resultText : '',
aiType: r.ai ? r.ai.resultText : '',
gallupPreview,
mbtiTime: r.mbti ? r.mbti.testTime : '',
discTime: r.disc ? r.disc.testTime : '',
pdpTime: r.pdp ? r.pdp.testTime : '',
@@ -209,6 +212,11 @@ Page({
const pdpResult = tt.getStorageSync('pdpResult')
const aiResult = tt.getStorageSync('aiResult')
let gallupPreview = ''
if (aiResult && Array.isArray(aiResult.gallupTop3) && aiResult.gallupTop3.length) {
gallupPreview = aiResult.gallupTop3.slice(0, 3).map(String).join('、')
}
let testCount = 0
if (mbtiResult) testCount++
if (discResult) testCount++
@@ -228,7 +236,8 @@ Page({
mbtiType: mbtiResult ? getTypeOnly(mbtiResult, 'mbti') : '',
discType: discResult ? getTypeOnly(discResult, 'disc') : '',
pdpType: pdpResult ? getTypeOnly(pdpResult, 'pdp') : '',
aiType: aiResult ? (aiResult.mbtiType || aiResult.type || '') : '',
aiType: aiResult ? (aiResult.mbti || aiResult.mbtiType || aiResult.type || '') : '',
gallupPreview,
mbtiTime: _fmt(mbtiResult && (mbtiResult.createdAt || mbtiResult.timestamp || mbtiResult.testTime)),
discTime: _fmt(discResult && (discResult.createdAt || discResult.timestamp || discResult.testTime)),
pdpTime: _fmt(pdpResult && (pdpResult.createdAt || pdpResult.timestamp || pdpResult.testTime)),
@@ -281,19 +290,32 @@ Page({
},
viewMBTI() {
const id = this.data.mbtiResultId
tt.navigateTo({ url: id ? `/pages/result/mbti?id=${id}&type=mbti` : '/pages/result/mbti' })
if (id) tt.navigateTo({ url: `/pages/result/mbti?id=${id}&type=mbti` })
else tt.navigateTo({ url: '/pages/test/mbti' })
},
viewDISC() {
const id = this.data.discResultId
tt.navigateTo({ url: id ? `/pages/result/disc?id=${id}&type=disc` : '/pages/result/disc' })
if (id) tt.navigateTo({ url: `/pages/result/disc?id=${id}&type=disc` })
else tt.navigateTo({ url: '/pages/test/disc' })
},
viewPDP() {
const id = this.data.pdpResultId
tt.navigateTo({ url: id ? `/pages/result/pdp?id=${id}&type=pdp` : '/pages/result/pdp' })
if (id) tt.navigateTo({ url: `/pages/result/pdp?id=${id}&type=pdp` })
else tt.navigateTo({ url: '/pages/test/pdp' })
},
viewGallup() {
const id = this.data.aiResultId
if (id) tt.navigateTo({ url: `/pages/index/result?id=${id}&type=ai` })
else tt.switchTab({ url: '/pages/index/camera' })
},
viewAI() {
const id = this.data.aiResultId
tt.navigateTo({ url: id ? `/pages/index/result?id=${id}&type=ai` : '/pages/index/result' })
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' })
},
logout() {

View File

@@ -78,52 +78,70 @@
<text class="depth-header-chevron"></text>
</view>
</view>
<block tt:if="{{hasResults}}">
<scroll-view scroll-x class="cards-scroll cards-scroll--in-card" enhanced show-scrollbar="{{false}}">
<view class="cards-row cards-row--in-card">
<view class="result-card card-purple" tt:if="{{mbtiType}}" bindtap="viewMBTI">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-purple">
<text class="card-icon">🧠</text>
</view>
<text class="card-label">MBTI性格</text>
<text class="card-value">{{mbtiType}}</text>
<text class="card-time">{{mbtiTime}}</text>
</view>
<view class="result-card card-blue" tt:if="{{discType}}" bindtap="viewDISC">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-blue">
<text class="card-icon">📊</text>
</view>
<text class="card-label">DISC测评</text>
<text class="card-value">{{discType}}型</text>
<text class="card-time">{{discTime}}</text>
</view>
<view class="result-card card-orange" tt:if="{{pdpType}}" bindtap="viewPDP">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-orange">
<text class="card-icon">🦁</text>
</view>
<text class="card-label">PDP行为</text>
<text class="card-value">{{pdpType}}</text>
<text class="card-time">{{pdpTime}}</text>
</view>
<view class="result-card card-rose" tt:if="{{aiType && !reviewMode}}" bindtap="viewAI">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-rose">
<text class="card-icon">👁️</text>
</view>
<text class="card-label">面相分析</text>
<text class="card-value">{{aiType}}</text>
<text class="card-time">{{aiTime}}</text>
<scroll-view scroll-x class="cards-scroll cards-scroll--in-card" enhanced show-scrollbar="{{false}}">
<view class="cards-row cards-row--in-card">
<view class="result-card card-purple {{mbtiType ? '' : 'result-card--placeholder'}}" bindtap="viewMBTI">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-purple">
<text class="card-icon">🧠</text>
</view>
<text class="card-label">MBTI性格</text>
<text class="card-value">{{mbtiType || '未测评'}}</text>
<text class="card-time">{{mbtiType ? mbtiTime : '点击进入问卷'}}</text>
</view>
</scroll-view>
</block>
<view tt:else class="depth-empty-hint">
<text>完成任一测评后,此处展示最近一次结果;右上角可查看全部历史。</text>
<view class="result-card card-orange {{pdpType ? '' : 'result-card--placeholder'}}" bindtap="viewPDP">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-orange">
<text class="card-icon">🦁</text>
</view>
<text class="card-label">PDP行为</text>
<text class="card-value">{{pdpType || '未测评'}}</text>
<text class="card-time">{{pdpType ? pdpTime : '点击进入问卷'}}</text>
</view>
<view class="result-card card-blue {{discType ? '' : 'result-card--placeholder'}}" bindtap="viewDISC">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-blue">
<text class="card-icon">📊</text>
</view>
<text class="card-label">DISC测评</text>
<text class="card-value">{{discType ? discType + '型' : '未测评'}}</text>
<text class="card-time">{{discType ? discTime : '点击进入问卷'}}</text>
</view>
<view class="result-card card-teal {{(gallupPreview || aiType) ? '' : 'result-card--placeholder'}}" bindtap="viewGallup" tt:if="{{!reviewMode}}">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-teal">
<text class="card-icon">⭐</text>
</view>
<text class="card-label">盖洛普优势</text>
<text class="card-value card-value--small">{{gallupPreview || (aiType ? '见面相报告' : '未测评')}}</text>
<text class="card-time">{{gallupPreview ? aiTime : (aiType ? '已拍面相可查看' : '先完成面相拍摄')}}</text>
</view>
<view class="result-card card-rose {{aiType ? '' : 'result-card--placeholder'}}" bindtap="viewAI" tt:if="{{!reviewMode}}">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-rose">
<text class="card-icon">👁️</text>
</view>
<text class="card-label">面相分析</text>
<text class="card-value">{{aiType || '未测评'}}</text>
<text class="card-time">{{aiType ? aiTime : '去拍摄分析'}}</text>
</view>
</view>
</scroll-view>
<view class="depth-empty-hint depth-empty-hint--compact">
<text>灰色为未完成项,点击即可进入测评或拍摄;右上方可查看全部历史。</text>
</view>
<view class="depth-inner-divider"></view>
<view class="menu-item menu-item--flat" bindtap="goToActionTest" tt:if="{{!reviewMode}}">
<view class="menu-icon-wrap menu-icon-amber">
<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="{{!reviewMode}}"></view>
<view class="menu-item menu-item--flat" bindtap="goToDeepService">
<view class="menu-icon-wrap menu-icon-purple">
<text class="menu-icon">✨</text>

View File

@@ -317,6 +317,11 @@ page {
line-height: 1.55;
}
.depth-empty-hint--compact {
padding-top: 4rpx;
padding-bottom: 16rpx;
}
.depth-inner-divider {
height: 1rpx;
background: #F3F4F6;
@@ -383,6 +388,29 @@ page {
flex-shrink: 0;
}
.result-card--placeholder {
background: #f3f4f6 !important;
border-color: #e5e7eb !important;
box-shadow: none !important;
}
.result-card--placeholder .card-deco {
opacity: 0.12 !important;
}
.result-card--placeholder .card-label,
.result-card--placeholder .card-time {
color: #9ca3af !important;
}
.result-card--placeholder .card-value {
color: #6b7280 !important;
}
.result-card--placeholder .card-icon-wrap {
opacity: 0.65;
}
.card-deco {
position: absolute;
top: -24rpx;
@@ -397,6 +425,7 @@ page {
.card-blue .card-deco { background: #BFDBFE; }
.card-orange .card-deco { background: #FDE68A; }
.card-rose .card-deco { background: #FECDD3; }
.card-teal .card-deco { background: #99f6e4; }
.card-icon-wrap {
width: 64rpx;
@@ -411,6 +440,7 @@ page {
.card-icon-blue { background: #DBEAFE; }
.card-icon-orange { background: #FEF3C7; }
.card-icon-rose { background: #FFE4E6; }
.card-icon-teal { background: #ccfbf1; }
.card-icon {
font-size: 32rpx;
@@ -432,6 +462,17 @@ page {
.card-blue .card-value { color: #2563EB; }
.card-orange .card-value { color: #D97706; }
.card-rose .card-value { color: #E11D48; }
.card-teal .card-value { color: #0f766e; }
.card-value--small {
font-size: 26rpx !important;
font-weight: 700 !important;
line-height: 1.35;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.card-time {
font-size: 20rpx;

View File

@@ -2,6 +2,47 @@
const app = getApp()
const payment = require('../../utils/payment')
const { hasPhone, bindPhoneByCode, isProfileComplete } = require('../../utils/phoneAuth.js')
const { mbtiDescriptions } = require('../../utils/descriptions')
function buildSceneFallback(baseResult) {
const mbti = baseResult.mbti || ''
const d = mbtiDescriptions[mbti] || {}
const jobs = (d.careers || []).slice(0, 2).join('、') || '专业岗位'
const name = d.name || baseResult.title || ''
return {
careerDevelopment: `结合 ${mbti}${name ? '' + name + '' : ''} 特质,${jobs} 等与系统性、长期主义更契合的路径往往更容易做出成绩;建议前 13 年夯实基本功与协作习惯,再向骨干或专家角色过渡。`,
familyParenting: '家庭互动中可减少「对错评判」、增加情感确认;给孩子清晰边界的同时也留出讨论与试错空间,更利于信任感与自驱力。',
partnerCofounder: '寻找合伙人时建议重点考察责任感与信息透明度,角色分工、决策机制与退出规则尽量书面化;互补型搭档通常比同特质堆叠更有效。'
}
}
/** 仅在后端已下发完整面相等字段时合并;预览脱敏接口不下发,避免未付费用户看到正文 */
function mergeSceneBlocks(apiData, baseResult) {
const trim = (s) => (s && String(s).trim()) || ''
const apiC = trim(apiData.careerDevelopment)
const apiF = trim(apiData.familyParenting)
const apiP = trim(apiData.partnerCofounder)
const fa = apiData.faceAnalysis
const bone = apiData.boneAnalysis
const rel = typeof apiData.relationship === 'string' ? apiData.relationship.trim() : ''
const hasUnlockedReport =
rel.length > 8 ||
(typeof fa === 'string' && fa.length > 40) ||
(fa && typeof fa === 'object' && !Array.isArray(fa)) ||
(typeof apiData.faceAnalysisText === 'string' && apiData.faceAnalysisText.length > 40) ||
(typeof bone === 'string' && bone.length > 40) ||
(bone && typeof bone === 'object' && !Array.isArray(bone)) ||
(typeof apiData.boneAnalysisText === 'string' && apiData.boneAnalysisText.length > 40)
if (!hasUnlockedReport) {
return { careerDevelopment: '', familyParenting: '', partnerCofounder: '' }
}
const fb = buildSceneFallback(baseResult)
return {
careerDevelopment: apiC || fb.careerDevelopment,
familyParenting: apiF || fb.familyParenting,
partnerCofounder: apiP || fb.partnerCofounder
}
}
const { getEnterpriseIdForApiPayload } = require('../../utils/enterpriseContext.js')
Page({
@@ -43,7 +84,10 @@ Page({
portrait: null,
hrView: null,
bossView: null,
resumeHighlights: ''
resumeHighlights: '',
careerDevelopment: '',
familyParenting: '',
partnerCofounder: ''
},
// 当前这次AI分析对应的测试记录IDmbti_test_results.id
testResultId: null,
@@ -237,7 +281,7 @@ Page({
// 处理API返回结果
processResult(apiData) {
const result = {
const base = {
mbti: apiData.mbti?.type || '',
title: apiData.mbti?.title || '',
summary: apiData.personalitySummary || apiData.overview || '',
@@ -251,7 +295,12 @@ Page({
boneAnalysisText: typeof apiData.boneAnalysis === 'string' ? apiData.boneAnalysis : '',
careers: Array.isArray(apiData.careers) ? apiData.careers : [],
relationship: apiData.relationship || '',
gallupTop3: Array.isArray(apiData.gallupTop3) ? apiData.gallupTop3 : [],
gallupTop3: Array.isArray(apiData.gallupTop3) ? apiData.gallupTop3 : []
}
const scene = mergeSceneBlocks(apiData, base)
const result = {
...base,
...scene,
faceAnalysis: null,
boneAnalysis: null,
portrait: apiData.portrait || null,
@@ -282,17 +331,21 @@ Page({
// 优先使用后端 /api/analyze 返回的价格信息,避免二次请求
if (apiData._payment) {
const p = apiData._payment || {}
let amountYuan = typeof p.amountYuan === 'number'
? p.amountYuan
: (p.amountFen ? (p.amountFen / 100) : 0)
if (p.requiresPayment && amountYuan <= 0) amountYuan = 1
this.setData({
payInfo: {
requiresPayment: !!p.requiresPayment,
isPaid: false,
amountYuan: typeof p.amountYuan === 'number'
? p.amountYuan
: (p.amountFen ? (p.amountFen / 100) : 0)
amountYuan
}
})
} else {
// 拍照直连分析未带 _payment 时,拉 runtime 定价(与历史详情入口互不覆盖)
this.initPayInfoFromRuntime()
}
// 无 _payment 时(如从历史详情进入)不在这里调 initPayInfoFromRuntime避免异步 getRuntimeConfig 后覆盖详情接口返回的 needPaymentToUnlock由调用方用 detail 的 payload 单独设置 payInfo
},
@@ -322,6 +375,7 @@ Page({
isPaid: !!detailPayload.isPaid,
amountYuan: needPay ? amountYuan : 0
},
hasPhone: hasPhone(),
isProfileComplete: isProfileComplete()
})
return
@@ -336,18 +390,25 @@ Page({
: Number(facePriceRaw || 0)
const requiresByConfig = !!(reportRequires && reportRequires.face)
const requiresPayment =
let requiresPayment =
typeof recordRequires === 'boolean' ? recordRequires : requiresByConfig
// 系统设置需付款但金额为0 则直接可查看,不展示付费墙
const needPay = requiresPayment && facePrice > 0
const amountYuan = facePrice > 0 ? facePrice : 0
let amountYuan = facePrice > 0 ? facePrice : 0
let needPay = requiresPayment && amountYuan > 0
// 后台开启人脸报告付费但未配有效单价时,按 ¥1 展示并走支付
if (requiresByConfig && !needPay) {
amountYuan = 1
needPay = true
requiresPayment = true
}
this.setData({
payInfo: {
requiresPayment: needPay,
isPaid: !!recordIsPaid,
amountYuan
}
amountYuan: needPay ? amountYuan : 0
},
hasPhone: hasPhone(),
isProfileComplete: isProfileComplete()
})
})
.catch(() => {
@@ -356,7 +417,9 @@ Page({
requiresPayment: false,
isPaid: !!recordIsPaid,
amountYuan: 0
}
},
hasPhone: hasPhone(),
isProfileComplete: isProfileComplete()
})
})
},
@@ -365,10 +428,14 @@ Page({
wx.navigateTo({ url: '/pages/user-profile/index' })
},
// 解锁完整报告:发起人脸测试付费
/** 付费墙主按钮:先支付,支付后再引导手机号与资料 */
onTapUnlockPay() {
this.unlockFullReport()
},
// 解锁完整报告:发起人脸测试付费(不要求先完善资料)
unlockFullReport() {
const { payInfo, testResultId, hasReloadedAfterPay, isProfileComplete } = this.data
if (!isProfileComplete) return
const { payInfo, testResultId, hasReloadedAfterPay } = this.data
if (!payInfo.requiresPayment || payInfo.isPaid) return
app.ensureLogin().then((logged) => {
@@ -381,12 +448,12 @@ Page({
success: () => {
wx.showToast({ title: '已解锁完整报告', icon: 'success' })
// 本地先标记已付费,避免按钮仍然提示“需要解锁”
this.setData({
'payInfo.isPaid': true
'payInfo.isPaid': true,
hasPhone: hasPhone(),
isProfileComplete: isProfileComplete()
})
// 避免重复触发刷新:只在还没刷新的情况下,延迟 0.5s 拉一次详情
if (testResultId && !hasReloadedAfterPay) {
this.setData({ hasReloadedAfterPay: true })
setTimeout(() => {
@@ -394,14 +461,31 @@ Page({
}, 500)
}
},
fail: () => {
// 支付失败或取消,这里不做额外处理
}
fail: () => {}
})
})
},
// AI结果页付费按钮就地触发微信手机号授权然后调用 unlockFullReport
/** 支付成功后:仅绑定手机号,不重复发起支付 */
onPostPayBindPhone(e) {
const { code, errMsg } = e.detail || {}
if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) {
wx.showToast({ title: '需要手机号以便保存报告', icon: 'none' })
return
}
if (!code) {
wx.showToast({ title: '获取手机号失败', icon: 'none' })
return
}
bindPhoneByCode(code)
.then(() => {
this.setData({ hasPhone: true, isProfileComplete: isProfileComplete() })
wx.showToast({ title: '已绑定手机号', icon: 'success' })
})
.catch(() => {})
},
// 兼容旧版:付费前绑手机(现流程已改为先付费,此方法可不再使用)
onGetPhoneNumberForFacePay(e) {
const { code, errMsg } = e.detail || {}
if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) {

View File

@@ -44,6 +44,21 @@
<text class="status-text">{{hasError ? '分析出现异常' : '分析完成,以下是您的个性化报告'}}</text>
</view>
<!-- 支付成功后:引导手机号 → 完善资料(不挡报告阅读) -->
<view class="post-pay-guide" wx:if="{{!hasError && payInfo.isPaid && (!hasPhone || !isProfileComplete)}}">
<text class="post-pay-guide-title">再完成两步,报告更好归属到您的账号</text>
<text class="post-pay-guide-desc">① 授权手机号 ② 完善头像与昵称(「我的」- 个人资料)</text>
<view class="post-pay-guide-actions">
<button
wx:if="{{!hasPhone}}"
class="post-pay-guide-btn phone"
open-type="getPhoneNumber"
bindgetphonenumber="onPostPayBindPhone"
>授权手机号</button>
<view wx:if="{{hasPhone && !isProfileComplete}}" class="post-pay-guide-btn profile" bindtap="goToCompleteProfile">去完善资料</view>
</view>
</view>
<!-- 错误内容 -->
<view class="error-content" wx:if="{{hasError}}">
<text class="error-title">错误信息</text>
@@ -56,23 +71,30 @@
<!-- 报告预览区:未解锁时整区加盖高遮罩,避免概述/盖洛普等全文外露 -->
<view class="report-gate-wrap" wx:if="{{!hasError}}">
<view
class="card personality-card {{(!isProfileComplete || (isProfileComplete && payInfo.requiresPayment && !payInfo.isPaid)) ? 'personality-card--locked' : ''}}"
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>
<view class="type-details">
<view class="type-box pdp-box">
<text class="type-emoji" wx:if="{{result.pdpEmoji}}">{{result.pdpEmoji}}</text>
<text class="type-label">PDP主性格</text>
<text class="type-value">{{result.pdp || '--'}}</text>
<text class="type-sub" wx:if="{{result.pdpAux}}">辅助: {{result.pdpAux}}</text>
</view>
<view class="type-box disc-box">
<text class="type-emoji type-emoji--disc">◎</text>
<text class="type-label">DISC类型</text>
<text class="type-value">{{result.disc || '--'}}型</text>
<text class="type-sub" wx:if="{{result.discAux}}">辅助: {{result.discAux}}</text>
@@ -95,7 +117,7 @@
</view>
<view
class="card report-gate-teaser {{(!isProfileComplete || (isProfileComplete && payInfo.requiresPayment && !payInfo.isPaid)) ? 'report-gate-teaser--locked' : ''}}"
class="card report-gate-teaser {{(payInfo.requiresPayment && !payInfo.isPaid) ? 'report-gate-teaser--locked' : ''}}"
wx:if="{{result.summary}}"
>
<text class="card-title">性格概述</text>
@@ -103,7 +125,7 @@
</view>
<view
class="card gallup-card report-gate-teaser {{(!isProfileComplete || (isProfileComplete && payInfo.requiresPayment && !payInfo.isPaid)) ? 'report-gate-teaser--locked' : ''}}"
class="card gallup-card report-gate-teaser {{(payInfo.requiresPayment && !payInfo.isPaid) ? 'report-gate-teaser--locked' : ''}}"
wx:if="{{result.gallupTop3.length > 0}}"
>
<text class="card-title">盖洛普前三大优势</text>
@@ -115,51 +137,67 @@
</view>
</view>
<!-- 未解锁:三大生活场景仅标题 + 占位行(正文付费后见) -->
<view class="card scene-teaser-bundle" wx:if="{{payInfo.requiresPayment && !payInfo.isPaid}}">
<text class="scene-bundle-hint">以下版块解锁后展示完整正文</text>
<view class="scene-teaser-item">
<text class="scene-teaser-title">职业发展方向</text>
<view class="scene-teaser-lines">
<view class="scene-teaser-line"></view>
<view class="scene-teaser-line scene-teaser-line--short"></view>
</view>
</view>
<view class="scene-teaser-item">
<text class="scene-teaser-title">家庭亲子关系</text>
<view class="scene-teaser-lines">
<view class="scene-teaser-line"></view>
<view class="scene-teaser-line scene-teaser-line--short"></view>
</view>
</view>
<view class="scene-teaser-item scene-teaser-item--last">
<text class="scene-teaser-title">寻找合伙人</text>
<view class="scene-teaser-lines">
<view class="scene-teaser-line"></view>
<view class="scene-teaser-line scene-teaser-line--short"></view>
</view>
</view>
</view>
<!-- 全幅遮罩 + 底部操作(覆盖性格概述、盖洛普等主要预览,仅留顶部少量「悬念」) -->
<view
class="report-gate-overlay"
wx:if="{{!isProfileComplete || (isProfileComplete && payInfo.requiresPayment && !payInfo.isPaid)}}"
wx:if="{{payInfo.requiresPayment && !payInfo.isPaid}}"
>
<view class="report-gate-gradient"></view>
<!-- 底部白区内:标题 + 说明 + 按钮作为一组上下居中 -->
<view class="report-gate-cta-wrap">
<view class="report-gate-panel">
<text class="paywall-fake-title">完整版性格深度解析</text>
<text class="paywall-tip">解锁后将展示完整报告内容</text>
<view
class="paywall-btn paywall-btn-inline"
wx:if="{{!isProfileComplete}}"
bindtap="goToCompleteProfile"
>
<text class="paywall-btn-main">完善资料</text>
<text class="paywall-fake-title">完整版深度解析</text>
<text class="paywall-tip">含面相、骨相、人际与职业 / 家庭 / 合伙三大场景</text>
<view class="paywall-btn paywall-btn-inline paywall-btn--compact" bindtap="onTapUnlockPay">
<text class="paywall-btn-main">{{payInfo.amountYuan || 1}} 元解锁</text>
</view>
<block wx:elif="{{isProfileComplete && payInfo.requiresPayment && !payInfo.isPaid}}">
<button
class="paywall-btn paywall-btn-inline"
wx:if="{{!hasPhone}}"
open-type="getPhoneNumber"
bindgetphonenumber="onGetPhoneNumberForFacePay"
>
<text class="paywall-btn-main">解锁完整报告</text>
<text class="paywall-btn-price">¥{{payInfo.amountYuan || 0}} / 次</text>
</button>
<view
class="paywall-btn paywall-btn-inline"
wx:elif="{{hasPhone}}"
bindtap="unlockFullReport"
>
<text class="paywall-btn-main">解锁完整报告</text>
<text class="paywall-btn-price">¥{{payInfo.amountYuan || 0}} / 次</text>
</view>
</block>
<text class="paywall-tip paywall-tip-panel" wx:if="{{isProfileComplete}}">一次性解锁本次{{reportTitle || '分析报告'}},永久保存在「历史记录」中</text>
<text class="paywall-tip paywall-tip-panel">解锁后可在「历史记录」再次查看;绑定手机与资料见上方绿色提示条</text>
</view>
</view>
</view>
</view>
<!-- ========== 详细分析区(解锁后可见) ========== -->
<block wx:if="{{!hasError && isProfileComplete && (!payInfo.requiresPayment || payInfo.isPaid)}}">
<block wx:if="{{!hasError && (!payInfo.requiresPayment || payInfo.isPaid)}}">
<!-- 生活场景深度分析AI 或本地兜底) -->
<view class="card scene-detail-card" wx:if="{{result.careerDevelopment}}">
<text class="card-title">职业发展方向</text>
<text class="card-text">{{result.careerDevelopment}}</text>
</view>
<view class="card scene-detail-card" wx:if="{{result.familyParenting}}">
<text class="card-title">家庭亲子关系</text>
<text class="card-text">{{result.familyParenting}}</text>
</view>
<view class="card scene-detail-card" wx:if="{{result.partnerCofounder}}">
<text class="card-title">寻找合伙人</text>
<text class="card-text">{{result.partnerCofounder}}</text>
</view>
<!-- 面相分析 -->
<block wx:if="{{result.faceAnalysis}}">
@@ -304,18 +342,5 @@
</view>
</block>
<!-- 操作按钮 -->
<view class="action-section" wx:if="{{!hasError}}">
<button class="btn btn-primary" open-type="share">
<text class="btn-text">分享我的分析报告</text>
</button>
<view class="btn btn-outline" bindtap="retake">
<text class="btn-text-outline">重新分析</text>
</view>
<view class="btn btn-outline" bindtap="goHome">
<text class="btn-text-outline">返回首页</text>
</view>
</view>
</view>
</view>

View File

@@ -323,17 +323,79 @@
top: 0;
bottom: 0;
pointer-events: none;
/* 高透明白雾,避免大块灰底发闷 */
/* 约前 40% 区域保持清晰预览,以下渐强为「约 60% 可读」观感 */
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.22) 10%,
rgba(255, 255, 255, 0.48) 28%,
rgba(255, 255, 255, 0.72) 48%,
rgba(255, 255, 255, 0.88) 100%
rgba(255, 255, 255, 0) 38%,
rgba(255, 255, 255, 0.2) 52%,
rgba(255, 255, 255, 0.55) 68%,
rgba(255, 255, 255, 0.82) 88%,
rgba(255, 255, 255, 0.92) 100%
);
}
/* 支付成功后:手机号 / 资料引导条 */
.post-pay-guide {
margin: 16rpx 24rpx 0;
padding: 24rpx 28rpx;
background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%);
border-radius: 20rpx;
border: 1rpx solid rgba(16, 185, 129, 0.35);
}
.post-pay-guide-title {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #065f46;
margin-bottom: 8rpx;
}
.post-pay-guide-desc {
display: block;
font-size: 24rpx;
color: #047857;
line-height: 1.45;
margin-bottom: 20rpx;
}
.post-pay-guide-actions {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.post-pay-guide-btn.phone {
margin: 0;
padding: 0 36rpx;
height: 72rpx;
line-height: 72rpx;
font-size: 28rpx;
font-weight: 600;
color: #fff;
background: linear-gradient(135deg, #10b981 0%, #34d399 100%);
border-radius: 36rpx;
border: none;
}
.post-pay-guide-btn.phone::after {
border: none;
}
.post-pay-guide-btn.profile {
padding: 0 36rpx;
height: 72rpx;
line-height: 72rpx;
font-size: 28rpx;
font-weight: 600;
color: #065f46;
background: #fff;
border-radius: 36rpx;
border: 2rpx solid #10b981;
text-align: center;
}
.report-gate-panel {
position: relative;
z-index: 2;
@@ -362,23 +424,102 @@
max-width: 640rpx;
margin: 28rpx auto 0;
box-sizing: border-box;
display: flex !important;
flex-direction: column !important;
align-items: center !important;
padding: 28rpx 24rpx !important;
}
.paywall-btn-inline .paywall-btn-price {
display: block;
width: 100%;
text-align: center;
margin-top: 10rpx;
font-size: 22rpx;
line-height: 1.45;
font-weight: 400;
}
/* 主按钮单行:仅金额 +「元解锁」 */
.paywall-btn--compact {
flex-direction: row !important;
justify-content: center !important;
padding: 32rpx 40rpx !important;
}
.paywall-btn--compact .paywall-btn-main {
font-size: 34rpx;
letter-spacing: 2rpx;
}
.report-gate-panel .paywall-btn-inline::after {
border: none;
}
/* 未解锁:三大场景标题预览 */
.scene-teaser-bundle {
text-align: left;
padding-bottom: 28rpx !important;
}
.scene-bundle-hint {
display: block;
font-size: 22rpx;
color: #94a3b8;
margin-bottom: 20rpx;
}
.scene-teaser-item {
padding: 20rpx 0;
border-bottom: 1rpx solid #f1f5f9;
}
.scene-teaser-item--last {
border-bottom: none;
padding-bottom: 8rpx;
}
.scene-teaser-title {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #334155;
margin-bottom: 16rpx;
}
.scene-teaser-lines {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.scene-teaser-line {
height: 14rpx;
border-radius: 8rpx;
background: linear-gradient(90deg, #e2e8f0 0%, #f1f5f9 100%);
width: 100%;
}
.scene-teaser-line--short {
width: 62%;
}
.scene-detail-card {
border-left: 6rpx solid #e63946;
padding-left: 28rpx !important;
}
/* 未解锁:限制概述与盖洛普可见行数 */
.report-gate-teaser--locked .card-text {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 5;
-webkit-line-clamp: 8;
overflow: hidden;
text-overflow: ellipsis;
}
.report-gate-teaser--locked .gallup-list {
max-height: 96rpx;
max-height: 140rpx;
overflow: hidden;
position: relative;
}
@@ -428,6 +569,48 @@
text-align: center;
}
.personality-card-sub {
display: block;
font-size: 22rpx;
color: #94a3b8;
margin: -8rpx 0 24rpx;
letter-spacing: 1rpx;
}
.type-chip-row {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12rpx;
margin: 12rpx 0 8rpx;
}
.type-chip {
font-size: 22rpx;
color: #64748b;
background: #f8fafc;
border: 1rpx solid #e2e8f0;
padding: 8rpx 20rpx;
border-radius: 999rpx;
}
.type-chip--disc {
color: #2563eb;
background: #eff6ff;
border-color: #bfdbfe;
}
.type-emoji {
display: block;
font-size: 36rpx;
margin-bottom: 4rpx;
}
.type-emoji--disc {
opacity: 0.35;
font-size: 28rpx;
}
.personality-type {
margin-bottom: 32rpx;
}

View File

@@ -14,6 +14,8 @@ Page({
discType: '',
pdpType: '',
aiType: '',
/** 面相记录中的盖洛普前三摘要(/api/test/recent 或本地 aiResult */
gallupPreview: '',
mbtiTime: '',
discTime: '',
pdpTime: '',
@@ -158,6 +160,7 @@ Page({
// DISC resultText 后端已含「型」type badge 只显示字母,去掉「型」
const discType = r.disc ? r.disc.resultText.replace(/型$/, '') : ''
const gallupPreview = (r.ai && r.ai.gallupPreview) ? String(r.ai.gallupPreview) : ''
this.setData({
testCount: totalCount,
hasResults: !!(r.mbti || r.disc || r.pdp || r.ai),
@@ -165,6 +168,7 @@ Page({
discType,
pdpType: r.pdp ? r.pdp.resultText : '',
aiType: r.ai ? r.ai.resultText : '',
gallupPreview,
mbtiTime: r.mbti ? r.mbti.testTime : '',
discTime: r.disc ? r.disc.testTime : '',
pdpTime: r.pdp ? r.pdp.testTime : '',
@@ -209,6 +213,11 @@ Page({
const pdpResult = wx.getStorageSync('pdpResult')
const aiResult = wx.getStorageSync('aiResult')
let gallupPreview = ''
if (aiResult && Array.isArray(aiResult.gallupTop3) && aiResult.gallupTop3.length) {
gallupPreview = aiResult.gallupTop3.slice(0, 3).map(String).join('、')
}
let testCount = 0
if (mbtiResult) testCount++
if (discResult) testCount++
@@ -228,7 +237,8 @@ Page({
mbtiType: mbtiResult ? getTypeOnly(mbtiResult, 'mbti') : '',
discType: discResult ? getTypeOnly(discResult, 'disc') : '',
pdpType: pdpResult ? getTypeOnly(pdpResult, 'pdp') : '',
aiType: aiResult ? (aiResult.mbtiType || aiResult.type || '') : '',
aiType: aiResult ? (aiResult.mbti || aiResult.mbtiType || aiResult.type || '') : '',
gallupPreview,
mbtiTime: _fmt(mbtiResult && (mbtiResult.createdAt || mbtiResult.timestamp || mbtiResult.testTime)),
discTime: _fmt(discResult && (discResult.createdAt || discResult.timestamp || discResult.testTime)),
pdpTime: _fmt(pdpResult && (pdpResult.createdAt || pdpResult.timestamp || pdpResult.testTime)),
@@ -282,19 +292,34 @@ Page({
},
viewMBTI() {
const id = this.data.mbtiResultId
wx.navigateTo({ url: id ? `/pages/result/mbti?id=${id}&type=mbti` : '/pages/result/mbti' })
if (id) wx.navigateTo({ url: `/pages/result/mbti?id=${id}&type=mbti` })
else wx.navigateTo({ url: '/pages/test/mbti' })
},
viewDISC() {
const id = this.data.discResultId
wx.navigateTo({ url: id ? `/pages/result/disc?id=${id}&type=disc` : '/pages/result/disc' })
if (id) wx.navigateTo({ url: `/pages/result/disc?id=${id}&type=disc` })
else wx.navigateTo({ url: '/pages/test/disc' })
},
viewPDP() {
const id = this.data.pdpResultId
wx.navigateTo({ url: id ? `/pages/result/pdp?id=${id}&type=pdp` : '/pages/result/pdp' })
if (id) wx.navigateTo({ url: `/pages/result/pdp?id=${id}&type=pdp` })
else wx.navigateTo({ url: '/pages/test/pdp' })
},
/** 盖洛普随面相报告:有记录看报告,否则去拍摄 */
viewGallup() {
const id = this.data.aiResultId
if (id) wx.navigateTo({ url: `/pages/index/result?id=${id}&type=ai` })
else wx.switchTab({ url: '/pages/index/camera' })
},
viewAI() {
const id = this.data.aiResultId
wx.navigateTo({ url: id ? `/pages/index/result?id=${id}&type=ai` : '/pages/index/result' })
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' })
},
logout() {

View File

@@ -79,52 +79,70 @@
<text class="depth-header-chevron"></text>
</view>
</view>
<block wx:if="{{hasResults}}">
<scroll-view scroll-x class="cards-scroll cards-scroll--in-card" enhanced show-scrollbar="{{false}}">
<view class="cards-row cards-row--in-card">
<view class="result-card card-purple" wx:if="{{mbtiType}}" bindtap="viewMBTI">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-purple">
<text class="card-icon">🧠</text>
</view>
<text class="card-label">MBTI性格</text>
<text class="card-value">{{mbtiType}}</text>
<text class="card-time">{{mbtiTime}}</text>
</view>
<view class="result-card card-blue" wx:if="{{discType}}" bindtap="viewDISC">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-blue">
<text class="card-icon">📊</text>
</view>
<text class="card-label">DISC测评</text>
<text class="card-value">{{discType}}型</text>
<text class="card-time">{{discTime}}</text>
</view>
<view class="result-card card-orange" wx:if="{{pdpType}}" bindtap="viewPDP">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-orange">
<text class="card-icon">🦁</text>
</view>
<text class="card-label">PDP行为</text>
<text class="card-value">{{pdpType}}</text>
<text class="card-time">{{pdpTime}}</text>
</view>
<view class="result-card card-rose" wx:if="{{aiType && !reviewMode}}" bindtap="viewAI">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-rose">
<text class="card-icon">👁️</text>
</view>
<text class="card-label">面相分析</text>
<text class="card-value">{{aiType}}</text>
<text class="card-time">{{aiTime}}</text>
<scroll-view scroll-x class="cards-scroll cards-scroll--in-card" enhanced show-scrollbar="{{false}}">
<view class="cards-row cards-row--in-card">
<view class="result-card card-purple {{mbtiType ? '' : 'result-card--placeholder'}}" bindtap="viewMBTI">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-purple">
<text class="card-icon">🧠</text>
</view>
<text class="card-label">MBTI性格</text>
<text class="card-value">{{mbtiType || '未测评'}}</text>
<text class="card-time">{{mbtiType ? mbtiTime : '点击进入问卷'}}</text>
</view>
</scroll-view>
</block>
<view wx:else class="depth-empty-hint">
<text>完成任一测评后,此处展示最近一次结果;右上角可查看全部历史。</text>
<view class="result-card card-orange {{pdpType ? '' : 'result-card--placeholder'}}" bindtap="viewPDP">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-orange">
<text class="card-icon">🦁</text>
</view>
<text class="card-label">PDP行为</text>
<text class="card-value">{{pdpType || '未测评'}}</text>
<text class="card-time">{{pdpType ? pdpTime : '点击进入问卷'}}</text>
</view>
<view class="result-card card-blue {{discType ? '' : 'result-card--placeholder'}}" bindtap="viewDISC">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-blue">
<text class="card-icon">📊</text>
</view>
<text class="card-label">DISC测评</text>
<text class="card-value">{{discType ? discType + '型' : '未测评'}}</text>
<text class="card-time">{{discType ? discTime : '点击进入问卷'}}</text>
</view>
<view class="result-card card-teal {{(gallupPreview || aiType) ? '' : 'result-card--placeholder'}}" bindtap="viewGallup" wx:if="{{!reviewMode}}">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-teal">
<text class="card-icon">⭐</text>
</view>
<text class="card-label">盖洛普优势</text>
<text class="card-value card-value--small">{{gallupPreview || (aiType ? '见面相报告' : '未测评')}}</text>
<text class="card-time">{{gallupPreview ? aiTime : (aiType ? '已拍面相可查看' : '先完成面相拍摄')}}</text>
</view>
<view class="result-card card-rose {{aiType ? '' : 'result-card--placeholder'}}" bindtap="viewAI" wx:if="{{!reviewMode}}">
<view class="card-deco"></view>
<view class="card-icon-wrap card-icon-rose">
<text class="card-icon">👁️</text>
</view>
<text class="card-label">面相分析</text>
<text class="card-value">{{aiType || '未测评'}}</text>
<text class="card-time">{{aiType ? aiTime : '去拍摄分析'}}</text>
</view>
</view>
</scroll-view>
<view class="depth-empty-hint depth-empty-hint--compact">
<text>灰色为未完成项,点击即可进入测评或拍摄;右上方可查看全部历史。</text>
</view>
<view class="depth-inner-divider"></view>
<view class="menu-item menu-item--flat" bindtap="goToActionTest" wx:if="{{!reviewMode}}">
<view class="menu-icon-wrap menu-icon-amber">
<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="{{!reviewMode}}"></view>
<view class="menu-item menu-item--flat" bindtap="goToDeepService">
<view class="menu-icon-wrap menu-icon-purple">
<text class="menu-icon">✨</text>

View File

@@ -325,6 +325,11 @@ custom-tab-bar {
line-height: 1.55;
}
.depth-empty-hint--compact {
padding-top: 4rpx;
padding-bottom: 16rpx;
}
.depth-inner-divider {
height: 1rpx;
background: #F3F4F6;
@@ -391,6 +396,30 @@ custom-tab-bar {
flex-shrink: 0;
}
/* 未完成测评:灰阶可点击 */
.result-card--placeholder {
background: #f3f4f6 !important;
border-color: #e5e7eb !important;
box-shadow: none !important;
}
.result-card--placeholder .card-deco {
opacity: 0.12 !important;
}
.result-card--placeholder .card-label,
.result-card--placeholder .card-time {
color: #9ca3af !important;
}
.result-card--placeholder .card-value {
color: #6b7280 !important;
}
.result-card--placeholder .card-icon-wrap {
opacity: 0.65;
}
.card-deco {
position: absolute;
top: -24rpx;
@@ -405,6 +434,7 @@ custom-tab-bar {
.card-blue .card-deco { background: #BFDBFE; }
.card-orange .card-deco { background: #FDE68A; }
.card-rose .card-deco { background: #FECDD3; }
.card-teal .card-deco { background: #99f6e4; }
.card-icon-wrap {
width: 64rpx;
@@ -419,6 +449,7 @@ custom-tab-bar {
.card-icon-blue { background: #DBEAFE; }
.card-icon-orange { background: #FEF3C7; }
.card-icon-rose { background: #FFE4E6; }
.card-icon-teal { background: #ccfbf1; }
.card-icon {
font-size: 32rpx;
@@ -440,6 +471,17 @@ custom-tab-bar {
.card-blue .card-value { color: #2563EB; }
.card-orange .card-value { color: #D97706; }
.card-rose .card-value { color: #E11D48; }
.card-teal .card-value { color: #0f766e; }
.card-value--small {
font-size: 26rpx !important;
font-weight: 700 !important;
line-height: 1.35;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.card-time {
font-size: 20rpx;

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

View File

@@ -0,0 +1,7 @@
功能一:企业用户统计
![](images/2026-03-27-18-13-26.png)整个公司的那个用户数据的统计就是有测试的,做相应的分类,然后每个分类把图标都加上去,然后有多少人什么性格测试都统计到这个里面。然后企业的那个第一个统计数据就能看到这个那个统计的那个报告,对吧?就放到那个企业的统后台的管理,后台的那个统计数据单个
![](images/2026-03-27-18-13-55.png)然后你把这些数据的话,就包括刚刚的那些东西,统一的到数据概览,整合到数据概览里面,数据概览只有一张图,你就这样。