fix: 仪表盘与用户管理、分析/测试接口、双端结果页与个人中心展示调整
- admin:Dashboard/Users 与超管 Users 联动后端统计 - api:Dashboard、Analyze、Test、超管 AppUser 逻辑更新 - 微信/抖音小程序:result、profile 页面与样式 - 新增 admin 公共组件与需求文档截图 Made-with: Cursor
This commit is contained in:
1003
admin/src/components/UserDetailDialog.vue
Normal file
1003
admin/src/components/UserDetailDialog.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 返回则透传,未返回则不输出
|
||||
|
||||
@@ -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'] = [];
|
||||
}
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
@@ -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} 等与系统性、长期主义更契合的路径往往更容易做出成绩;建议前 1~3 年夯实基本功与协作习惯,再向骨干或专家角色过渡。`,
|
||||
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分析对应的测试记录ID(mbti_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重新拉取完整详情
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} 等与系统性、长期主义更契合的路径往往更容易做出成绩;建议前 1~3 年夯实基本功与协作习惯,再向骨干或专家角色过渡。`,
|
||||
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分析对应的测试记录ID(mbti_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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
BIN
开发文档/1、需求/修改/images/2026-03-27-18-13-26.png
Normal file
BIN
开发文档/1、需求/修改/images/2026-03-27-18-13-26.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 196 KiB |
BIN
开发文档/1、需求/修改/images/2026-03-27-18-13-55.png
Normal file
BIN
开发文档/1、需求/修改/images/2026-03-27-18-13-55.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 322 KiB |
7
开发文档/1、需求/修改/管理后台20260327-1.md
Normal file
7
开发文档/1、需求/修改/管理后台20260327-1.md
Normal file
@@ -0,0 +1,7 @@
|
||||
|
||||
|
||||
功能一:企业用户统计
|
||||
整个公司的那个用户数据的统计就是有测试的,做相应的分类,然后每个分类把图标都加上去,然后有多少人什么性格测试都统计到这个里面。然后企业的那个第一个统计数据就能看到这个那个统计的那个报告,对吧?就放到那个企业的统后台的管理,后台的那个统计数据单个
|
||||
|
||||
|
||||
然后你把这些数据的话,就包括刚刚的那些东西,统一的到数据概览,整合到数据概览里面,数据概览只有一张图,你就这样。
|
||||
Reference in New Issue
Block a user