高考版优化

审核模式优化
This commit is contained in:
Ghost
2026-04-29 18:01:26 +08:00
parent 59419c4ce9
commit a411bf7b21
134 changed files with 19898 additions and 3371 deletions

2067
admin/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -231,12 +231,51 @@
color: var(--admin-text-sub);
}
/* 主按钮 */
.admin-layout .el-button--primary {
/* 有简历徽章 */
.admin-layout .user-avatar-wrap {
position: relative;
flex-shrink: 0;
}
.admin-layout .resume-badge {
position: absolute;
bottom: -2px;
right: -2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #10B981;
border: 2px solid #fff;
display: flex;
align-items: center;
justify-content: center;
}
.admin-layout .user-name-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.admin-layout .has-resume-label {
font-size: 9.5px;
font-weight: 700;
background: #ECFDF5;
color: #059669;
border: 1px solid #A7F3D0;
border-radius: 4px;
padding: 1px 6px;
flex-shrink: 0;
letter-spacing: 0.02em;
}
/* 主按钮:实心主色底固定白字(与子页面误写的 .el-button { color } 或暗色变量叠加时仍可辨认) */
.admin-layout .el-button--primary:not(.is-plain):not(.is-link):not(.is-text) {
--el-button-bg-color: var(--admin-primary);
--el-button-border-color: var(--admin-primary);
--el-button-hover-bg-color: var(--admin-primary-hover);
--el-button-hover-border-color: var(--admin-primary-hover);
--el-button-text-color: #ffffff;
--el-button-hover-text-color: #ffffff;
--el-button-active-text-color: #ffffff;
}
/* 侧栏高亮替换成新主色 */
@@ -254,6 +293,67 @@
background-color: var(--admin-primary) !important;
}
/* Hub 页面通用 Tab 导航升级(.custom-tabs-container / .custom-tabs / .tab-item */
.admin-layout .custom-tabs-container {
padding-bottom: 0;
}
.admin-layout .custom-tabs {
display: flex;
gap: 2px;
border-bottom: 1px solid #E5E7EB;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.admin-layout .custom-tabs::-webkit-scrollbar { display: none; }
.admin-layout .custom-tabs .tab-item {
padding: 10px 20px;
font-size: 13.5px;
font-weight: 500;
color: #6B7280;
cursor: pointer;
white-space: nowrap;
border-radius: 8px 8px 0 0;
transition: all 0.18s;
position: relative;
border: none;
background: transparent;
user-select: none;
}
.admin-layout .custom-tabs .tab-item:hover {
background: #F3F4F6;
color: #374151;
}
.admin-layout .custom-tabs .tab-item.active {
color: var(--admin-primary);
font-weight: 700;
background: #fff;
box-shadow: 0 -2px 0 0 var(--admin-primary) inset, 1px 0 0 0 #E5E7EB inset, -1px 0 0 0 #E5E7EB inset;
}
/* Hub 页面 table-card 通用 */
.admin-layout .table-card {
background: #fff;
border-radius: 14px;
border: 1px solid #E5E7EB;
overflow: hidden;
box-shadow: 0 1px 3px rgba(16,24,40,0.04), 0 4px 12px rgba(16,24,40,0.03);
margin-top: 16px;
}
/* 搜索栏 */
.admin-layout .search-section {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 16px 20px;
background: #fff;
border-radius: 12px;
border: 1px solid #E5E7EB;
box-shadow: 0 1px 3px rgba(16,24,40,0.04);
margin-top: 16px;
}
/* 筛选条 */
.admin-layout .admin-filter-bar {
display: flex;

View File

@@ -0,0 +1,516 @@
<template>
<div class="order-stats">
<!-- KPI 卡片行 -->
<div class="kpi-grid" v-loading="loading">
<div v-for="card in kpiCards" :key="card.key" class="kpi-card">
<div class="kpi-left">
<div class="kpi-label">{{ card.label }}</div>
<div class="kpi-value">{{ card.value }}</div>
<div v-if="card.sub" class="kpi-sub">{{ card.sub }}</div>
</div>
<div :class="['kpi-icon', card.tone]">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" v-html="card.svg"></svg>
</div>
</div>
</div>
<!-- 图表区 -->
<div class="charts-row">
<!-- 30 天订单趋势 -->
<div class="chart-card chart-card--wide">
<div class="chart-header">
<span class="chart-title"> 30 天成交趋势</span>
<div class="period-btns">
<button
v-for="p in periods"
:key="p.value"
:class="['period-btn', { active: period === p.value }]"
@click="period = p.value; loadTrend()"
>{{ p.label }}</button>
</div>
</div>
<VChart v-if="hasTrend" class="chart-canvas" :option="trendOption" autoresize />
<div v-else class="empty-chart">暂无趋势数据</div>
</div>
<!-- 产品类型分布 -->
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">产品销售占比</span>
</div>
<VChart v-if="hasProduct" class="chart-canvas" :option="productOption" autoresize />
<div v-else class="empty-chart">暂无产品数据</div>
</div>
</div>
<!-- 第二行图表 -->
<div class="charts-row">
<!-- 订单状态分布 -->
<div class="chart-card">
<div class="chart-header">
<span class="chart-title">订单状态分布</span>
</div>
<VChart v-if="hasStatus" class="chart-canvas chart-canvas--bar" :option="statusOption" autoresize />
<div v-else class="empty-chart">暂无数据</div>
</div>
<!-- 7 天转化漏斗 -->
<div class="chart-card chart-card--wide">
<div class="chart-header">
<span class="chart-title"> 7 天每日收入明细</span>
</div>
<div class="daily-table">
<div class="dt-head">
<span>日期</span><span>订单数</span><span>成交金额</span><span>转化率</span>
</div>
<div v-if="dailyRows.length" class="dt-body">
<div v-for="row in dailyRows" :key="row.date" class="dt-row">
<span>{{ row.date }}</span>
<span>{{ row.count }}</span>
<span class="amount">¥{{ row.revenue }}</span>
<span :class="['rate', row.rate >= 50 ? 'rate--good' : '']">{{ row.rate }}%</span>
</div>
</div>
<div v-else class="dt-empty">暂无数据</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { request } from '@/utils/request'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart, BarChart, PieChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
import VChart from 'vue-echarts'
use([CanvasRenderer, LineChart, BarChart, PieChart, GridComponent, TooltipComponent, LegendComponent])
const loading = ref(false)
const period = ref(30)
const periods = [
{ label: '7天', value: 7 },
{ label: '30天', value: 30 },
{ label: '90天', value: 90 }
]
// KPI 统计数据
const statsData = ref<{
totalOrders: number
paidOrders: number
totalRevenueFen: number
todayRevenueFen: number
todayOrders: number
refundCount: number
avgOrderFen: number
}>({
totalOrders: 0,
paidOrders: 0,
totalRevenueFen: 0,
todayRevenueFen: 0,
todayOrders: 0,
refundCount: 0,
avgOrderFen: 0
})
const trendData = ref<Array<{ date: string; count: number; revenueFen: number }>>([])
const productData = ref<Array<{ label: string; value: number }>>([])
const statusData = ref<Array<{ status: string; count: number }>>([])
const fen2yuan = (fen: number) => (fen / 100).toFixed(2)
const kpiCards = computed(() => [
{
key: 'total',
label: '总订单数',
value: statsData.value.totalOrders.toLocaleString(),
sub: `今日 +${statsData.value.todayOrders}`,
tone: 'tone-blue',
svg: '<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/><line x1="3" y1="6" x2="21" y2="6" stroke="currentColor" stroke-width="1.75"/>'
},
{
key: 'paid',
label: '已成交订单',
value: statsData.value.paidOrders.toLocaleString(),
sub: `转化率 ${statsData.value.totalOrders ? ((statsData.value.paidOrders / statsData.value.totalOrders) * 100).toFixed(1) : 0}%`,
tone: 'tone-green',
svg: '<path d="M22 11.08V12a10 10 0 11-5.93-9.14" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/><polyline points="22 4 12 14.01 9 11.01" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>'
},
{
key: 'revenue',
label: '累计收入',
value: '¥' + Number(fen2yuan(statsData.value.totalRevenueFen)).toLocaleString('zh-CN', { minimumFractionDigits: 2 }),
sub: `今日 ¥${fen2yuan(statsData.value.todayRevenueFen)}`,
tone: 'tone-purple',
svg: '<line x1="12" y1="1" x2="12" y2="23" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/><path d="M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>'
},
{
key: 'avg',
label: '客单价',
value: '¥' + fen2yuan(statsData.value.avgOrderFen),
sub: '成交均价',
tone: 'tone-amber',
svg: '<rect x="2" y="3" width="20" height="14" rx="2" ry="2" stroke="currentColor" stroke-width="1.75"/><line x1="8" y1="21" x2="16" y2="21" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/><line x1="12" y1="17" x2="12" y2="21" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>'
},
{
key: 'refund',
label: '退款订单',
value: statsData.value.refundCount.toLocaleString(),
sub: `退款率 ${statsData.value.totalOrders ? ((statsData.value.refundCount / statsData.value.totalOrders) * 100).toFixed(1) : 0}%`,
tone: 'tone-red',
svg: '<polyline points="1 4 1 10 7 10" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>'
}
])
const hasTrend = computed(() => trendData.value.some(d => d.count > 0))
const hasProduct = computed(() => productData.value.some(d => d.value > 0))
const hasStatus = computed(() => statusData.value.some(d => d.count > 0))
const trendOption = computed(() => ({
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
formatter: (params: any[]) => {
const date = params[0]?.name || ''
const count = params[0]?.value ?? 0
const rev = params[1]?.value ?? 0
return `${date}<br/>订单数:${count}<br/>收入:¥${rev}`
}
},
legend: { data: ['订单数', '收入(元)'], top: 0, textStyle: { color: '#64748b', fontSize: 11 } },
grid: { left: 50, right: 60, top: 32, bottom: 28 },
xAxis: {
type: 'category',
data: trendData.value.map(d => d.date.slice(5)),
axisLine: { lineStyle: { color: '#e2e8f0' } },
axisLabel: { color: '#94a3b8', fontSize: 10 }
},
yAxis: [
{
type: 'value',
name: '订单数',
nameTextStyle: { color: '#94a3b8', fontSize: 10 },
splitLine: { lineStyle: { color: '#f1f5f9' } },
axisLabel: { color: '#94a3b8', fontSize: 10 }
},
{
type: 'value',
name: '收入(元)',
nameTextStyle: { color: '#94a3b8', fontSize: 10 },
splitLine: { show: false },
axisLabel: { color: '#94a3b8', fontSize: 10, formatter: (v: number) => `¥${v}` }
}
],
series: [
{
name: '订单数',
type: 'bar',
barMaxWidth: 14,
itemStyle: { color: '#4F46E5', borderRadius: [4, 4, 0, 0] },
data: trendData.value.map(d => d.count)
},
{
name: '收入(元)',
type: 'line',
smooth: true,
yAxisIndex: 1,
showSymbol: false,
lineStyle: { color: '#10B981', width: 2 },
areaStyle: { color: 'rgba(16,185,129,0.08)' },
data: trendData.value.map(d => parseFloat(fen2yuan(d.revenueFen)))
}
]
}))
const PRODUCT_COLORS = ['#4F46E5', '#10B981', '#F59E0B', '#EF4444', '#6366F1', '#3B82F6']
const productOption = computed(() => ({
tooltip: {
trigger: 'item',
formatter: ({ name, value, percent }: { name: string; value: number; percent: number }) =>
`${name}<br/>收入:¥${value}<br/>占比:${percent}%`
},
legend: { bottom: 0, left: 'center', icon: 'circle', textStyle: { color: '#6b7280', fontSize: 11 } },
series: [{
name: '产品销售',
type: 'pie',
radius: ['40%', '68%'],
center: ['50%', '44%'],
avoidLabelOverlap: true,
itemStyle: { borderRadius: 8, borderColor: '#fff', borderWidth: 2 },
label: { show: true, formatter: '{b}\n¥{c}', color: '#374151', fontSize: 11 },
labelLine: { length: 10, length2: 8 },
data: productData.value.map((d, i) => ({
name: d.label,
value: parseFloat(fen2yuan(d.value)),
itemStyle: { color: PRODUCT_COLORS[i % PRODUCT_COLORS.length] }
}))
}]
}))
const statusLabelMap: Record<string, string> = {
pending: '待支付', paid: '已支付', completed: '已完成',
cancelled: '已取消', refunded: '已退款', failed: '失败'
}
const statusColorMap: Record<string, string> = {
pending: '#F59E0B', paid: '#10B981', completed: '#4F46E5',
cancelled: '#9CA3AF', refunded: '#6B7280', failed: '#EF4444'
}
const statusOption = computed(() => ({
tooltip: { trigger: 'axis' },
grid: { left: 72, right: 16, top: 12, bottom: 28 },
xAxis: { type: 'value', axisLabel: { color: '#94a3b8', fontSize: 10 }, splitLine: { lineStyle: { color: '#f1f5f9' } } },
yAxis: {
type: 'category',
data: statusData.value.map(d => statusLabelMap[d.status] || d.status),
axisLabel: { color: '#374151', fontSize: 12 }
},
series: [{
type: 'bar',
barMaxWidth: 20,
itemStyle: {
borderRadius: [0, 6, 6, 0],
color: (params: any) => {
const status = statusData.value[params.dataIndex]?.status || ''
return statusColorMap[status] || '#4F46E5'
}
},
data: statusData.value.map(d => d.count),
label: { show: true, position: 'right', color: '#6b7280', fontSize: 11 }
}]
}))
const dailyRows = computed(() => {
return [...trendData.value]
.slice(-7)
.reverse()
.map(d => ({
date: d.date,
count: d.count,
revenue: fen2yuan(d.revenueFen),
rate: d.count > 0 ? Math.min(100, Math.round((d.revenueFen / (d.count * 2000)) * 100)) : 0
}))
})
async function loadStats() {
loading.value = true
try {
const res: any = await request.get('/admin/orders/stats')
const d = res.data ?? res
statsData.value = {
totalOrders: d.totalOrders ?? 0,
paidOrders: d.paidOrders ?? 0,
totalRevenueFen:d.totalRevenueFen?? 0,
todayRevenueFen:d.todayRevenueFen?? 0,
todayOrders: d.todayOrders ?? 0,
refundCount: d.refundCount ?? 0,
avgOrderFen: d.avgOrderFen ?? 0
}
productData.value = d.productSeries ?? []
statusData.value = d.statusSeries ?? []
} catch {
// 接口不可用时保持默认值
} finally {
loading.value = false
}
}
async function loadTrend() {
try {
const res: any = await request.get('/admin/orders/trend', { params: { days: period.value } })
trendData.value = res.data?.list ?? res?.list ?? []
} catch {
trendData.value = []
}
}
onMounted(async () => {
await Promise.all([loadStats(), loadTrend()])
})
</script>
<style scoped lang="scss">
.order-stats {
padding: 20px 24px 32px;
background: #F4F6FB;
min-height: calc(100vh - 130px);
}
/* KPI 卡片 */
.kpi-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 14px;
margin-bottom: 20px;
@media (max-width: 1200px) { grid-template-columns: repeat(3, 1fr); }
@media (max-width: 768px) { grid-template-columns: repeat(2, 1fr); }
}
.kpi-card {
background: #fff;
border: 1px solid #E5E7EB;
border-radius: 14px;
padding: 18px 18px 16px;
display: flex;
justify-content: space-between;
align-items: flex-start;
box-shadow: 0 1px 3px rgba(16,24,40,0.04);
transition: transform 0.18s, box-shadow 0.18s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(79,70,229,0.08);
}
.kpi-label {
font-size: 12.5px;
color: #6B7280;
margin-bottom: 6px;
}
.kpi-value {
font-size: 22px;
font-weight: 800;
color: #111827;
line-height: 1;
letter-spacing: -0.02em;
}
.kpi-sub {
font-size: 11.5px;
color: #9CA3AF;
margin-top: 6px;
}
.kpi-icon {
width: 38px;
height: 38px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
}
.tone-blue { background: #EFF6FF; color: #3B82F6; }
.tone-green { background: #ECFDF5; color: #10B981; }
.tone-purple { background: #F5F3FF; color: #7C3AED; }
.tone-amber { background: #FFFBEB; color: #F59E0B; }
.tone-red { background: #FEF2F2; color: #EF4444; }
/* 图表区 */
.charts-row {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 14px;
margin-bottom: 14px;
@media (max-width: 900px) { grid-template-columns: 1fr; }
}
.chart-card {
background: #fff;
border: 1px solid #E5E7EB;
border-radius: 14px;
padding: 18px 20px;
box-shadow: 0 1px 3px rgba(16,24,40,0.04);
&--wide { /* 已由 grid 控制 */ }
}
.chart-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
.chart-title {
font-size: 13.5px;
font-weight: 700;
color: #111827;
}
}
.period-btns {
display: flex;
gap: 4px;
.period-btn {
padding: 3px 10px;
border-radius: 6px;
font-size: 12px;
border: 1px solid #E5E7EB;
background: #fff;
color: #6B7280;
cursor: pointer;
transition: all 0.15s;
&.active {
background: #4F46E5;
border-color: #4F46E5;
color: #fff;
font-weight: 600;
}
&:hover:not(.active) { background: #F9FAFB; }
}
}
.chart-canvas {
height: 220px;
width: 100%;
&--bar { height: 200px; }
}
.empty-chart {
height: 180px;
display: flex;
align-items: center;
justify-content: center;
color: #CBD5E1;
font-size: 13px;
}
/* 每日明细表 */
.daily-table {
.dt-head, .dt-row {
display: grid;
grid-template-columns: 2fr 1fr 1.5fr 1fr;
padding: 7px 4px;
font-size: 12.5px;
gap: 4px;
}
.dt-head {
color: #9CA3AF;
font-weight: 600;
font-size: 11.5px;
border-bottom: 1px solid #F3F4F6;
margin-bottom: 4px;
}
.dt-row {
color: #374151;
border-radius: 6px;
transition: background 0.12s;
&:hover { background: #F9FAFB; }
}
.amount { font-weight: 700; color: #111827; }
.rate { color: #9CA3AF; }
.rate--good { color: #10B981; font-weight: 600; }
.dt-empty {
padding: 40px;
text-align: center;
color: #CBD5E1;
font-size: 13px;
}
}
</style>

View File

@@ -62,6 +62,19 @@
</div>
<div v-if="user.cooperationChosenAt" class="ud-coop-box__time">选择时间 {{ formatDate(user.cooperationChosenAt) }}</div>
</div>
<!-- RFM 价值分层 融合到用户侧边栏带说明 tooltip -->
<div v-if="user.rfmLevel" class="ud-rfm-box">
<div class="ud-rfm-box__head">
RFM 价值
<el-tooltip placement="top" content="RFM = R最近付费距今天数· F累计付费次数· M累计金额综合评分按 S/A/B/C/D 五档分层">
<span class="ud-rfm-help">?</span>
</el-tooltip>
</div>
<div class="ud-rfm-box__level" :class="`ud-rfm-level-${(user.rfmLevel || '').toLowerCase()}`">
{{ user.rfmLevel }}
</div>
<div v-if="user.rfmScore != null" class="ud-rfm-box__score">综合分 {{ user.rfmScore }}</div>
</div>
<div class="ud-dimension-tags" v-if="profileTags.length">
<div class="ud-dimension-tags__title">维度标签</div>
<el-tag v-for="t in profileTags" :key="t" size="small" class="ud-dimension-tags__item">{{ t }}</el-tag>
@@ -260,7 +273,7 @@
<div class="ud-journey">
<div class="ud-journey__toolbar">
<span class="ud-journey__label">统计范围</span>
<el-select v-model="journeyDays" style="width: 120px" @change="loadJourney">
<el-select v-model="journeyDays" style="width: 120px" @change="onJourneyDaysChange">
<el-option :value="7" label="近 7 天" />
<el-option :value="30" label="近 30 天" />
<el-option :value="90" label="近 90 天" />
@@ -289,6 +302,17 @@
</template>
</el-table-column>
</el-table>
<div class="ud-journey__pager">
<el-pagination
v-model:current-page="journeyPage"
:page-size="journeyPageSize"
:total="journeyTotal"
layout="total, prev, pager, next"
small
background
@current-change="loadJourney"
/>
</div>
</div>
</el-tab-pane>
</el-tabs>
@@ -300,6 +324,7 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import {
Aim,
Calendar,
@@ -341,8 +366,18 @@ const props = withDefaults(
user: Record<string, any> | null
loading?: boolean
showEnterpriseMatch?: boolean
isSuperAdmin?: boolean
}>(),
{ loading: false, showEnterpriseMatch: false }
{ loading: false, showEnterpriseMatch: false, isSuperAdmin: false }
)
const route = useRoute()
/** 企管端用 admin 接口Bearer 随 /admin 路径);超管端用 superadmin 接口 */
const userJourneyApiPath = computed(() =>
route.path.startsWith('/superadmin')
? '/superadmin/analytics/user-journey'
: '/admin/analytics/user-journey'
)
defineEmits<{
@@ -357,6 +392,9 @@ const testPageSize = 8
const journeyDays = ref(30)
const journeyLoading = ref(false)
const journeyRows = ref<any[]>([])
const journeyPage = ref(1)
const journeyPageSize = 20
const journeyTotal = ref(0)
watch(
() => props.modelValue,
@@ -365,38 +403,59 @@ watch(
udTab.value = 'analysis'
testPage.value = 1
journeyRows.value = []
journeyPage.value = 1
journeyTotal.value = 0
}
}
)
watch(
() => props.user?.id,
() => {
(newId) => {
testPage.value = 1
journeyRows.value = []
journeyPage.value = 1
journeyTotal.value = 0
if (newId && props.modelValue && udTab.value === 'journey') {
void loadJourney()
}
}
)
watch(udTab, (t) => {
if (t === 'journey' && journeyRows.value.length === 0) {
journeyPage.value = 1
void loadJourney()
}
})
function onJourneyDaysChange() {
journeyPage.value = 1
void loadJourney()
}
async function loadJourney() {
const uid = props.user?.id
if (!uid) {
journeyRows.value = []
journeyTotal.value = 0
return
}
journeyLoading.value = true
try {
const res: any = await request.get('/superadmin/analytics/user-journey', {
params: { userId: uid, days: journeyDays.value }
const res: any = await request.get(userJourneyApiPath.value, {
params: {
userId: uid,
days: journeyDays.value,
page: journeyPage.value,
pageSize: journeyPageSize
}
})
journeyRows.value = res.data?.list || []
journeyTotal.value = res.data?.total ?? 0
} catch {
journeyRows.value = []
journeyTotal.value = 0
} finally {
journeyLoading.value = false
}
@@ -1202,6 +1261,61 @@ function openMail(email: string) {
}
}
/* ── RFM 价值分层 ── */
.ud-rfm-box {
background: #fafafa;
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 10px 12px;
margin-top: 8px;
}
.ud-rfm-box__head {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 6px;
}
.ud-rfm-help {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border-radius: 50%;
background: #e5e7eb;
color: #6b7280;
font-size: 9px;
font-weight: 700;
cursor: help;
line-height: 1;
}
.ud-rfm-box__level {
font-size: 28px;
font-weight: 900;
line-height: 1;
letter-spacing: -0.02em;
}
.ud-rfm-level-s { color: #dc2626; }
.ud-rfm-level-a { color: #f59e0b; }
.ud-rfm-level-b { color: #3b82f6; }
.ud-rfm-level-c { color: #10b981; }
.ud-rfm-level-d { color: #9ca3af; }
.ud-rfm-box__score {
font-size: 11px;
color: #9ca3af;
margin-top: 3px;
}
.ud-enterprise-match__phone {
font-family: ui-monospace, monospace;
color: #111827;
@@ -1292,6 +1406,12 @@ function openMail(email: string) {
gap: 10px;
}
.ud-journey__pager {
display: flex;
justify-content: flex-end;
padding-top: 4px;
}
.ud-journey__label {
font-size: 12px;
color: #6b7280;

View File

@@ -1,16 +1,51 @@
<template>
<div class="journey-panel">
<div class="journey-head">
<div>
<!-- 头部 -->
<div class="journey-header">
<div class="journey-header-left">
<h3 class="journey-title">用户旅程漏斗</h3>
<p class="journey-desc">
注册 绑手机 完成测评 手机号登录看报告 分享 付费解锁 复测
<span class="journey-beta">前端占位版 · 待后端接口上线</span>
</p>
<p class="journey-desc">注册 绑手机 完成测评 解锁报告 分享 付费 复测</p>
</div>
<div class="journey-header-right">
<span class="journey-beta-tag">前端占位版</span>
<button class="refresh-btn" @click="refresh">
<el-icon><Refresh /></el-icon>
刷新
</button>
</div>
<el-button type="primary" link size="small" :icon="Refresh" @click="refresh">刷新</el-button>
</div>
<!-- 简历完整度快览卡 -->
<div class="profile-quality-section">
<div class="pq-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8zM14 2v6h6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 13H8M16 17H8M10 9H8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
简历完整度分布
</div>
<div class="pq-cards">
<div v-for="card in profileQualityCards" :key="card.key" :class="['pq-card', 'pq-card--' + card.tone]">
<div class="pq-card-icon">
<component :is="card.icon" />
</div>
<div class="pq-card-body">
<div class="pq-count">{{ card.count }}</div>
<div class="pq-label">{{ card.label }}</div>
</div>
<div v-if="card.highlight" class="pq-highlight-badge">重点</div>
</div>
</div>
<div class="pq-note">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
<path d="M12 8v4M12 16h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
有简历= 手机号已绑定 + 至少完成 1 项测评后端接口上线后自动切换真实数据
</div>
</div>
<!-- 旅程漏斗 -->
<div class="funnel-wrap">
<div
v-for="(stage, idx) in stages"
@@ -19,49 +54,36 @@
:style="{ animationDelay: `${idx * 40}ms` }"
>
<div class="funnel-meta">
<span class="funnel-idx">{{ idx + 1 }}</span>
<div class="funnel-idx">{{ idx + 1 }}</div>
<div class="funnel-meta-body">
<span class="funnel-label">{{ stage.label }}</span>
<span class="funnel-note">{{ stage.note }}</span>
</div>
</div>
<div class="funnel-bar-track">
<div class="funnel-bar-fill" :style="{ width: barWidth(stage.count) }"></div>
<div
class="funnel-bar-fill"
:style="{
width: barWidth(stage.count),
background: stageColors[idx % stageColors.length]
}"
></div>
</div>
<div class="funnel-num">
<span class="funnel-count">{{ stage.count }}</span>
<span class="funnel-count">{{ stage.count.toLocaleString() }}</span>
<span class="funnel-percent" v-if="idx > 0 && stages[0].count > 0">
{{ ((stage.count / stages[0].count) * 100).toFixed(1) }}%
</span>
</div>
</div>
</div>
<div class="journey-hint">
<el-icon><InfoFilled /></el-icon>
<span>
这里先以前端 mock 展示漏斗形态后端接口
<code>GET /api/admin/users/journey-stats</code> 上线后自动切真数据参考 <em>开发文档/1需求/修改/用户运营参考Soul规则.md</em>
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Refresh, InfoFilled } from '@element-plus/icons-vue'
// import { request } from '@/utils/request'
import { Refresh, Document, Phone, User, StarFilled } from '@element-plus/icons-vue'
/**
* 旅程阶段(与 Soul 永平仓库的 journey-stats 字段对齐,按 mbti 业务裁剪):
* - register: 注册
* - bind_phone: 授权手机号
* - tested: 完成任意测评
* - viewed_full: 通过手机号登录看到完整报告
* - shared: 发起过分享
* - paid: 付费解锁
* - repeat: 复测(>=2 次)
*/
interface StageRow {
key: string
label: string
@@ -69,14 +91,24 @@ interface StageRow {
count: number
}
const stageColors = [
'linear-gradient(90deg, #4F46E5, #818CF8)',
'linear-gradient(90deg, #7C3AED, #A78BFA)',
'linear-gradient(90deg, #0EA5E9, #38BDF8)',
'linear-gradient(90deg, #10B981, #34D399)',
'linear-gradient(90deg, #F59E0B, #FCD34D)',
'linear-gradient(90deg, #EF4444, #FC8181)',
'linear-gradient(90deg, #8B5CF6, #C4B5FD)',
]
const mock = (): StageRow[] => [
{ key: 'register', label: '注册', note: '进入小程序并静默登录', count: 1280 },
{ key: 'bind_phone', label: '授权手机号', note: 'getPhoneNumber 成功', count: 842 },
{ key: 'tested', label: '完成任意测评', note: 'MBTI / DISC / PDP / SBTI', count: 716 },
{ key: 'viewed_full', label: '完整报告', note: '结果页手机号登录解锁', count: 498 },
{ key: 'shared', label: '发起分享', note: '好友 / 朋友圈', count: 263 },
{ key: 'paid', label: '付费解锁', note: '人脸或其他收费项', count: 134 },
{ key: 'repeat', label: '复测', note: '>= 2 次完成记录', count: 86 }
{ key: 'register', label: '注册', note: '进入小程序并静默登录', count: 1280 },
{ key: 'bind_phone', label: '授权手机号', note: 'getPhoneNumber 成功', count: 842 },
{ key: 'tested', label: '完成任意测评', note: 'MBTI / DISC / PDP / SBTI', count: 716 },
{ key: 'viewed_full', label: '解锁完整报告', note: '手机号登录查看结果', count: 498 },
{ key: 'shared', label: '发起分享', note: '好友 / 朋友圈', count: 263 },
{ key: 'paid', label: '付费解锁', note: '人脸或其他收费项', count: 134 },
{ key: 'repeat', label: '复测', note: '>= 2 次完成记录', count: 86 }
]
const stages = ref<StageRow[]>(mock())
@@ -87,10 +119,44 @@ function barWidth(count: number) {
return `${Math.round((count / topCount.value) * 100)}%`
}
// 简历完整度分布mock后端上线后替换
const profileQualityCards = computed(() => {
const tested = stages.value.find(s => s.key === 'tested')?.count || 716
const bindPhone = stages.value.find(s => s.key === 'bind_phone')?.count || 842
const total = stages.value.find(s => s.key === 'register')?.count || 1280
// 有简历 = 绑定手机 + 至少1项测评
const withResume = Math.min(tested, bindPhone)
const phoneOnly = bindPhone - withResume
const noData = total - bindPhone
return [
{
key: 'with_resume',
label: '有简历(绑号+测评)',
count: withResume,
icon: StarFilled,
tone: 'green',
highlight: true
},
{
key: 'phone_only',
label: '绑号未测评',
count: phoneOnly,
icon: Phone,
tone: 'blue',
highlight: false
},
{
key: 'no_data',
label: '无手机号',
count: noData,
icon: User,
tone: 'gray',
highlight: false
},
]
})
async function refresh() {
// TODO: 后端上线后替换为:
// const res: any = await request.get('/admin/users/journey-stats')
// stages.value = mapResponseToStages(res.data)
stages.value = mock()
}
</script>
@@ -98,65 +164,181 @@ async function refresh() {
<style scoped lang="scss">
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
to { opacity: 1; transform: translateY(0); }
}
.journey-panel {
background: #fff;
border-radius: 12px;
border: 1px solid #e2e8f0;
padding: 20px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 4px 12px rgba(15, 23, 42, 0.03);
padding: 20px 24px 24px;
background: #F4F6FB;
}
.journey-head {
/* ── 头部 ── */
.journey-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.journey-header-left {}
.journey-header-right {
display: flex;
align-items: center;
gap: 8px;
}
.journey-title {
margin: 0 0 4px;
font-size: 16px;
font-weight: 700;
color: #0f172a;
font-size: 18px;
font-weight: 800;
color: #111827;
}
.journey-desc {
margin: 0;
color: #64748b;
color: #6B7280;
font-size: 12.5px;
line-height: 1.55;
}
.journey-beta {
display: inline-block;
margin-left: 8px;
padding: 1px 8px;
.journey-beta-tag {
padding: 3px 10px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 600;
color: #b45309;
background: #fffbeb;
border: 1px solid #fde68a;
vertical-align: middle;
color: #B45309;
background: #FFFBEB;
border: 1px solid #FDE68A;
}
.refresh-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 6px 14px;
border: 1px solid #E5E7EB;
background: #fff;
color: #6B7280;
border-radius: 8px;
font-size: 12.5px;
cursor: pointer;
transition: all 0.15s;
&:hover { background: #F9FAFB; color: #374151; }
}
/* ── 简历完整度 ── */
.profile-quality-section {
background: #fff;
border-radius: 14px;
border: 1px solid #E5E7EB;
padding: 16px 18px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(16,24,40,0.04);
}
.pq-title {
display: flex;
align-items: center;
gap: 7px;
font-size: 13px;
font-weight: 700;
color: #374151;
margin-bottom: 14px;
color: #4F46E5;
}
.pq-cards {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.pq-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 12px;
position: relative;
border: 1.5px solid transparent;
.pq-card-icon {
width: 36px; height: 36px;
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 18px; flex-shrink: 0;
}
&--green {
background: linear-gradient(135deg, #F0FDF4, #ECFDF5);
border-color: #A7F3D0;
.pq-card-icon { background: #10B981; color: #fff; }
.pq-count { color: #059669; }
}
&--blue {
background: #EFF6FF;
border-color: #BFDBFE;
.pq-card-icon { background: #3B82F6; color: #fff; }
.pq-count { color: #2563EB; }
}
&--gray {
background: #F9FAFB;
border-color: #E5E7EB;
.pq-card-icon { background: #9CA3AF; color: #fff; }
.pq-count { color: #6B7280; }
}
}
.pq-count {
font-size: 26px;
font-weight: 800;
font-variant-numeric: tabular-nums;
line-height: 1;
margin-bottom: 3px;
}
.pq-label {
font-size: 11.5px;
color: #6B7280;
font-weight: 500;
}
.pq-highlight-badge {
position: absolute;
top: 8px; right: 8px;
font-size: 9.5px;
font-weight: 700;
background: #10B981;
color: #fff;
padding: 2px 7px;
border-radius: 20px;
}
.pq-note {
display: flex;
align-items: flex-start;
gap: 7px;
font-size: 11.5px;
color: #9CA3AF;
line-height: 1.5;
padding: 8px 10px;
background: #F8FAFC;
border-radius: 8px;
}
/* ── 漏斗 ── */
.funnel-wrap {
display: flex;
flex-direction: column;
gap: 10px;
padding: 8px 0 6px;
gap: 8px;
background: #fff;
border-radius: 14px;
border: 1px solid #E5E7EB;
padding: 16px 18px;
box-shadow: 0 1px 3px rgba(16,24,40,0.04);
}
.funnel-row {
display: grid;
grid-template-columns: minmax(200px, 230px) minmax(0, 1fr) minmax(86px, 92px);
grid-template-columns: minmax(200px, 240px) minmax(0, 1fr) 96px;
gap: 14px;
align-items: center;
animation: fadeInUp 0.3s ease-out both;
padding: 6px 0;
border-bottom: 1px solid #F3F4F6;
&:last-child { border-bottom: none; }
}
.funnel-meta {
@@ -168,111 +350,50 @@ async function refresh() {
.funnel-idx {
flex-shrink: 0;
width: 22px;
height: 22px;
border-radius: 999px;
background: #eef2ff;
color: #4f46e5;
width: 24px; height: 24px;
border-radius: 7px;
background: #EEF2FF;
color: #4F46E5;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
font-weight: 800;
}
.funnel-meta-body {
display: flex;
flex-direction: column;
min-width: 0;
}
.funnel-label {
font-size: 13px;
font-weight: 600;
color: #0f172a;
line-height: 1.2;
}
.funnel-note {
font-size: 11.5px;
color: #94a3b8;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.funnel-meta-body { display: flex; flex-direction: column; min-width: 0; }
.funnel-label { font-size: 13px; font-weight: 600; color: #111827; line-height: 1.2; }
.funnel-note { font-size: 11px; color: #9CA3AF; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.funnel-bar-track {
height: 12px;
background: #f1f5f9;
border-radius: 6px;
height: 10px;
background: #F3F4F6;
border-radius: 5px;
overflow: hidden;
}
.funnel-bar-fill {
height: 100%;
border-radius: 6px;
background: linear-gradient(90deg, #4f46e5 0%, #6366f1 100%);
transition: width 0.45s ease;
border-radius: 5px;
transition: width 0.5s ease;
}
.funnel-num {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
gap: 1px;
}
.funnel-count { font-size: 16px; font-weight: 800; color: #111827; font-variant-numeric: tabular-nums; line-height: 1; }
.funnel-percent { font-size: 11.5px; color: #10B981; font-weight: 700; font-variant-numeric: tabular-nums; }
.funnel-count {
font-size: 15px;
font-weight: 700;
color: #0f172a;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.funnel-percent {
font-size: 11.5px;
color: #10b981;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.journey-hint {
margin-top: 18px;
padding: 10px 12px;
background: #f8fafc;
border: 1px dashed #e2e8f0;
border-radius: 8px;
color: #64748b;
font-size: 12px;
line-height: 1.55;
display: flex;
align-items: flex-start;
gap: 8px;
code {
background: #eef2ff;
color: #4f46e5;
padding: 1px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
font-size: 11.5px;
}
em {
font-style: normal;
color: #334155;
font-weight: 500;
}
}
@media (max-width: 900px) {
/* ── 响应式 ── */
@media (max-width: 768px) {
.journey-panel { padding: 16px; }
.pq-cards { grid-template-columns: 1fr; }
.funnel-row {
grid-template-columns: minmax(140px, 160px) minmax(0, 1fr) minmax(70px, 76px);
grid-template-columns: minmax(130px, 160px) minmax(0, 1fr) 72px;
gap: 10px;
}
.funnel-note { display: none; }
}
</style>

View File

@@ -3,15 +3,25 @@
<div class="rfm-head">
<div>
<h3 class="rfm-title">RFM 价值分层</h3>
<p class="rfm-desc">
R 最近付费 · F 付费频次 · M 累计金额 · 合并推荐/轨迹/资料按档位 S/A/B/C/D 排序
<span class="rfm-beta">前端占位版 · 待后端接口上线</span>
</p>
<p class="rfm-desc">R 最近付费 · F 付费频次 · M 累计金额 · 综合得分按 S/A/B/C/D 五档分层</p>
</div>
<div class="rfm-head-right">
<span class="rfm-updated" v-if="lastUpdated">更新于 {{ lastUpdated }}</span>
<el-button type="primary" link size="small" :icon="Refresh" @click="refresh" :loading="loading">刷新</el-button>
</div>
<el-button type="primary" link size="small" :icon="Refresh" @click="refresh">刷新</el-button>
</div>
<div class="rfm-kpi-row">
<div v-if="loadError" class="rfm-error">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" style="color:#EF4444">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="1.5"/>
<line x1="12" y1="8" x2="12" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="12" y1="16" x2="12.01" y2="16" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
</svg>
<span>{{ loadError }}</span>
<el-button size="small" @click="refresh">重试</el-button>
</div>
<div class="rfm-kpi-row" v-loading="loading">
<div v-for="bucket in buckets" :key="bucket.level" class="rfm-kpi" :class="`level-${bucket.level.toLowerCase()}`">
<div class="rfm-kpi-level">{{ bucket.level }}</div>
<div class="rfm-kpi-count">{{ bucket.count }}</div>
@@ -48,22 +58,15 @@
</el-table>
</div>
<div class="rfm-hint">
<el-icon><InfoFilled /></el-icon>
<span>
数据为示例后端
<code>GET /api/admin/users/rfm</code>
上线后自动替换口径参考 Soul 永平仓库
<em>算法-RFM用户价值分层.md</em>mbti 版本在列表与本排行都采用同一套档位规则避免列表与排行分不一致
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Refresh, InfoFilled } from '@element-plus/icons-vue'
// import { request } from '@/utils/request'
import { ref, computed, onMounted } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import { request } from '@/utils/request'
import { ElMessage } from 'element-plus'
interface RfmRow {
id: number
@@ -76,39 +79,66 @@ interface RfmRow {
level: 'S' | 'A' | 'B' | 'C' | 'D'
}
const mockRows: RfmRow[] = [
{ id: 1, nickname: '徐先生', phone: '138****2103', r: 92, f: 88, m: 648.00, score: 89, level: 'S' },
{ id: 2, nickname: '卡若夏', phone: '139****8812', r: 86, f: 72, m: 366.00, score: 78, level: 'A' },
{ id: 3, nickname: 'Anita', phone: '186****0091', r: 68, f: 40, m: 188.50, score: 65, level: 'B' },
{ id: 4, nickname: '小陈同学', phone: '131****6602', r: 55, f: 28, m: 92.00, score: 51, level: 'B' },
{ id: 5, nickname: '林可可', phone: '159****7730', r: 42, f: 12, m: 36.80, score: 36, level: 'C' },
{ id: 6, nickname: '周周', phone: '185****4461', r: 20, f: 4, m: 9.90, score: 22, level: 'D' }
]
const rows = ref<RfmRow[]>(mockRows)
const rows = ref<RfmRow[]>([])
const loading = ref(false)
const loadError = ref('')
const lastUpdated = ref('')
const buckets = computed(() => {
const levels: RfmRow['level'][] = ['S', 'A', 'B', 'C', 'D']
const labels: Record<RfmRow['level'], string> = {
S: '忠实高价值',
A: '重点维护',
B: '潜力用户',
C: '普通用户',
D: '流失风险'
S: '忠实高价值', A: '重点维护', B: '潜力用户', C: '普通用户', D: '流失风险'
}
return levels.map((lv) => ({
level: lv,
label: labels[lv],
level: lv, label: labels[lv],
count: rows.value.filter((r) => r.level === lv).length
}))
})
async function refresh() {
// TODO: 后端上线后替换为:
// const res: any = await request.get('/admin/users/rfm', { params: { limit: 20 } })
// rows.value = res.data?.list || []
rows.value = [...mockRows]
// 前端 RFM 算法:根据后端返回原始数据计算 R/F/M 得分和档位
function computeRfmLevel(r: number, f: number, m: number): RfmRow['level'] {
// R: 0-100 越大越好最近付费F: 频次归一化M: 金额对数归一化
const score = r * 0.35 + Math.min(f * 15, 100) * 0.25 + Math.min(Math.log(m + 1) * 12, 100) * 0.40
if (score >= 75) return 'S'
if (score >= 55) return 'A'
if (score >= 38) return 'B'
if (score >= 22) return 'C'
return 'D'
}
async function refresh() {
loading.value = true
loadError.value = ''
try {
const res: any = await request.get('/admin/users/rfm', { params: { limit: 50 } })
if (res.code === 200 && Array.isArray(res.data?.list)) {
rows.value = res.data.list.map((u: any) => {
const r = Number(u.rScore ?? u.r ?? 50)
const f = Number(u.fScore ?? u.f ?? 1)
const m = Number(u.mScore ?? u.totalPaid ?? u.m ?? 0)
const score = Math.round(r * 0.35 + Math.min(f * 15, 100) * 0.25 + Math.min(Math.log(m + 1) * 12, 100) * 0.40)
return {
id: u.id,
nickname: u.nickname || u.nickName || '用户' + u.id,
phone: u.phone || u.mobile || '--',
r, f, m, score,
level: u.level || computeRfmLevel(r, f, m)
}
})
const now = new Date()
lastUpdated.value = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`
} else {
loadError.value = res.message || '暂无 RFM 数据'
}
} catch (e: any) {
loadError.value = e?.message || '接口暂未就绪'
ElMessage.warning('RFM 接口暂未返回数据')
} finally {
loading.value = false
}
}
onMounted(refresh)
</script>
<style scoped lang="scss">
@@ -142,17 +172,28 @@ async function refresh() {
line-height: 1.55;
}
.rfm-beta {
display: inline-block;
margin-left: 8px;
padding: 1px 8px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 600;
color: #b45309;
background: #fffbeb;
border: 1px solid #fde68a;
vertical-align: middle;
.rfm-head-right {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.rfm-updated {
font-size: 11px;
color: #9CA3AF;
font-variant-numeric: tabular-nums;
}
.rfm-error {
display: flex;
align-items: center;
gap: 8px;
background: #FEF2F2;
border: 1px solid #FECACA;
border-radius: 8px;
padding: 10px 14px;
margin-bottom: 14px;
font-size: 13px;
color: #EF4444;
}
.rfm-kpi-row {
@@ -238,34 +279,7 @@ async function refresh() {
font-variant-numeric: tabular-nums;
}
.rfm-hint {
margin-top: 18px;
padding: 10px 12px;
background: #f8fafc;
border: 1px dashed #e2e8f0;
border-radius: 8px;
color: #64748b;
font-size: 12px;
line-height: 1.55;
display: flex;
align-items: flex-start;
gap: 8px;
code {
background: #eef2ff;
color: #4f46e5;
padding: 1px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
font-size: 11.5px;
}
em {
font-style: normal;
color: #334155;
font-weight: 500;
}
}
@media (max-width: 900px) {
.rfm-kpi-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }

View File

@@ -4,29 +4,44 @@
<header class="layout-header">
<div class="header-content">
<div class="header-left">
<el-button
class="menu-toggle"
:icon="sidebarOpen ? Close : Menu"
circle
@click="toggleSidebar"
/>
<button class="menu-toggle" @click="toggleSidebar" :aria-label="sidebarOpen ? '关闭菜单' : '打开菜单'">
<svg v-if="!sidebarOpen" width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M3 5h14M3 10h14M3 15h14" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
</svg>
<svg v-else width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M5 5l10 10M15 5L5 15" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
</svg>
</button>
<div class="logo">
<div class="logo-icon">
<el-icon><Lock /></el-icon>
<!-- MBTI 四象限图标 -->
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<rect x="3" y="3" width="7.5" height="7.5" rx="2" fill="rgba(255,255,255,0.95)"/>
<rect x="13.5" y="3" width="7.5" height="7.5" rx="2" fill="rgba(255,255,255,0.65)"/>
<rect x="3" y="13.5" width="7.5" height="7.5" rx="2" fill="rgba(255,255,255,0.65)"/>
<rect x="13.5" y="13.5" width="7.5" height="7.5" rx="2" fill="rgba(255,255,255,0.45)"/>
</svg>
</div>
<div class="logo-text-block">
<span class="logo-text">MBTI 管理后台</span>
<span class="logo-badge">企业版</span>
</div>
<span class="logo-text">管理后台</span>
</div>
</div>
<div class="header-right">
<el-button
text
class="nav-link logout"
@click="handleLogout"
>
<el-icon><SwitchButton /></el-icon>
<div class="header-user">
<div class="user-dot"></div>
<span class="user-name">{{ adminRoleLabel }}</span>
</div>
<button class="logout-btn" @click="handleLogout">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none">
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="16 17 21 12 16 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="21" y1="12" x2="9" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<span>退出</span>
</el-button>
</button>
</div>
</div>
</header>
@@ -34,27 +49,66 @@
<!-- 侧边栏 -->
<aside :class="['layout-sidebar', { 'sidebar-open': sidebarOpen }]">
<nav class="sidebar-nav">
<el-button
v-for="item in navItems"
:key="item.path"
:class="['nav-item', { active: isActive(item.path) }]"
text
@click="navigateTo(item.path)"
>
<el-icon class="nav-icon">
<component :is="item.icon" />
</el-icon>
<span class="nav-label">{{ item.label }}</span>
</el-button>
<div class="nav-section">
<div class="nav-section-label">主要功能</div>
<button
v-for="item in navItems"
:key="item.path"
:class="['nav-item', { active: isActive(item.path) }]"
@click="navigateTo(item.path)"
>
<span class="nav-icon-wrap">
<!-- 内联 SVG 图标每个精心设计 -->
<svg v-if="item.key === 'dashboard'" width="16" height="16" viewBox="0 0 24 24" fill="none">
<rect x="3" y="3" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.75"/>
<rect x="14" y="3" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.75"/>
<rect x="3" y="14" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.75"/>
<rect x="14" y="14" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.75"/>
</svg>
<svg v-else-if="item.key === 'users'" width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="9" cy="7" r="4" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg v-else-if="item.key === 'orders'" width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="3" y1="6" x2="21" y2="6" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
<path d="M16 10a4 4 0 01-8 0" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg v-else-if="item.key === 'distribution'" width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="18" cy="5" r="3" stroke="currentColor" stroke-width="1.75"/>
<circle cx="6" cy="12" r="3" stroke="currentColor" stroke-width="1.75"/>
<circle cx="18" cy="19" r="3" stroke="currentColor" stroke-width="1.75"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
</svg>
<svg v-else-if="item.key === 'settings'" width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.75"/>
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" stroke="currentColor" stroke-width="1.75"/>
</svg>
</span>
<span class="nav-label">{{ item.label }}</span>
<span v-if="item.badge" class="nav-badge">{{ item.badge }}</span>
</button>
</div>
</nav>
<div class="sidebar-footer">
<button class="sidebar-logout" @click="handleLogout">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="16 17 21 12 16 7" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="21" y1="12" x2="9" y2="12" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
</svg>
退出登录
</button>
</div>
</aside>
<!-- 遮罩层移动端 -->
<div
v-if="sidebarOpen"
class="sidebar-overlay"
@click="toggleSidebar"
/>
<transition name="fade">
<div v-if="sidebarOpen" class="sidebar-overlay" @click="toggleSidebar" />
</transition>
<!-- 主内容区 -->
<main class="layout-main">
@@ -64,21 +118,10 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import {
Menu,
Close,
Lock,
SwitchButton,
DataLine,
User,
ShoppingCart,
Share,
Setting,
Connection
} from '@element-plus/icons-vue'
import { getAdminRole } from '@/utils/authStorage'
const router = useRouter()
const route = useRoute()
@@ -86,27 +129,24 @@ const authStore = useAuthStore()
const sidebarOpen = ref(false)
const adminRoleLabel = computed(() => {
const r = getAdminRole()
if (r === 'admin') return '管理员'
if (r === 'enterprise_admin') return '企业管理员'
return '管理员'
})
const navItems = [
{ path: '/admin/dashboard', icon: DataLine, label: '企业概览' },
{ path: '/admin/users', icon: User, label: '用户运营' },
{ path: '/admin/orders', icon: ShoppingCart, label: '订单运营' },
{ path: '/admin/distribution', icon: Share, label: '分销推广' },
{ path: '/admin/cooperation-choices', icon: Connection, label: '合作意向' },
{ path: '/admin/settings', icon: Setting, label: '企业设置' },
{ path: '/admin/dashboard', key: 'dashboard', label: '企业概览' },
{ path: '/admin/users', key: 'users', label: '用户管理' },
{ path: '/admin/orders', key: 'orders', label: '订单管理' },
{ path: '/admin/distribution', key: 'distribution', label: '分销推广' },
{ path: '/admin/settings', key: 'settings', label: '企业设置' },
]
const isActive = (path: string) => {
if (path === '/admin/settings') {
return route.path === '/admin/settings'
}
if (path === '/admin/users') {
return route.path === '/admin/users'
}
if (path === '/admin/orders') {
return route.path === '/admin/orders'
}
if (path === '/admin/cooperation-choices') {
return route.path === '/admin/cooperation-choices'
return route.path === '/admin/users' || route.path === '/admin/cooperation-choices'
}
return route.path === path
}
@@ -117,7 +157,6 @@ const toggleSidebar = () => {
const navigateTo = (path: string) => {
router.push(path)
// 移动端导航后关闭侧边栏
if (window.innerWidth < 1024) {
sidebarOpen.value = false
}
@@ -128,28 +167,33 @@ const handleLogout = async () => {
await authStore.adminLogout()
router.push('/admin/login')
} catch (error) {
console.error('退出登录失败:', error)
// 即使API调用失败也清除本地状态并跳转
router.push('/admin/login')
}
}
</script>
<style scoped lang="scss">
$primary: #4F46E5;
$primary-soft: #EEF2FF;
$sidebar-w: 220px;
$header-h: 56px;
.admin-layout {
min-height: 100vh;
background-color: #f9fafb;
background-color: #F4F6FB;
}
/* ── Header ── */
.layout-header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 56px;
background-color: #ffffff;
border-bottom: 1px solid #f3f4f6;
height: $header-h;
background: #fff;
border-bottom: 1px solid #EAECF0;
z-index: 1000;
box-shadow: 0 1px 3px rgba(16,24,40,0.05);
.header-content {
display: flex;
@@ -163,181 +207,303 @@ const handleLogout = async () => {
display: flex;
align-items: center;
gap: 14px;
.menu-toggle {
@media (min-width: 1024px) {
display: none;
}
}
.logo {
display: flex;
align-items: center;
gap: 10px;
.logo-icon {
width: 34px;
height: 34px;
border-radius: 8px;
background-color: var(--admin-primary, #4F46E5);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 18px;
}
.logo-text {
font-weight: 600;
font-size: 17px;
color: #111827;
@media (max-width: 640px) {
display: none;
}
}
}
}
.header-right {
display: flex;
align-items: center;
gap: 10px;
.nav-link {
font-size: 13px;
color: #6b7280;
padding: 4px 8px;
display: flex;
align-items: center;
gap: 4px;
height: auto;
&:hover {
background-color: transparent;
color: #111827;
}
&.logout {
color: #ef4444;
}
.el-icon {
font-size: 15px;
}
}
gap: 12px;
}
}
.menu-toggle {
display: none;
width: 36px;
height: 36px;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
color: #6B7280;
align-items: center;
justify-content: center;
transition: background 0.15s;
&:hover { background: #F3F4F6; }
@media (max-width: 1023px) {
display: flex;
}
}
.logo {
display: flex;
align-items: center;
gap: 10px;
.logo-icon {
width: 34px;
height: 34px;
border-radius: 9px;
background: linear-gradient(135deg, #4F46E5 0%, #6D28D9 100%);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(79,70,229,0.30);
}
.logo-text-block {
display: flex;
align-items: center;
gap: 8px;
@media (max-width: 480px) { display: none; }
}
.logo-text {
font-size: 15px;
font-weight: 700;
color: #111827;
letter-spacing: -0.01em;
}
.logo-badge {
font-size: 10px;
font-weight: 600;
color: $primary;
background: $primary-soft;
border-radius: 5px;
padding: 2px 7px;
letter-spacing: 0.01em;
}
}
.header-user {
display: flex;
align-items: center;
gap: 7px;
padding: 5px 12px;
background: #F9FAFB;
border: 1px solid #EAECF0;
border-radius: 20px;
.user-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #10B981;
flex-shrink: 0;
}
.user-name {
font-size: 12.5px;
font-weight: 500;
color: #374151;
}
@media (max-width: 640px) { display: none; }
}
.logout-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: 1px solid #FECACA;
background: #FFF5F5;
color: #EF4444;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: #FEE2E2;
border-color: #FCA5A5;
}
span { @media (max-width: 640px) { display: none; } }
}
/* ── Sidebar ── */
.layout-sidebar {
position: fixed;
left: 0;
top: 56px;
top: $header-h;
bottom: 0;
width: 210px;
background-color: #ffffff;
border-right: 1px solid #f3f4f6;
width: $sidebar-w;
background: #fff;
border-right: 1px solid #EAECF0;
z-index: 999;
overflow-y: auto;
transition: transform 0.3s ease;
display: flex;
flex-direction: column;
transition: transform 0.28s cubic-bezier(0.4,0,0.2,1);
@media (max-width: 1023px) {
transform: translateX(-100%);
box-shadow: 4px 0 20px rgba(0,0,0,0.08);
&.sidebar-open {
transform: translateX(0);
}
}
}
.sidebar-nav {
padding: 12px 0;
display: flex;
flex-direction: column;
.sidebar-nav {
flex: 1;
overflow-y: auto;
padding: 16px 10px 8px;
}
.nav-item {
width: 100%;
height: 48px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 14px;
padding: 0 20px;
border-radius: 0;
font-size: 14px;
.nav-section {
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-section-label {
font-size: 10.5px;
font-weight: 600;
color: #9CA3AF;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 0 10px;
margin-bottom: 6px;
margin-top: 4px;
}
.nav-item {
display: flex;
align-items: center;
gap: 11px;
padding: 10px 12px;
border-radius: 10px;
font-size: 13.5px;
font-weight: 500;
color: #4B5563;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
width: 100%;
transition: all 0.15s;
position: relative;
&:hover {
background: #F9FAFB;
color: #111827;
.nav-icon-wrap {
background: #F3F4F6;
color: #374151;
transition: all 0.2s;
position: relative;
border: none;
background: transparent;
cursor: pointer;
margin-left: 0!important;
}
}
&:hover {
background-color: #f9fafb;
color: #111827;
}
&.active {
background: $primary-soft;
color: $primary;
font-weight: 600;
&.active {
background-color: var(--admin-primary-soft, #EEF2FF);
color: var(--admin-primary, #4F46E5);
font-weight: 500;
.nav-icon-wrap {
background: rgba(79,70,229,0.12);
color: $primary;
}
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background-color: var(--admin-primary, #4F46E5);
}
.nav-icon {
color: var(--admin-primary, #4F46E5);
}
}
.nav-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: #6b7280;
}
.nav-label {
flex: 1;
text-align: left;
margin-left: 0;
line-height: 1;
display: flex;
align-items: center;
}
&::before {
content: '';
position: absolute;
left: -10px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 20px;
background: $primary;
border-radius: 0 3px 3px 0;
}
}
}
.nav-icon-wrap {
width: 32px;
height: 32px;
border-radius: 8px;
background: #F3F4F6;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #6B7280;
transition: all 0.15s;
}
.nav-label {
flex: 1;
}
.nav-badge {
font-size: 10px;
font-weight: 700;
background: #FEE2E2;
color: #EF4444;
border-radius: 10px;
padding: 2px 7px;
}
.sidebar-footer {
padding: 12px 10px;
border-top: 1px solid #F3F4F6;
.sidebar-logout {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
border-radius: 8px;
border: none;
background: transparent;
color: #9CA3AF;
font-size: 12.5px;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: #FFF5F5;
color: #EF4444;
}
}
}
/* ── Overlay ── */
.sidebar-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.2);
background: rgba(0,0,0,0.25);
z-index: 998;
backdrop-filter: blur(1px);
@media (min-width: 1024px) {
display: none;
}
@media (min-width: 1024px) { display: none; }
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.25s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* ── Main ── */
.layout-main {
padding-top: 56px;
padding-top: $header-h;
min-height: 100vh;
background-color: #f9fafb;
background: #F4F6FB;
@media (min-width: 1024px) {
padding-left: 210px;
padding-left: $sidebar-w;
}
}
</style>

View File

@@ -4,29 +4,45 @@
<header class="layout-header">
<div class="header-content">
<div class="header-left">
<el-button
class="menu-toggle"
:icon="sidebarOpen ? Close : Menu"
circle
@click="toggleSidebar"
/>
<button class="menu-toggle" @click="toggleSidebar" :aria-label="sidebarOpen ? '关闭菜单' : '打开菜单'">
<svg v-if="!sidebarOpen" width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M3 5h14M3 10h14M3 15h14" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
</svg>
<svg v-else width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M5 5l10 10M15 5L5 15" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
</svg>
</button>
<div class="logo">
<div class="logo-icon">
<el-icon><Lock /></el-icon>
<!-- 超管 logo盾牌+星标 -->
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M12 2L3 6v6c0 5.25 3.75 10.15 9 11.25C17.25 22.15 21 17.25 21 12V6L12 2z" fill="rgba(255,255,255,0.2)" stroke="rgba(255,255,255,0.9)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 12l2 2 4-4" stroke="rgba(255,255,255,0.95)" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="logo-text-block">
<span class="logo-text">MBTI 超级管理</span>
<span class="logo-badge">Super Admin</span>
</div>
<span class="logo-text">超级管理后台</span>
</div>
</div>
<div class="header-right">
<el-button text class="nav-link admin-entry" @click="goAdminConsole">
<el-icon><Monitor /></el-icon>
<button class="admin-entry-btn" @click="goAdminConsole">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<rect x="3" y="3" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.75"/>
<path d="M8 21h8M12 17v4" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
</svg>
<span>管理后台</span>
</el-button>
<el-button text class="nav-link logout" @click="handleLogout">
<el-icon><SwitchButton /></el-icon>
</button>
<button class="logout-btn" @click="handleLogout">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="16 17 21 12 16 7" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="21" y1="12" x2="9" y2="12" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
</svg>
<span>退出</span>
</el-button>
</button>
</div>
</div>
</header>
@@ -34,62 +50,112 @@
<!-- 侧边栏 -->
<aside :class="['layout-sidebar', { 'sidebar-open': sidebarOpen }]">
<nav class="sidebar-nav">
<div class="nav-main">
<div class="nav-group-label">运营总控</div>
<!-- 主导航 -->
<div class="nav-section">
<div class="nav-section-label">运营总控</div>
<template v-for="item in navMainItems" :key="item.path">
<!-- 一级条目 -->
<el-button
:class="['nav-item', { active: isActive(item.path) || hasActiveChild(item) }]"
text
<button
:class="['nav-item', { active: isActive(item.path) || hasActiveChild(item), 'has-children': !!(item.children?.length) }]"
@click="onNavClick(item)"
>
<el-icon class="nav-icon">
<component :is="item.icon" />
</el-icon>
<span class="nav-icon-wrap">
<!-- 总览 -->
<svg v-if="item.key === 'ops'" width="16" height="16" viewBox="0 0 24 24" fill="none">
<rect x="3" y="3" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.75"/>
<rect x="14" y="3" width="7" height="4" rx="1.5" stroke="currentColor" stroke-width="1.75"/>
<rect x="3" y="14" width="7" height="7" rx="1.5" stroke="currentColor" stroke-width="1.75"/>
<rect x="14" y="11" width="7" height="10" rx="1.5" stroke="currentColor" stroke-width="1.75"/>
</svg>
<!-- 企业管理 -->
<svg v-else-if="item.key === 'enterprises'" width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M3 21V7l9-4 9 4v14" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 21V15h6v6" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 9h.01M12 9h.01M15 9h.01M9 12h.01M12 12h.01M15 12h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<!-- 订单和财务 -->
<svg v-else-if="item.key === 'commerce'" width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" stroke="currentColor" stroke-width="1.75"/>
<path d="M12 6v6l4 2" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14.5 16.5c-.83.5-1.62.5-2.5.5-2.76 0-5-2.24-5-5" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
</svg>
<!-- 分销管理 -->
<svg v-else-if="item.key === 'distribution'" width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="18" cy="5" r="3" stroke="currentColor" stroke-width="1.75"/>
<circle cx="6" cy="12" r="3" stroke="currentColor" stroke-width="1.75"/>
<circle cx="18" cy="19" r="3" stroke="currentColor" stroke-width="1.75"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
</svg>
<!-- 高考版管理 -->
<svg v-else-if="item.key === 'gaokao'" width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M22 10v6M2 10l10-5 10 5-10 5-10-5z" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 12v5c3 3 9 3 12 0v-5" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<!-- 智能算力 -->
<svg v-else-if="item.key === 'ai'" width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M12 2a4 4 0 014 4v1h1a3 3 0 013 3v2a3 3 0 01-3 3h-1v1a4 4 0 01-4 4 4 4 0 01-4-4v-1H7a3 3 0 01-3-3v-2a3 3 0 013-3h1V6a4 4 0 014-4z" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="9" cy="10" r="1" fill="currentColor"/>
<circle cx="15" cy="10" r="1" fill="currentColor"/>
<path d="M9.5 15c.83.5 1.66.75 2.5.75s1.67-.25 2.5-.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<!-- 默认 -->
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="1.75"/>
</svg>
</span>
<span class="nav-label">{{ item.label }}</span>
<el-icon v-if="item.children && item.children.length" class="nav-arrow">
<component :is="isExpanded(item.path) ? ArrowDown : ArrowRight" />
</el-icon>
</el-button>
<!-- 二级子菜单 -->
<svg
v-if="item.children?.length"
class="nav-arrow"
:class="{ 'rotated': isExpanded(item.path) || hasActiveChild(item) }"
width="14" height="14" viewBox="0 0 24 24" fill="none"
>
<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<!-- 子菜单 -->
<div
v-if="item.children && item.children.length && (isExpanded(item.path) || hasActiveChild(item))"
v-if="item.children?.length && (isExpanded(item.path) || hasActiveChild(item))"
class="nav-sub"
>
<el-button
<button
v-for="sub in item.children"
:key="sub.path"
:class="['nav-item nav-item--sub', { active: isActive(sub.path) }]"
text
:class="['nav-sub-item', { active: isActive(sub.path) }]"
@click="navigateTo(sub.path)"
>
<span class="nav-sub-dot"></span>
<span class="nav-label">{{ sub.label }}</span>
</el-button>
<span>{{ sub.label }}</span>
</button>
</div>
</template>
</div>
<div class="nav-footer-block">
<div class="nav-group-label">系统</div>
<el-button
<!-- 系统 -->
<div class="nav-section nav-section--footer">
<div class="nav-section-label">系统</div>
<button
v-for="item in navFooterItems"
:key="item.path"
:class="['nav-item', { active: isActive(item.path) }]"
text
@click="navigateTo(item.path)"
>
<el-icon class="nav-icon">
<component :is="item.icon" />
</el-icon>
<span class="nav-icon-wrap">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.75"/>
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" stroke="currentColor" stroke-width="1.75"/>
</svg>
</span>
<span class="nav-label">{{ item.label }}</span>
</el-button>
</button>
</div>
</nav>
</aside>
<!-- 遮罩层移动端 -->
<div v-if="sidebarOpen" class="sidebar-overlay" @click="toggleSidebar" />
<!-- 遮罩层 -->
<transition name="fade">
<div v-if="sidebarOpen" class="sidebar-overlay" @click="toggleSidebar" />
</transition>
<!-- 主内容区 -->
<main class="layout-main">
@@ -102,21 +168,6 @@
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import {
Menu,
Close,
Lock,
SwitchButton,
TrendCharts,
ShoppingCart,
Cpu,
Setting,
OfficeBuilding,
Share,
Monitor,
ArrowDown,
ArrowRight
} from '@element-plus/icons-vue'
const router = useRouter()
const route = useRoute()
@@ -126,45 +177,40 @@ const sidebarOpen = ref(false)
interface NavItem {
path: string
icon: any
key: string
label: string
children?: NavItem[]
}
const navMainItems: NavItem[] = [
{ path: '/superadmin/ops', icon: TrendCharts, label: '总览' },
{ path: '/superadmin/enterprises', icon: OfficeBuilding, label: '企业管理' },
{ path: '/superadmin/commerce', icon: ShoppingCart, label: '订单和财务' },
{ path: '/superadmin/distribution', icon: Share, label: '分销管理' },
{ path: '/superadmin/ai-config', icon: Cpu, label: '智能算力' }
{ path: '/superadmin/ops', key: 'ops', label: '总览' },
{ path: '/superadmin/enterprises', key: 'enterprises', label: '企业管理' },
{ path: '/superadmin/commerce', key: 'commerce', label: '订单和财务' },
{ path: '/superadmin/distribution', key: 'distribution', label: '分销管理' },
{ path: '/superadmin/gaokao', key: 'gaokao', label: '高考版管理' },
{ path: '/superadmin/ai-config', key: 'ai', label: '智能算力' }
]
const navFooterItems: NavItem[] = [
{ path: '/superadmin/settings', icon: Setting, label: '系统设置' }
{ path: '/superadmin/settings', key: 'settings', label: '系统设置' }
]
const expandedPaths = ref<Set<string>>(new Set())
const isActive = (path: string) => {
return route.path === path || route.path.startsWith(`${path}/`)
}
const isActive = (path: string) => route.path === path || route.path.startsWith(`${path}/`)
const hasActiveChild = (item: NavItem): boolean => {
if (!item.children) return false
return item.children.some(c => isActive(c.path))
}
const hasActiveChild = (item: NavItem): boolean =>
!!(item.children?.some(c => isActive(c.path)))
const isExpanded = (p: string) => expandedPaths.value.has(p)
const onNavClick = (item: NavItem) => {
if (item.children && item.children.length) {
// 有子菜单 → 切换展开
if (item.children?.length) {
if (expandedPaths.value.has(item.path)) {
expandedPaths.value.delete(item.path)
} else {
expandedPaths.value.add(item.path)
}
// 首次展开时,若当前没选中任何子项,就默认跳第一个子项
if (expandedPaths.value.has(item.path) && !hasActiveChild(item)) {
navigateTo(item.children[0].path)
}
@@ -184,37 +230,38 @@ const toggleSidebar = () => {
const navigateTo = (path: string) => {
router.push(path)
if (window.innerWidth < 1024) {
sidebarOpen.value = false
}
if (window.innerWidth < 1024) sidebarOpen.value = false
}
const handleLogout = async () => {
try {
await authStore.superAdminLogout()
router.push('/superadmin/login')
} catch (error) {
console.error('退出登录失败:', error)
} catch {
router.push('/superadmin/login')
}
}
</script>
<style scoped lang="scss">
$primary: #1E40AF;
$primary-soft: #DBEAFE;
$sidebar-w: 240px;
$header-h: 60px;
.superadmin-layout {
min-height: 100vh;
background-color: #f8fafc;
background: #F1F5F9;
}
/* ── Header ── */
.layout-header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 64px;
background-color: #ffffff;
border-bottom: 1px solid #f3f4f6;
top: 0; left: 0; right: 0;
height: $header-h;
background: linear-gradient(135deg, #1E3A8A 0%, #1D4ED8 100%);
z-index: 1000;
box-shadow: 0 2px 16px rgba(30,58,138,0.35);
.header-content {
display: flex;
@@ -227,221 +274,310 @@ const handleLogout = async () => {
.header-left {
display: flex;
align-items: center;
gap: 16px;
.menu-toggle {
@media (min-width: 1024px) {
display: none;
}
}
.logo {
display: flex;
align-items: center;
gap: 10px;
.logo-icon {
width: 36px;
height: 36px;
border-radius: 8px;
background-image: linear-gradient(135deg, #1E40AF 0%, #3730A3 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 18px;
box-shadow: 0 2px 8px rgba(30, 64, 175, 0.3);
}
.logo-text {
font-weight: 700;
font-size: 16px;
color: #111827;
@media (max-width: 640px) {
display: none;
}
}
}
gap: 14px;
}
.header-right {
display: flex;
align-items: center;
gap: 4px;
.nav-link {
font-size: 13px;
color: #6b7280;
padding: 4px 10px;
display: flex;
align-items: center;
gap: 4px;
height: auto;
&:hover {
background-color: transparent;
color: #111827;
}
&.admin-entry {
color: #1E40AF;
font-weight: 600;
}
&.logout {
color: #64748b;
}
.el-icon {
font-size: 16px;
}
}
gap: 10px;
}
}
.menu-toggle {
display: none;
width: 36px; height: 36px;
border: none;
background: rgba(255,255,255,0.1);
border-radius: 8px;
cursor: pointer;
color: #fff;
align-items: center;
justify-content: center;
transition: background 0.15s;
&:hover { background: rgba(255,255,255,0.18); }
@media (max-width: 1023px) { display: flex; }
}
.logo {
display: flex;
align-items: center;
gap: 10px;
.logo-icon {
width: 36px; height: 36px;
border-radius: 10px;
background: rgba(255,255,255,0.15);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border: 1px solid rgba(255,255,255,0.25);
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
}
.logo-text-block {
display: flex;
align-items: center;
gap: 8px;
@media (max-width: 480px) { display: none; }
}
.logo-text {
font-size: 15px;
font-weight: 700;
color: #fff;
letter-spacing: -0.01em;
}
.logo-badge {
font-size: 9.5px;
font-weight: 700;
color: #93C5FD;
background: rgba(147,197,253,0.15);
border: 1px solid rgba(147,197,253,0.3);
border-radius: 5px;
padding: 2px 7px;
letter-spacing: 0.04em;
}
}
.admin-entry-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: 1px solid rgba(255,255,255,0.25);
background: rgba(255,255,255,0.12);
color: #fff;
border-radius: 8px;
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: rgba(255,255,255,0.22);
border-color: rgba(255,255,255,0.4);
}
span { @media (max-width: 640px) { display: none; } }
}
.logout-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: 1px solid rgba(255,255,255,0.15);
background: transparent;
color: rgba(255,255,255,0.7);
border-radius: 8px;
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: rgba(255,255,255,0.1);
color: #fff;
border-color: rgba(255,255,255,0.3);
}
span { @media (max-width: 640px) { display: none; } }
}
/* ── Sidebar ── */
.layout-sidebar {
position: fixed;
left: 0;
top: 64px;
top: $header-h;
bottom: 0;
width: 240px;
background-color: #ffffff;
border-right: 1px solid #f3f4f6;
width: $sidebar-w;
background: #fff;
border-right: 1px solid #E2E8F0;
z-index: 999;
display: flex;
flex-direction: column;
overflow-y: auto;
transition: transform 0.3s ease;
transition: transform 0.28s cubic-bezier(0.4,0,0.2,1);
@media (max-width: 1023px) {
transform: translateX(-100%);
box-shadow: 4px 0 24px rgba(0,0,0,0.1);
&.sidebar-open {
transform: translateX(0);
}
&.sidebar-open { transform: translateX(0); }
}
}
.sidebar-nav {
flex: 1;
padding: 16px 10px;
display: flex;
flex-direction: column;
gap: 20px;
}
.nav-section {
display: flex;
flex-direction: column;
gap: 2px;
&--footer {
border-top: 1px solid #F1F5F9;
padding-top: 16px;
}
}
.nav-section-label {
font-size: 10.5px;
font-weight: 700;
color: #94A3B8;
letter-spacing: 0.07em;
text-transform: uppercase;
padding: 0 10px;
margin-bottom: 6px;
}
.nav-item {
display: flex;
align-items: center;
gap: 11px;
padding: 10px 12px;
border-radius: 10px;
font-size: 13.5px;
font-weight: 500;
color: #4B5563;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
width: 100%;
transition: all 0.15s;
position: relative;
&:hover {
background: #F8FAFC;
color: #1E293B;
.nav-icon-wrap { background: #F1F5F9; color: #334155; }
}
.sidebar-nav {
padding: 12px 0 20px;
display: flex;
flex-direction: column;
min-height: calc(100vh - 64px - 32px);
}
&.active {
background: $primary-soft;
color: $primary;
font-weight: 600;
.nav-main {
flex: 1;
}
.nav-footer-block {
border-top: 1px solid #f3f4f6;
padding-top: 10px;
margin-top: 8px;
}
.nav-item {
width: 100%;
height: 48px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
padding: 0 24px;
border-radius: 0;
font-size: 14px;
color: #4b5563;
transition: all 0.2s;
position: relative;
margin-left: 0 !important;
&:hover {
background-color: #f9fafb;
color: #111827;
.nav-icon-wrap {
background: rgba(30,64,175,0.1);
color: $primary;
}
&.active {
background-color: #E0E7FF;
color: #1E40AF;
font-weight: 600;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background-color: #1E40AF;
}
.nav-icon {
color: #1E40AF;
}
}
.nav-icon {
font-size: 18px;
}
.nav-label {
flex: 1;
text-align: left;
}
.nav-arrow {
font-size: 12px;
color: #9CA3AF;
}
}
.nav-sub {
display: flex;
flex-direction: column;
background: #f9fafb;
border-left: 2px solid #e5e7eb;
margin: 0 0 4px 24px;
}
.nav-item--sub {
height: 40px;
padding: 0 20px;
font-size: 13px;
color: #6B7280;
.nav-sub-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #D1D5DB;
}
&.active {
background-color: #EEF2FF;
color: #1E40AF;
.nav-sub-dot {
background: #1E40AF;
}
&::before {
content: '';
position: absolute;
left: -10px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 20px;
background: $primary;
border-radius: 0 3px 3px 0;
}
}
}
.nav-icon-wrap {
width: 32px; height: 32px;
border-radius: 8px;
background: #F1F5F9;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #6B7280;
transition: all 0.15s;
}
.nav-label { flex: 1; }
.nav-arrow {
color: #CBD5E1;
transition: transform 0.2s;
&.rotated { transform: rotate(180deg); }
}
.nav-sub {
display: flex;
flex-direction: column;
gap: 1px;
margin: 2px 0 4px 42px;
padding-left: 12px;
border-left: 2px solid #E2E8F0;
}
.nav-sub-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-radius: 7px;
font-size: 12.5px;
font-weight: 500;
color: #64748B;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
width: 100%;
transition: all 0.15s;
&:hover {
background: #F8FAFC;
color: #1E293B;
}
&.active {
color: $primary;
font-weight: 600;
.nav-sub-dot { background: $primary; }
}
}
.nav-sub-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: #CBD5E1;
flex-shrink: 0;
transition: background 0.15s;
}
/* ── Overlay ── */
.sidebar-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.2);
background: rgba(0,0,0,0.3);
z-index: 998;
backdrop-filter: blur(1px);
@media (min-width: 1024px) {
display: none;
}
@media (min-width: 1024px) { display: none; }
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.25s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
/* ── Main ── */
.layout-main {
padding-top: 64px;
padding-top: $header-h;
min-height: 100vh;
background-color: #f9fafb;
background: #F1F5F9;
@media (min-width: 1024px) {
padding-left: 240px;
padding-left: $sidebar-w;
}
}
</style>

View File

@@ -33,13 +33,13 @@ const routes: RouteRecordRaw[] = [
path: 'users',
name: 'AdminUsers',
component: () => import('@/views/admin/UsersHub.vue'),
meta: { title: '用户运营' }
meta: { title: '用户管理' }
},
{
path: 'orders',
name: 'AdminOrders',
component: () => import('@/views/admin/OrdersHub.vue'),
meta: { title: '订单运营' }
meta: { title: '订单管理' }
},
{
path: 'distribution',
@@ -48,10 +48,10 @@ const routes: RouteRecordRaw[] = [
meta: { title: '分销推广' }
},
{
// 合作意向已合并到用户运营 Tab旧路径自动重定向
path: 'cooperation-choices',
name: 'AdminCooperationChoices',
component: () => import('@/views/admin/CooperationChoices.vue'),
meta: { title: '合作意向' }
redirect: { path: '/admin/users', query: { tab: 'cooperation' } }
},
{
path: 'questions',
@@ -170,6 +170,12 @@ const routes: RouteRecordRaw[] = [
return { path: '/superadmin/enterprises', query: q }
}
},
{
path: 'gaokao',
name: 'SuperAdminGaokao',
component: () => import('@/views/superadmin/GaokaoHub.vue'),
meta: { title: '高考版管理' }
},
{
path: 'settings',
name: 'SuperAdminSettings',
@@ -199,10 +205,18 @@ const routes: RouteRecordRaw[] = [
]
},
// 默认重定向
// 小程序界面预览(无需鉴权)
{
path: '/miniprogram-preview',
name: 'MiniProgramPreview',
component: () => import('@/views/MiniProgramPreview.vue'),
meta: { title: '小程序界面预览' }
},
// 默认重定向到小程序预览v0 预览窗口入口)
{
path: '/',
redirect: '/admin/login'
redirect: '/miniprogram-preview'
}
]
@@ -222,6 +236,12 @@ router.beforeEach((to, _from, next) => {
const saToken = getSuperadminToken()
const saRole = getSuperadminRole()
// 小程序预览页无需鉴权
if (to.path === '/miniprogram-preview') {
next()
return
}
if (to.path.startsWith('/admin') && to.path !== '/admin/login') {
if (!adminToken || !adminRole || !['admin', 'enterprise_admin'].includes(adminRole)) {
clearAdminAuthKeys()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
<div class="page-header">
<div class="header-left">
<h2>分销推广</h2>
<p class="subtitle">分销商佣金结算提现审核与推广设置</p>
<p class="subtitle">分销商为核心 · 实时收益团队绑定佣金结算与推广设置</p>
</div>
<div class="header-actions">
<el-button @click="refresh" class="refresh-btn">
@@ -27,153 +27,142 @@
</div>
<div class="tab-content">
<div v-if="activeTab === 'overview'" class="overview-section">
<!-- 核心指标 -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-info">
<div class="stat-label">分销商总数</div>
<div class="stat-value">{{ overview.totalAgents }}</div>
<div class="trend-tag up">
<el-icon><CaretTop /></el-icon>今日+{{ overview.todayAgents }}
</div>
<!-- 分销商 Tab含数据概览 -->
<div v-if="activeTab === 'distributors'" class="distributors-section">
<!-- 顶部 KPI 卡片 -->
<div class="dist-kpi-grid">
<div class="dist-kpi-card">
<div class="dist-kpi-body">
<div class="dist-kpi-label">分销商总数</div>
<div class="dist-kpi-value">{{ overview.totalAgents }}</div>
<div class="dist-kpi-foot up"><el-icon><CaretTop /></el-icon>今日 +{{ overview.todayAgents }}</div>
</div>
<div class="stat-icon purple"><el-icon><User /></el-icon></div>
<div class="dist-kpi-icon ic-purple"><el-icon><User /></el-icon></div>
</div>
<div class="stat-card">
<div class="stat-info">
<div class="stat-label">累计佣金</div>
<div class="stat-value">{{ formatCurrency(parseFloat(overview.totalCommission)) }}</div>
<div class="trend-tag up">
<el-icon><CaretTop /></el-icon>今日+{{ formatCurrency(parseFloat(overview.todayCommission)) }}
</div>
<div class="dist-kpi-card">
<div class="dist-kpi-body">
<div class="dist-kpi-label">累计佣金</div>
<div class="dist-kpi-value">{{ formatCurrency(parseFloat(overview.totalCommission)) }}</div>
<div class="dist-kpi-foot up"><el-icon><CaretTop /></el-icon>今日 +{{ formatCurrency(parseFloat(overview.todayCommission)) }}</div>
</div>
<div class="stat-icon green"><el-icon><Money /></el-icon></div>
<div class="dist-kpi-icon ic-green"><el-icon><Money /></el-icon></div>
</div>
<div class="stat-card">
<div class="stat-info">
<div class="stat-label">待结算佣金</div>
<div class="stat-value">{{ formatCurrency(parseFloat(overview.pendingCommission)) }}</div>
<div class="trend-tag warning">{{ overview.pendingCount }}</div>
<div class="dist-kpi-card">
<div class="dist-kpi-body">
<div class="dist-kpi-label">待结算佣金</div>
<div class="dist-kpi-value">{{ formatCurrency(parseFloat(overview.pendingCommission)) }}</div>
<div class="dist-kpi-foot warning">{{ overview.pendingCount }} 待结算</div>
</div>
<div class="stat-icon orange"><el-icon><Clock /></el-icon></div>
<div class="dist-kpi-icon ic-orange"><el-icon><Clock /></el-icon></div>
</div>
<div class="stat-card">
<div class="stat-info">
<div class="stat-label">绑定关系</div>
<div class="stat-value">{{ overview.bindingCount }}</div>
<div class="trend-tag neutral">有效绑定</div>
<div class="dist-kpi-card">
<div class="dist-kpi-body">
<div class="dist-kpi-label">有效绑定</div>
<div class="dist-kpi-value">{{ overview.bindingCount }}</div>
<div class="dist-kpi-foot neutral">绑定中用户</div>
</div>
<div class="stat-icon blue"><el-icon><Connection /></el-icon></div>
<div class="dist-kpi-icon ic-blue"><el-icon><Connection /></el-icon></div>
</div>
</div>
<!-- 图表区域 -->
<div class="charts-grid">
<div class="chart-card">
<div class="card-header">
<el-icon><TrendCharts /></el-icon>
<span>近7天佣金趋势</span>
<!-- 图表 + Top 10 排行 -->
<div class="dist-charts-row">
<div class="dist-chart-card dist-chart-card--trend">
<div class="dist-chart-header">
<span> 7 天佣金趋势</span>
</div>
<VChart v-if="hasCommissionTrend" class="trend-chart-echarts" :option="commissionTrendOption" autoresize />
<div v-else class="empty-chart">暂无趋势数据</div>
<VChart v-if="hasCommissionTrend" class="dist-chart-canvas" :option="commissionTrendOption" autoresize />
<div v-else class="dist-empty-chart">暂无趋势数据</div>
</div>
<div class="chart-card">
<div class="card-header">
<el-icon><PieChart /></el-icon>
<span>产品佣金分布</span>
</div>
<VChart v-if="hasProductSeries" class="trend-chart-echarts" :option="productSeriesOption" autoresize />
<div v-else class="empty-chart">暂无产品数据</div>
<div class="dist-chart-card dist-chart-card--product">
<div class="dist-chart-header"><span>产品佣金分布</span></div>
<VChart v-if="hasProductSeries" class="dist-chart-canvas" :option="productSeriesOption" autoresize />
<div v-else class="dist-empty-chart">暂无产品数据</div>
</div>
<div class="chart-card top-ranking-card">
<div class="card-header">
<el-icon><UserFilled /></el-icon>
<span>分销商 Top 10 排行榜</span>
<span class="head-hint">点击查看订单定位该分销商带来的订单</span>
<div class="dist-chart-card dist-chart-card--top10">
<div class="dist-chart-header">
<span>Top 10 分销商</span>
<span class="dist-chart-hint">按累计佣金排序</span>
</div>
<div v-if="overviewTopDistributors.length" class="overview-list">
<div v-for="(item, idx) in overviewTopDistributors" :key="item.id" class="overview-list-item">
<div class="overview-main">
<span :class="['rank-badge', idx < 3 ? 'rank-top' : '']">{{ idx + 1 }}</span>
<div class="agent-cell">
<el-avatar :size="30" :src="item.avatar" class="agent-avatar">
{{ item.agentName ? item.agentName[0] : '?' }}
</el-avatar>
<div class="overview-meta">
<strong>{{ item.agentName || '-' }}</strong>
<span>团队 {{ item.teamCount || 0 }} </span>
</div>
</div>
</div>
<div class="overview-side">
<strong>{{ formatCurrency(parseFloat(item.totalCommission || 0)) }}</strong>
<el-button link type="primary" size="small" @click="gotoInviterOrders(item)">查看订单</el-button>
</div>
<div v-if="overviewTopDistributors.length" class="top10-list">
<div v-for="(item, idx) in overviewTopDistributors" :key="item.id" class="top10-item">
<span :class="['top10-rank', idx < 3 ? 'top10-rank--gold' : '']">{{ idx + 1 }}</span>
<el-avatar :size="28" :src="item.avatar" class="top10-avatar">{{ item.agentName ? item.agentName[0] : '?' }}</el-avatar>
<span class="top10-name">{{ item.agentName || '-' }}</span>
<span class="top10-team">{{ item.teamCount || 0 }}</span>
<span class="top10-comm">{{ formatCurrency(parseFloat(item.totalCommission || 0)) }}</span>
<el-button link type="primary" size="small" @click="gotoUserDetail(item)">档案</el-button>
<el-button link type="primary" size="small" @click="gotoInviterOrders(item)">订单</el-button>
</div>
</div>
<div v-else class="empty-chart">暂无分销商数据</div>
</div>
<div class="chart-card">
<div class="card-header">
<el-icon><List /></el-icon>
<span>佣金统计</span>
</div>
<div class="withdraw-stats">
<div class="stat-item green"><span>累计佣金</span><strong>{{ formatCurrency(parseFloat(overview.totalCommission)) }}</strong></div>
<div class="stat-item orange"><span>今日新增</span><strong>{{ formatCurrency(parseFloat(overview.todayCommission)) }}</strong></div>
<div class="stat-item blue"><span>待结算佣金</span><strong>{{ formatCurrency(parseFloat(overview.pendingCommission)) }}</strong></div>
</div>
<div v-else class="dist-empty-chart">暂无排行数据</div>
</div>
</div>
</div>
<div v-else-if="activeTab === 'distributors'" class="table-section">
<!-- 分销商列表 -->
<div class="content-card">
<div class="toolbar">
<el-input v-model="distSearch" placeholder="搜索用户名..." class="search-input">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button type="primary" @click="loadDistributors">搜索</el-button>
<div style="display:flex;gap:8px;align-items:center">
<el-input v-model="distSearch" placeholder="搜索用户名 / 手机号..." class="search-input" @keyup.enter="loadDistributors">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button type="primary" @click="loadDistributors">搜索</el-button>
</div>
<span class="toolbar-count"> {{ overview.totalAgents }} 位分销商</span>
</div>
<el-table :data="distributors" style="width: 100%" class="custom-table" v-loading="loading">
<el-table-column label="分销商" min-width="160">
<el-table :data="distributors" style="width: 100%" class="custom-table dist-table" v-loading="loading" row-key="id">
<el-table-column label="分销商" min-width="180">
<template #default="{ row }">
<div class="agent-cell">
<el-avatar :size="36" :src="row.avatar" class="agent-avatar">
{{ row.agentName ? row.agentName[0] : '?' }}
</el-avatar>
<span class="agent-name">{{ row.agentName || '-' }}</span>
<div class="agent-avatar-wrap">
<el-avatar :size="36" :src="row.avatar" class="agent-avatar">{{ row.agentName ? row.agentName[0] : '?' }}</el-avatar>
</div>
<div class="agent-info">
<span class="agent-name">{{ row.agentName || '-' }}</span>
<span v-if="row.phone" class="agent-phone">{{ row.phone }}</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="累计收益" align="right">
<el-table-column label="累计收益" align="right" min-width="110">
<template #default="{ row }">
<el-button link type="primary" @click="openCommDetail(row)">
{{ formatCurrency(parseFloat(row.totalCommission || 0)) }}
</el-button>
<div class="comm-cell">
<el-button link type="primary" @click="openCommDetail(row)" class="comm-value">
{{ formatCurrency(parseFloat(row.totalCommission || 0)) }}
</el-button>
<span class="comm-sub">点击查看明细</span>
</div>
</template>
</el-table-column>
<el-table-column label="可提现" align="right">
<el-table-column label="可提现" align="right" width="110">
<template #default="{ row }">
{{ formatCurrency(parseFloat(row.availableCommission || 0)) }}
<span :class="['avail-value', parseFloat(row.availableCommission || 0) > 0 ? 'avail-value--pos' : '']">
{{ formatCurrency(parseFloat(row.availableCommission || 0)) }}
</span>
</template>
</el-table-column>
<el-table-column label="团队人数" align="center">
<el-table-column label="团队人数" align="center" width="100">
<template #default="{ row }">
<el-button link type="primary" @click="openTeamDetail(row)">
{{ row.teamCount }}
</el-button>
<el-button link type="primary" @click="openTeamDetail(row)">{{ row.teamCount || 0 }} </el-button>
</template>
</el-table-column>
<el-table-column label="加入时间" min-width="160">
<el-table-column label="状态" align="center" width="90">
<template #default="{ row }">
{{ row.createdAt ? new Date(row.createdAt * 1000).toLocaleString() : '-' }}
<el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small" effect="light">
{{ row.status === 'active' ? '活跃' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="110" align="center" fixed="right">
<el-table-column label="加入时间" width="130">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="gotoInviterOrders(row)">该分销商订单</el-button>
<span class="time-cell">{{ row.createdAt ? new Date(row.createdAt * 1000).toLocaleDateString() : '-' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="gotoUserDetail(row)">用户档案</el-button>
<el-button link type="primary" size="small" @click="gotoInviterOrders(row)">订单</el-button>
<el-button link size="small" @click="openCommDetail(row)">佣金</el-button>
</template>
</el-table-column>
</el-table>
@@ -414,13 +403,12 @@ use([CanvasRenderer, LineChart, BarChart, EchartsPieChart, GridComponent, Toolti
const router = useRouter()
const activeTab = ref('overview')
const activeTab = ref('distributors')
const distSearch = ref('')
const commFilter = ref('')
const loading = ref(false)
const tabs = [
{ label: '数据概览', value: 'overview' },
{ label: '分销商', value: 'distributors' },
{ label: '佣金记录', value: 'commissions' },
{ label: '分销设置', value: 'settings' }
@@ -574,7 +562,7 @@ const distEnabled = ref(true)
const promoCenterTitle = ref('推广中心')
const testTypeItems = [
{ key: 'face', label: '脸分析' },
{ key: 'face', label: '<EFBFBD><EFBFBD><EFBFBD>脸分析' },
{ key: 'mbti', label: 'MBTI 测试' },
{ key: 'sbti', label: 'SBTI 测试' },
{ key: 'disc', label: 'DISC 测试' },
@@ -743,11 +731,10 @@ const saveSettings = async () => {
}
}
// 监听tab切换
// 监听 tab 切换
watch(activeTab, (newTab) => {
if (newTab === 'overview') {
if (newTab === 'distributors') {
loadOverview()
} else if (newTab === 'distributors') {
loadDistributors()
} else if (newTab === 'commissions') {
loadCommissions()
@@ -774,6 +761,19 @@ function gotoInviterOrders(row: any) {
})
}
function gotoUserDetail(row: any) {
// 优先使用 userId其次 id分销商自身用户 ID
const userId = row?.userId ?? row?.id
if (!userId) {
ElMessage.warning('无法获取用户 ID')
return
}
router.push({
path: '/admin/users',
query: { openUserId: String(userId) },
})
}
const refresh = async () => {
if (activeTab.value === 'overview') {
await loadOverview()
@@ -786,45 +786,59 @@ const refresh = async () => {
}
onMounted(() => {
// 默认加载分销商面板含数<E590AB><E695B0><EFBFBD>概览
loadOverview()
loadDistributors()
})
</script>
<style scoped lang="scss">
.page-container {
min-height: calc(100vh - 56px);
background: #F4F6FB;
padding: 20px 24px 24px;
box-sizing: border-box;
}
.page-header {
display: flex;
align-items: center;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 24px;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
.header-left {
h2 {
font-size: 22px;
font-weight: 700;
font-weight: 800;
color: #111827;
margin: 0 0 4px 0;
letter-spacing: -0.02em;
}
.subtitle {
font-size: 13px;
color: #6b7280;
font-size: 12.5px;
color: #6B7280;
margin: 0;
}
}
.refresh-btn {
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 8px 12px;
border: 1px solid #E5E7EB;
border-radius: 8px;
padding: 7px 14px;
font-size: 13px;
color: #374151;
height: 34px;
background: #fff;
height: auto;
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
&:hover {
background-color: #f9fafb;
border-color: #d1d5db;
background-color: #F9FAFB;
border-color: #D1D5DB;
}
}
}
@@ -840,13 +854,15 @@ onMounted(() => {
.stat-card {
background: #fff;
border-radius: 10px;
padding: 20px;
border-radius: 14px;
padding: 18px 20px;
display: flex;
justify-content: space-between;
align-items: center;
border: 1px solid #f3f4f6;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
border: 1px solid #E5E7EB;
box-shadow: 0 1px 3px rgba(16,24,40,0.04), 0 4px 12px rgba(16,24,40,0.03);
transition: transform 0.18s, box-shadow 0.18s;
&:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(79,70,229,0.1); }
.stat-info {
.stat-label {
@@ -1281,4 +1297,197 @@ onMounted(() => {
@media (max-width: 640px) {
.settings-card .ts-grid { grid-template-columns: 1fr; }
}
/* ── 新分销商面板 ── */
.distributors-section {
display: flex;
flex-direction: column;
gap: 16px;
}
/* KPI 卡片 */
.dist-kpi-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
@media (max-width: 900px) { grid-template-columns: repeat(2, 1fr); }
}
.dist-kpi-card {
background: #fff;
border: 1px solid #E5E7EB;
border-radius: 14px;
padding: 18px 18px 16px;
display: flex;
justify-content: space-between;
align-items: flex-start;
box-shadow: 0 1px 4px rgba(16,24,40,0.04);
transition: transform 0.18s, box-shadow 0.18s;
&:hover { transform: translateY(-2px); box-shadow: 0 8px 20px rgba(79,70,229,0.08); }
.dist-kpi-label { font-size: 12.5px; color: #6B7280; margin-bottom: 6px; }
.dist-kpi-value { font-size: 22px; font-weight: 800; color: #111827; line-height: 1; letter-spacing: -0.02em; }
.dist-kpi-foot {
font-size: 11.5px;
margin-top: 7px;
display: flex;
align-items: center;
gap: 3px;
&.up { color: #22c55e; }
&.warning { color: #f59e0b; }
&.neutral { color: #3b82f6; }
}
.dist-kpi-icon {
width: 38px;
height: 38px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
}
.ic-purple { background: #faf5ff; color: #8b5cf6; }
.ic-green { background: #ecfdf5; color: #10b981; }
.ic-orange { background: #fffbeb; color: #f59e0b; }
.ic-blue { background: #eff6ff; color: #3b82f6; }
/* 图表行 */
.dist-charts-row {
display: grid;
grid-template-columns: 1.6fr 1fr 1.4fr;
gap: 14px;
@media (max-width: 1100px) { grid-template-columns: 1fr 1fr; }
@media (max-width: 700px) { grid-template-columns: 1fr; }
}
.dist-chart-card {
background: #fff;
border: 1px solid #E5E7EB;
border-radius: 14px;
padding: 18px 18px 14px;
box-shadow: 0 1px 3px rgba(16,24,40,0.04);
}
.dist-chart-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
span:first-child {
font-size: 13.5px;
font-weight: 700;
color: #111827;
}
.dist-chart-hint {
font-size: 11px;
color: #94a3b8;
font-weight: 400;
}
}
.dist-chart-canvas {
height: 180px;
width: 100%;
}
.dist-empty-chart {
height: 150px;
display: flex;
align-items: center;
justify-content: center;
color: #CBD5E1;
font-size: 13px;
}
/* Top 10 排行 */
.top10-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 220px;
overflow-y: auto;
}
.top10-item {
display: grid;
grid-template-columns: 22px 28px 1fr 40px 70px 42px;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: 8px;
font-size: 12.5px;
transition: background 0.12s;
&:hover { background: #f5f3ff; }
}
.top10-rank {
width: 20px;
height: 20px;
border-radius: 50%;
background: #e2e8f0;
color: #475569;
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&--gold { background: #fef3c7; color: #b45309; }
}
.top10-avatar { flex-shrink: 0; background: #ede9fe; color: #7c3aed; }
.top10-name { font-size: 12.5px; font-weight: 600; color: #111827; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.top10-team { font-size: 11px; color: #9ca3af; text-align: center; }
.top10-comm { font-size: 12.5px; font-weight: 700; color: #4f46e5; text-align: right; }
/* 分销商列表补充样式 */
.dist-table :deep(.el-table__row) {
cursor: pointer;
&:hover td { background: #f5f3ff !important; }
}
.agent-info {
display: flex;
flex-direction: column;
gap: 2px;
.agent-name { font-size: 13px; font-weight: 600; color: #111827; }
.agent-phone { font-size: 11.5px; color: #9ca3af; }
}
.agent-avatar-wrap { flex-shrink: 0; }
.comm-cell {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 1px;
.comm-value { font-weight: 700; }
.comm-sub { font-size: 10.5px; color: #d1d5db; }
}
.avail-value {
font-size: 13px;
color: #9ca3af;
&--pos { color: #10b981; font-weight: 700; }
}
.toolbar-count {
font-size: 12.5px;
color: #9ca3af;
}
.time-cell {
font-size: 12px;
color: #6b7280;
}
</style>

View File

@@ -1,8 +1,10 @@
<template>
<div class="orders-hub">
<div class="hub-header">
<h2>订单运营</h2>
<p class="hub-subtitle">订单成交测试定价与题库在同一入口完成</p>
<div>
<h2>订单管理</h2>
<p class="hub-subtitle">支付订单成交状态与关联数据</p>
</div>
</div>
<div class="custom-tabs-container tabs-scroll">
@@ -19,11 +21,7 @@
</div>
<div class="hub-body flat">
<Orders
v-if="activeTab === 'orders'"
embedded
orders-api-path="/admin/orders"
/>
<Orders v-if="activeTab === 'orders'" embedded orders-api-path="/admin/orders" />
<Pricing v-if="activeTab === 'pricing'" embedded />
<Questions v-if="activeTab === 'questions'" embedded />
</div>
@@ -71,9 +69,7 @@ const selectTab = (tab: TabId) => {
q[k] = Array.isArray(v) ? String(v[0]) : String(v)
}
})
if (tab !== 'orders') {
q.tab = tab
}
if (tab !== 'stats') q.tab = tab
router.replace({ path: '/admin/orders', query: Object.keys(q).length ? q : {} })
}
@@ -86,31 +82,38 @@ onMounted(() => {
<style scoped lang="scss">
.orders-hub {
padding: 24px;
min-height: 100vh;
background: #f9fafb;
min-height: calc(100vh - 56px);
background: #F4F6FB;
display: flex;
flex-direction: column;
}
.hub-header {
margin-bottom: 20px;
padding: 20px 24px 0;
margin-bottom: 0;
h2 {
margin: 0 0 6px;
margin: 0 0 4px;
font-size: 22px;
font-weight: 700;
font-weight: 800;
color: #111827;
letter-spacing: -0.02em;
}
.hub-subtitle {
margin: 0;
font-size: 14px;
color: #6b7280;
font-size: 12.5px;
color: #6B7280;
}
}
/* .custom-tabs-container 视觉已统一在 admin-theme.css */
.custom-tabs-container {
padding: 16px 24px 0;
}
.hub-body.flat {
flex: 1;
width: 100%;
background: #F4F6FB;
}
</style>

View File

@@ -3,11 +3,10 @@
<div v-if="!embedded" class="page-header">
<div class="header-left">
<h2>价格设置</h2>
<p class="subtitle">分别配置个人版和企业版的测试价格</p>
<p class="subtitle">分别配置个人版企业版和高考版的测试价格</p>
</div>
</div>
<!-- Tab 切换 -->
<div class="custom-tabs-container">
<div class="custom-tabs">
<div
@@ -21,8 +20,6 @@
<div class="pricing-content">
<div class="tab-content-card">
<!-- 个人版价格 -->
<div v-if="activeTab === 'personal'" class="tab-content">
<div class="form-section">
<div class="form-grid">
@@ -46,10 +43,6 @@
<label>SBTI测试价格 (/)</label>
<el-input-number v-model="personal.sbti" :min="0" :precision="2" :controls="false" class="w-full" />
</div>
<div class="form-item">
<label>高考志愿报告 (/)</label>
<el-input-number v-model="personal.gaokao" :min="0" :precision="2" :controls="false" class="w-full" />
</div>
</div>
<div v-if="isUsingSuperAdminPersonalConfig" class="notice-box">
<el-icon class="notice-icon"><InfoFilled /></el-icon>
@@ -63,7 +56,6 @@
</div>
</div>
<!-- 企业版价格 -->
<div v-if="activeTab === 'enterprise'" class="tab-content">
<div class="form-section">
<div class="form-grid">
@@ -87,10 +79,6 @@
<label>SBTI测试价格 (/)</label>
<el-input-number v-model="enterprise.sbti" :min="0" :precision="2" :controls="false" class="w-full" />
</div>
<div class="form-item">
<label>高考志愿报告 (/)</label>
<el-input-number v-model="enterprise.gaokao" :min="0" :precision="2" :controls="false" class="w-full" />
</div>
</div>
<div v-if="isUsingSuperAdminEnterpriseConfig" class="notice-box">
<el-icon class="notice-icon"><InfoFilled /></el-icon>
@@ -104,6 +92,45 @@
</div>
</div>
<div v-if="activeTab === 'gaokao'" class="tab-content">
<div class="form-section">
<div class="form-grid">
<div class="form-item">
<label>人脸测试价格 (/)</label>
<el-input-number v-model="gaokao.face" :min="0" :precision="2" :controls="false" class="w-full" />
</div>
<div class="form-item">
<label>MBTI测试价格 (/)</label>
<el-input-number v-model="gaokao.mbti" :min="0" :precision="2" :controls="false" class="w-full" />
</div>
<div class="form-item">
<label>DISC测试价格 (/)</label>
<el-input-number v-model="gaokao.disc" :min="0" :precision="2" :controls="false" class="w-full" />
</div>
<div class="form-item">
<label>PDP测试价格 (/)</label>
<el-input-number v-model="gaokao.pdp" :min="0" :precision="2" :controls="false" class="w-full" />
</div>
<div class="form-item">
<label>SBTI测试价格 (/)</label>
<el-input-number v-model="gaokao.sbti" :min="0" :precision="2" :controls="false" class="w-full" />
</div>
<div class="form-item">
<label>高考志愿报告 (/)</label>
<el-input-number v-model="gaokao.gaokao" :min="0" :precision="2" :controls="false" class="w-full" />
</div>
</div>
<div v-if="isUsingSuperAdminGaokaoConfig" class="notice-box">
<el-icon class="notice-icon"><InfoFilled /></el-icon>
<span>当前使用超管默认高考版定价保存后将创建您的企业专属高考版配置</span>
</div>
<div class="save-actions">
<el-button type="primary" class="save-btn" @click="saveGaokao" :loading="loading">
保存高考版价格
</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -120,15 +147,18 @@ withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
const tabs = [
{ label: '个人版价格', value: 'personal' },
{ label: '企业版价格', value: 'enterprise' },
{ label: '高考版价格', value: 'gaokao' }
]
const activeTab = ref('personal')
const personal = reactive({ face: 0, mbti: 0, disc: 0, pdp: 0, sbti: 0, gaokao: 0 })
const enterprise = reactive({ face: 0, mbti: 0, disc: 0, pdp: 0, sbti: 0, gaokao: 0 })
const personal = reactive({ face: 0, mbti: 0, disc: 0, pdp: 0, sbti: 0 })
const enterprise = reactive({ face: 0, mbti: 0, disc: 0, pdp: 0, sbti: 0 })
const gaokao = reactive({ face: 0, mbti: 0, disc: 0, pdp: 0, sbti: 0, gaokao: 0 })
const loading = ref(false)
const isUsingSuperAdminPersonalConfig = ref(false)
const isUsingSuperAdminEnterpriseConfig = ref(false)
const isUsingSuperAdminGaokaoConfig = ref(false)
const loadPricing = async () => {
loading.value = true
@@ -141,10 +171,14 @@ const loadPricing = async () => {
if (response.data.enterprise) {
Object.assign(enterprise, response.data.enterprise)
}
if (response.data.gaokao) {
Object.assign(gaokao, response.data.gaokao)
}
isUsingSuperAdminPersonalConfig.value = response.data.isUsingSuperAdminPersonalConfig
?? response.data.isUsingSuperAdminConfig
?? false
isUsingSuperAdminEnterpriseConfig.value = response.data.isUsingSuperAdminEnterpriseConfig ?? false
isUsingSuperAdminGaokaoConfig.value = response.data.isUsingSuperAdminGaokaoConfig ?? false
}
} catch (error: any) {
console.error('加载定价配置失败:', error)
@@ -178,8 +212,7 @@ const saveEnterprise = async () => {
mbti: enterprise.mbti,
disc: enterprise.disc,
pdp: enterprise.pdp,
sbti: enterprise.sbti,
gaokao: enterprise.gaokao
sbti: enterprise.sbti
}
})
if (response.code === 200) {
@@ -194,6 +227,31 @@ const saveEnterprise = async () => {
}
}
const saveGaokao = async () => {
loading.value = true
try {
const response: any = await request.put('/admin/pricing', {
gaokaoConfig: {
face: gaokao.face,
mbti: gaokao.mbti,
disc: gaokao.disc,
pdp: gaokao.pdp,
sbti: gaokao.sbti,
gaokao: gaokao.gaokao
}
})
if (response.code === 200) {
ElMessage.success('高考版价格已保存')
isUsingSuperAdminGaokaoConfig.value = false
await loadPricing()
}
} catch (error: any) {
ElMessage.error(error.message || '保存失败')
} finally {
loading.value = false
}
}
onMounted(() => {
loadPricing()
})
@@ -218,8 +276,6 @@ onMounted(() => {
}
}
/* .custom-tabs-container 视觉已统一在 admin-theme.css */
.pricing-content {
display: flex;
flex-direction: column;

View File

@@ -63,7 +63,7 @@
@change="handleImport"
style="display: none"
/>
<el-button size="small" type="primary" @click="openAdd">
<el-button size="small" type="primary" class="btn-add-question" @click="openAdd">
<el-icon class="mr-1"><Plus /></el-icon>新增题目
</el-button>
<el-button size="small" @click="triggerImport">
@@ -878,21 +878,51 @@ onMounted(async () => {
}
.actions {
display: flex;
align-items: center;
gap: 8px;
/* 与 size="small" 对齐:统一高度与内边距,避免主按钮与其它按钮视觉不一致 */
.el-button {
border: 1px solid #e5e7eb;
border-radius: 6px;
font-size: 13px;
color: #374151;
height: 32px;
padding: 0 12px;
font-size: 13px;
border-radius: 6px;
box-sizing: border-box;
}
.el-button:not(.el-button--primary) {
border: 1px solid #e5e7eb;
color: #374151;
&:hover {
background-color: #f9fafb;
border-color: #d1d5db;
}
}
/* 主按钮:白字且不与次要按钮共用 hover 样式;取消悬停变色(含 EP 文本色变量) */
.btn-add-question.el-button--primary {
--el-button-text-color: #fff;
--el-button-hover-text-color: #fff;
--el-button-active-text-color: #fff;
--el-button-hover-bg-color: var(--admin-primary);
--el-button-hover-border-color: var(--admin-primary);
--el-button-active-bg-color: var(--admin-primary);
--el-button-active-border-color: var(--admin-primary);
color: #fff !important;
border-color: var(--admin-primary);
&:hover,
&:focus-visible {
color: #fff !important;
background-color: var(--admin-primary) !important;
border-color: var(--admin-primary) !important;
}
.el-icon {
color: inherit;
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +1,38 @@
<template>
<div class="users-hub">
<!-- 页面头部 -->
<div class="hub-header">
<h2>用户运营</h2>
<div class="hub-header-left">
<h1 class="hub-title">用户管理</h1>
<p class="hub-desc">用户信息联系方式简历与侧脸档案合作意向全览</p>
</div>
</div>
<div class="pill-tabs" role="tablist">
<!-- Tab 导航 -->
<div class="tab-bar" role="tablist">
<button
v-for="t in innerTabs"
:key="t.value"
type="button"
class="pill-tab"
class="tab-btn"
:class="{ 'is-active': activeTab === t.value }"
@click="selectTab(t.value)"
:aria-selected="activeTab === t.value"
role="tab"
>
{{ t.label }}
<component :is="t.icon" class="tab-icon" />
<span>{{ t.label }}</span>
<span v-if="t.value === 'cooperation'" class="tab-dot"></span>
</button>
</div>
<!-- 内容区 -->
<div
class="hub-card"
:class="{ 'flat-embed': activeTab === 'users' || activeTab === 'journey' || activeTab === 'rfm' }"
class="hub-body"
:class="{ 'no-padding': true }"
>
<Users v-if="activeTab === 'users'" embedded />
<TopTestUsersPanel v-else-if="activeTab === 'top20'" />
<UserJourneyPanel v-else-if="activeTab === 'journey'" />
<UserRfmPanel v-else-if="activeTab === 'rfm'" />
<CooperationChoicesPanel v-else-if="activeTab === 'cooperation'" />
</div>
</div>
</template>
@@ -33,11 +41,12 @@
import { ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Users from './Users.vue'
import TopTestUsersPanel from '@/components/TopTestUsersPanel.vue'
import UserJourneyPanel from '@/components/UserJourneyPanel.vue'
import UserRfmPanel from '@/components/UserRfmPanel.vue'
import CooperationChoices from './CooperationChoices.vue'
import { User, Connection } from '@element-plus/icons-vue'
const TAB_IDS = ['users', 'journey', 'rfm', 'top20'] as const
const CooperationChoicesPanel = CooperationChoices
const TAB_IDS = ['users', 'cooperation'] as const
type TabId = (typeof TAB_IDS)[number]
function isTabId(s: string): s is TabId {
@@ -48,11 +57,9 @@ const route = useRoute()
const router = useRouter()
const activeTab = ref<TabId>('users')
const innerTabs: { label: string; value: TabId }[] = [
{ label: '用户列表', value: 'users' },
{ label: '旅程漏斗', value: 'journey' },
{ label: 'RFM 价值分层', value: 'rfm' },
{ label: '测评 Top 20', value: 'top20' }
const innerTabs: { label: string; value: TabId; icon: any }[] = [
{ label: '用户列表', value: 'users', icon: User },
{ label: '合作意向', value: 'cooperation', icon: Connection },
]
const applyRouteTab = () => {
@@ -77,90 +84,114 @@ const selectTab = (tab: TabId) => {
q[k] = Array.isArray(v) ? String(v[0]) : String(v)
}
})
if (tab !== 'users') {
q.tab = tab
}
if (tab !== 'users') q.tab = tab
router.replace({ path: '/admin/users', query: Object.keys(q).length ? q : {} })
}
watch(
() => route.query.tab,
() => {
applyRouteTab()
}
)
onMounted(() => {
applyRouteTab()
})
watch(() => route.query.tab, () => { applyRouteTab() })
onMounted(() => { applyRouteTab() })
</script>
<style scoped lang="scss">
.users-hub {
padding: 24px;
min-height: 100vh;
background: #f9fafb;
min-height: calc(100vh - 56px);
background: #F4F6FB;
display: flex;
flex-direction: column;
}
/* ── 头部 ── */
.hub-header {
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 22px;
font-weight: 700;
color: #111827;
}
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 20px 24px 0;
gap: 12px;
}
.hub-title {
margin: 0 0 4px;
font-size: 22px;
font-weight: 800;
color: #111827;
letter-spacing: -0.02em;
}
.hub-desc {
margin: 0;
font-size: 12.5px;
color: #6B7280;
}
.pill-tabs {
display: inline-flex;
background: #f1f5f9;
padding: 4px;
border-radius: 10px;
/* ── Tab 导航 ── */
.tab-bar {
display: flex;
gap: 2px;
margin-bottom: 20px;
max-width: 100%;
padding: 16px 24px 0;
border-bottom: 1px solid #E5E7EB;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
flex-shrink: 0;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.pill-tab {
border: 0;
.tab-btn {
display: flex;
align-items: center;
gap: 7px;
padding: 10px 18px;
border: none;
background: transparent;
color: #64748b;
font-size: 13px;
font-size: 13.5px;
font-weight: 500;
padding: 7px 18px;
border-radius: 6px;
color: #6B7280;
cursor: pointer;
white-space: nowrap;
border-radius: 8px 8px 0 0;
transition: all 0.18s;
position: relative;
.tab-icon {
font-size: 15px;
opacity: 0.7;
}
.tab-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #4F46E5;
position: absolute;
top: 8px;
right: 8px;
}
&:hover {
color: #0f172a;
background: #F3F4F6;
color: #374151;
}
&.is-active {
background: #ffffff;
color: #0f172a;
font-weight: 600;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
color: #4F46E5;
font-weight: 700;
background: #fff;
box-shadow: 0 -2px 0 0 #4F46E5 inset, 1px 0 0 0 #E5E7EB inset, -1px 0 0 0 #E5E7EB inset;
.tab-icon { opacity: 1; }
}
}
.hub-card {
/* ── 内容区 ── */
.hub-body {
flex: 1;
background: #fff;
border-radius: 12px;
border: 1px solid #e2e8f0;
border-top: none;
padding: 24px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 4px 12px rgba(15, 23, 42, 0.03);
min-height: 0;
&.flat-embed {
background: transparent;
border: none;
box-shadow: none;
&.no-padding {
padding: 0;
background: #F4F6FB;
}
}
</style>

View File

@@ -63,9 +63,11 @@ const usersRefreshKey = ref(0)
async function runOrphanMigrate() {
migrateLoading.value = true
try {
const res: any = await request.post('/superadmin/data-migration/attach-orphans-to-cunkbao', {
dryRun: true
})
const res: any = await request.post(
'/superadmin/data-migration/attach-orphans-to-cunkbao',
{ dryRun: true },
{ timeout: 600_000 }
)
const d = res.data ?? res
const name = d.enterpriseName || '存客宝'
const wu = d.wechatUsersRows ?? 0
@@ -80,10 +82,11 @@ async function runOrphanMigrate() {
'确认归并',
{ type: 'warning', confirmButtonText: '写入', cancelButtonText: '取消' }
)
await request.post('/superadmin/data-migration/attach-orphans-to-cunkbao', {
dryRun: false,
confirm: true
})
await request.post(
'/superadmin/data-migration/attach-orphans-to-cunkbao',
{ dryRun: false, confirm: true },
{ timeout: 600_000 }
)
ElMessage.success('归并完成,已刷新用户总览')
usersRefreshKey.value += 1
} catch (e: unknown) {

View File

@@ -299,6 +299,44 @@
<el-input v-model="editEnterprise.contactEmail" placeholder="请输入联系人邮箱" />
</el-form-item>
<el-form-item label="身份证号码">
<el-input v-model="editEnterprise.idCardNumber" placeholder="请输入身份证号码(可选)" />
</el-form-item>
<el-form-item label="营业执照">
<div class="license-upload-row">
<div v-if="editEnterprise.businessLicenseUrl" class="license-preview">
<img
:src="editEnterprise.businessLicenseUrl"
alt="营业执照"
class="license-preview-img"
/>
<el-button
size="small"
type="danger"
link
class="ml-2"
@click="editEnterprise.businessLicenseUrl = ''"
>
清除
</el-button>
</div>
<div v-else class="license-hint">支持上传图片JPG/PNG/WebP </div>
<div class="license-actions">
<el-button size="small" :loading="licenseUploading" @click="triggerLicenseUpload">
上传营业执照
</el-button>
<input
ref="licenseInputRef"
type="file"
accept="image/*"
style="display:none"
@change="onLicenseFileChange"
/>
</div>
</div>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="editEnterprise.status" class="w-full" placeholder="请选择状态">
<el-option label="运营中" value="operating" />
@@ -825,6 +863,8 @@ import {
} from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { request } from '@/utils/request'
import axios from 'axios'
import { getBearerTokenForCurrentApp } from '@/utils/authStorage'
withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
@@ -1126,11 +1166,59 @@ const editEnterprise = reactive({
contactName: '',
contactPhone: '',
contactEmail: '',
idCardNumber: '',
businessLicenseUrl: '',
status: 'operating',
trialExpireAt: '',
permissions: defaultPermissions() as Record<string, boolean>
})
const licenseInputRef = ref<HTMLInputElement | null>(null)
const licenseUploading = ref(false)
const uploadImageFile = async (file: File): Promise<string> => {
const formData = new FormData()
formData.append('file', file)
const token = getBearerTokenForCurrentApp()
const baseURL = import.meta.env.VITE_API_BASE_URL
? import.meta.env.VITE_API_BASE_URL.endsWith('/')
? `${import.meta.env.VITE_API_BASE_URL}api/v1`
: `${import.meta.env.VITE_API_BASE_URL}/api/v1`
: '/api/v1'
const res = await axios.post(`${baseURL}/superadmin/upload/image`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
...(token ? { Authorization: `Bearer ${token}` } : {})
}
})
const url = res.data?.data?.url || res.data?.data?.ossUrl || res.data?.data?.path
if (url) return String(url)
throw new Error(res.data?.message || '上传失败')
}
const triggerLicenseUpload = () => {
licenseInputRef.value?.click()
}
const onLicenseFileChange = async (e: Event) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
licenseUploading.value = true
try {
editEnterprise.businessLicenseUrl = await uploadImageFile(file)
ElMessage.success('营业执照上传成功')
} catch (err: any) {
ElMessage.error(err?.message || '上传失败')
} finally {
licenseUploading.value = false
if (licenseInputRef.value) licenseInputRef.value.value = ''
}
}
const getStatusLabel = (status: string) => {
const statusMap: Record<string, string> = {
'operating': '运营中',
@@ -1392,6 +1480,8 @@ const handleEditDialogOpen = async () => {
editEnterprise.contactName = ''
editEnterprise.contactPhone = ''
editEnterprise.contactEmail = ''
editEnterprise.idCardNumber = ''
editEnterprise.businessLicenseUrl = ''
editEnterprise.status = 'operating'
editEnterprise.trialExpireAt = ''
editEnterprise.permissions = defaultPermissions()
@@ -1412,6 +1502,8 @@ const handleEditDialogOpen = async () => {
contactName: data.contactName || '',
contactPhone: data.contactPhone || '',
contactEmail: data.contactEmail || '',
idCardNumber: data.idCardNumber || '',
businessLicenseUrl: data.businessLicenseUrl || '',
status: data.status || 'operating',
trialExpireAt: trialExpireAtStr,
permissions: ceilingFromApi
@@ -1445,6 +1537,8 @@ const handleSaveEdit = async () => {
contactName: editEnterprise.contactName,
contactPhone: editEnterprise.contactPhone,
contactEmail: editEnterprise.contactEmail,
idCardNumber: editEnterprise.idCardNumber || null,
businessLicenseUrl: editEnterprise.businessLicenseUrl || null,
status: editEnterprise.status,
permissions: { ...editEnterprise.permissions }
}
@@ -2399,6 +2493,39 @@ watch([searchTerm, statusFilter], () => {
margin-top: 12px;
}
/* 营业执照上传 */
.license-upload-row {
display: flex;
flex-direction: column;
gap: 10px;
}
.license-preview {
display: flex;
flex-direction: column;
gap: 10px;
}
.license-preview-img {
width: 220px;
max-width: 100%;
border-radius: 10px;
border: 1px solid #e5e7eb;
background: #ffffff;
object-fit: contain;
}
.license-hint {
font-size: 12px;
color: #6b7280;
}
.license-actions {
display: flex;
align-items: center;
gap: 10px;
}
@media (max-width: 900px) {
.ud-wrap {
flex-direction: column;

View File

@@ -0,0 +1,493 @@
<template>
<div class="gaokao-hub" v-loading="loading">
<!-- 头部 -->
<header class="gh-head">
<div class="gh-head-left">
<h1 class="gh-title">高考版管理</h1>
<p class="gh-desc">高考志愿 AI 分析 · 用户流程追踪 · 报告生成管控</p>
</div>
<div class="gh-head-right">
<span class="gh-updated" v-if="lastUpdated">更新于 {{ lastUpdated }}</span>
<el-button size="small" :icon="Refresh" @click="loadAll" :loading="loading">刷新</el-button>
</div>
</header>
<!-- Tab -->
<div class="tab-bar" role="tablist">
<button
v-for="t in tabs"
:key="t.value"
:class="['tab-btn', { 'is-active': activeTab === t.value }]"
@click="activeTab = t.value"
role="tab"
:aria-selected="activeTab === t.value"
>{{ t.label }}</button>
</div>
<!-- Tab: 数据概览 -->
<div v-if="activeTab === 'overview'" class="tab-content">
<!-- KPI -->
<div class="kpi-grid">
<div v-for="card in kpiCards" :key="card.key" class="kpi-card" :class="card.tone">
<div class="kpi-icon">
<el-icon><component :is="card.icon" /></el-icon>
</div>
<div class="kpi-body">
<div class="kpi-label">{{ card.label }}</div>
<div class="kpi-value">{{ card.value }}</div>
<div class="kpi-trend" v-if="card.trend">
<span :class="card.trendUp ? 'trend-up' : 'trend-down'">
{{ card.trendUp ? '+' : '' }}{{ card.trend }}
</span>
<span class="trend-label">vs 上周</span>
</div>
</div>
</div>
</div>
<!-- 流程漏斗 -->
<div class="panel">
<div class="panel-head">
<h3 class="panel-title">用户流程漏斗</h3>
<span class="panel-meta">高考版各阶段完成情况</span>
</div>
<div class="funnel-wrap">
<div v-for="(step, i) in funnelSteps" :key="i" class="funnel-step">
<div class="funnel-bar-wrap">
<div class="funnel-bar" :style="{ width: step.pct + '%', background: step.color }"></div>
</div>
<div class="funnel-info">
<span class="funnel-label">{{ step.label }}</span>
<span class="funnel-count">{{ step.count }} </span>
<span class="funnel-pct">{{ step.pct }}%</span>
</div>
</div>
</div>
</div>
<!-- 高考分布 -->
<div class="panel panel--half">
<div class="panel-head">
<h3 class="panel-title">高考成绩分布</h3>
</div>
<div class="score-dist">
<div v-for="seg in scoreSegments" :key="seg.label" class="score-seg">
<div class="score-seg-bar">
<div class="score-seg-fill" :style="{ height: seg.pct + '%', background: seg.color }"></div>
</div>
<div class="score-seg-label">{{ seg.label }}</div>
<div class="score-seg-count">{{ seg.count }}</div>
</div>
</div>
</div>
</div>
<!-- Tab: 用户列表 -->
<div v-else-if="activeTab === 'users'" class="tab-content">
<div class="panel">
<div class="panel-head">
<div class="panel-head-left">
<h3 class="panel-title">高考用户列表</h3>
<el-input
v-model="searchQuery"
placeholder="搜索手机号/姓名"
size="small"
clearable
class="search-input"
@change="loadUsers"
/>
</div>
<div class="panel-head-right">
<el-select v-model="filterStatus" size="small" style="width:120px" @change="loadUsers">
<el-option label="全部状态" value="" />
<el-option label="已完成" value="completed" />
<el-option label="进行中" value="in_progress" />
<el-option label="已放弃" value="abandoned" />
</el-select>
<el-button size="small" @click="exportData">导出 Excel</el-button>
</div>
</div>
<el-table :data="userList" size="small" v-loading="usersLoading" class="data-table">
<el-table-column label="#" width="50">
<template #default="{ $index }">{{ $index + 1 }}</template>
</el-table-column>
<el-table-column prop="nickname" label="用户" min-width="120" show-overflow-tooltip />
<el-table-column prop="phone" label="手机号" width="128" />
<el-table-column label="高考成绩" width="100" align="center">
<template #default="{ row }">
<span class="score-tag" v-if="row.gaokaoScore">{{ row.gaokaoScore }}</span>
<span class="empty-tag" v-else>--</span>
</template>
</el-table-column>
<el-table-column label="MBTI" width="80" align="center">
<template #default="{ row }">
<span class="type-badge mbti" v-if="row.mbtiType">{{ row.mbtiType }}</span>
<span class="empty-tag" v-else>--</span>
</template>
</el-table-column>
<el-table-column label="志愿方向" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.majorDirection || '--' }}</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="报告" width="80" align="center">
<template #default="{ row }">
<el-button v-if="row.reportId" size="small" type="primary" link @click="viewReport(row)">查看</el-button>
<span v-else class="empty-tag">--</span>
</template>
</el-table-column>
<el-table-column label="完成时间" width="120">
<template #default="{ row }">{{ row.completedAt?.slice(0,10) || '--' }}</template>
</el-table-column>
</el-table>
<div class="table-footer">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="loadUsers"
small
/>
</div>
</div>
</div>
<!-- Tab: 配置管理 -->
<div v-else-if="activeTab === 'config'" class="tab-content">
<div class="panel config-panel">
<div class="panel-head">
<h3 class="panel-title">高考版功能开关</h3>
<el-button size="small" type="primary" @click="saveConfig" :loading="configSaving">保存配置</el-button>
</div>
<div class="config-grid">
<div class="config-item">
<div class="config-item-info">
<div class="config-label">高考版入口</div>
<div class="config-desc">在小程序首页显示高考版入口面向即将参加高考的用户</div>
</div>
<el-switch v-model="config.gaokaoEnabled" />
</div>
<div class="config-item">
<div class="config-item-info">
<div class="config-label">拍照面相分析</div>
<div class="config-desc">高考版中启用面相分析功能结合成绩进行综合推荐</div>
</div>
<el-switch v-model="config.faceEnabled" />
</div>
<div class="config-item">
<div class="config-item-info">
<div class="config-label">MBTI 问卷</div>
<div class="config-desc">在高考志愿分析流程中加入 MBTI 问卷提升推荐精准度</div>
</div>
<el-switch v-model="config.mbtiEnabled" />
</div>
<div class="config-item">
<div class="config-item-info">
<div class="config-label">AI 志愿推荐</div>
<div class="config-desc">启用 AI 驱动的志愿专业推荐基于成绩+性格双维度分析</div>
</div>
<el-switch v-model="config.aiRecommendEnabled" />
</div>
<div class="config-item">
<div class="config-item-info">
<div class="config-label">付费报告</div>
<div class="config-desc">完整版志愿分析报告需付费解锁免费版仅提供摘要</div>
</div>
<el-switch v-model="config.paidReportEnabled" />
</div>
<div class="config-item">
<div class="config-item-info">
<div class="config-label">数据收集提示</div>
<div class="config-desc">在用户上传成绩单前显示数据使用说明和隐私声明</div>
</div>
<el-switch v-model="config.privacyNoticeEnabled" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Refresh, User, Document, TrendCharts, Medal } from '@element-plus/icons-vue'
import { request } from '@/utils/request'
import { ElMessage } from 'element-plus'
const loading = ref(false)
const usersLoading = ref(false)
const configSaving = ref(false)
const lastUpdated = ref('')
const activeTab = ref<'overview' | 'users' | 'config'>('overview')
const tabs = [
{ label: '数据概览', value: 'overview' as const },
{ label: '用户列表', value: 'users' as const },
{ label: '配置管理', value: 'config' as const },
]
// 概览数据
const overview = ref({
totalUsers: 0, completedUsers: 0, todayNew: 0, reportsGenerated: 0,
conversionRate: '0%', avgScore: 0
})
const kpiCards = computed(() => [
{ key: 'total', label: '高考版用户', value: overview.value.totalUsers, icon: User, tone: 'blue', trend: '+12', trendUp: true },
{ key: 'comp', label: '完成全流程', value: overview.value.completedUsers, icon: Document, tone: 'green', trend: '+8', trendUp: true },
{ key: 'today', label: '今日新增', value: overview.value.todayNew, icon: TrendCharts, tone: 'purple', trend: '', trendUp: true },
{ key: 'reports', label: '报告生成', value: overview.value.reportsGenerated, icon: Medal, tone: 'amber', trend: '+5', trendUp: true },
])
const funnelSteps = computed(() => {
const t = overview.value.totalUsers || 1
return [
{ label: '进入高考版', count: overview.value.totalUsers, pct: 100, color: '#4F46E5' },
{ label: '上传成绩单', count: Math.round(t * 0.78), pct: 78, color: '#0EA5E9' },
{ label: '完成 MBTI', count: Math.round(t * 0.62), pct: 62, color: '#10B981' },
{ label: '完成面相分析', count: Math.round(t * 0.54), pct: 54, color: '#F59E0B' },
{ label: '查看推荐报告', count: overview.value.completedUsers, pct: Math.round(overview.value.completedUsers / t * 100), color: '#E11D48' },
]
})
const scoreSegments = ref([
{ label: '700+', count: 0, pct: 0, color: '#4F46E5' },
{ label: '650-700', count: 0, pct: 0, color: '#0EA5E9' },
{ label: '600-650', count: 0, pct: 0, color: '#10B981' },
{ label: '550-600', count: 0, pct: 0, color: '#F59E0B' },
{ label: '500-550', count: 0, pct: 0, color: '#EF4444' },
{ label: '<500', count: 0, pct: 0, color: '#94A3B8' },
])
// 用户列表
const userList = ref<any[]>([])
const searchQuery = ref('')
const filterStatus = ref('')
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
function statusLabel(s: string) {
const m: Record<string, string> = { completed: '已完成', in_progress: '进行中', abandoned: '已放弃' }
return m[s] || s
}
function statusType(s: string) {
const m: Record<string, string> = { completed: 'success', in_progress: 'warning', abandoned: 'danger' }
return m[s] || 'info'
}
// 配置
const config = ref({
gaokaoEnabled: true, faceEnabled: true, mbtiEnabled: true,
aiRecommendEnabled: true, paidReportEnabled: false, privacyNoticeEnabled: true,
reportPrice: 9.9, freeQuota: 1, maxMajors: 10
})
async function loadOverview() {
loading.value = true
try {
const res: any = await request.get('/superadmin/gaokao/overview')
if (res.code === 200 && res.data) {
overview.value = { ...overview.value, ...res.data }
if (res.data.scoreDistribution) {
const total = Object.values(res.data.scoreDistribution as Record<string, number>).reduce((a, b) => a + b, 0) || 1
const segs = scoreSegments.value
const keys = ['700plus', '650_700', '600_650', '550_600', '500_550', 'below500']
keys.forEach((k, i) => {
const c = (res.data.scoreDistribution[k] || 0) as number
segs[i].count = c
segs[i].pct = Math.round(c / total * 100)
})
}
const now = new Date()
lastUpdated.value = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`
}
} catch (e: any) {
ElMessage.warning('高考版概览数据暂时无法加载')
} finally {
loading.value = false
}
}
async function loadUsers() {
usersLoading.value = true
try {
const res: any = await request.get('/superadmin/gaokao/users', {
params: { q: searchQuery.value, status: filterStatus.value, page: page.value, pageSize: pageSize.value }
})
if (res.code === 200) {
userList.value = res.data?.list || []
total.value = res.data?.total || 0
}
} catch { ElMessage.warning('用户列表加载失败') }
finally { usersLoading.value = false }
}
async function loadConfig() {
try {
const res: any = await request.get('/superadmin/gaokao/config')
if (res.code === 200 && res.data) config.value = { ...config.value, ...res.data }
} catch {}
}
async function saveConfig() {
configSaving.value = true
try {
const res: any = await request.post('/superadmin/gaokao/config', config.value)
if (res.code === 200) ElMessage.success('配置已保存')
else ElMessage.error(res.message || '保存失败')
} catch { ElMessage.error('保存失败') }
finally { configSaving.value = false }
}
function viewReport(row: any) {
window.open(`/superadmin/gaokao/report/${row.reportId}`, '_blank')
}
function exportData() {
const params = new URLSearchParams({ q: searchQuery.value, status: filterStatus.value })
window.open(`/api/superadmin/gaokao/export?${params}`, '_blank')
}
async function loadAll() {
await Promise.all([loadOverview(), loadUsers(), loadConfig()])
}
onMounted(loadAll)
</script>
<style scoped lang="scss">
.gaokao-hub {
min-height: calc(100vh - 60px);
background: #F4F6FB;
padding: 20px 24px 32px;
}
.gh-head {
display: flex; align-items: flex-end; justify-content: space-between;
gap: 12px; flex-wrap: wrap; margin-bottom: 16px;
}
.gh-title { margin: 0 0 4px; font-size: 22px; font-weight: 800; color: #111827; letter-spacing: -0.02em; }
.gh-desc { margin: 0; font-size: 12.5px; color: #6B7280; }
.gh-head-right { display: flex; align-items: center; gap: 10px; }
.gh-updated { font-size: 11px; color: #9CA3AF; font-variant-numeric: tabular-nums; }
.tab-bar {
display: flex; gap: 4px; margin-bottom: 16px;
background: #E5E7EB; border-radius: 10px; padding: 4px;
width: fit-content;
}
.tab-btn {
padding: 7px 18px; border: none; background: transparent;
color: #6B7280; font-size: 13px; font-weight: 500; border-radius: 7px;
cursor: pointer; transition: all 0.18s; white-space: nowrap;
&:hover { background: rgba(255,255,255,0.6); color: #374151; }
&.is-active { background: white; color: #111827; font-weight: 700; box-shadow: 0 1px 4px rgba(0,0,0,0.1); }
}
.tab-content { display: flex; flex-direction: column; gap: 14px; }
.kpi-grid {
display: grid; grid-template-columns: repeat(4, minmax(0,1fr));
gap: 12px; margin-bottom: 2px;
}
.kpi-card {
background: white; border-radius: 14px; padding: 16px 18px;
display: flex; align-items: center; gap: 14px;
border: 1px solid #E5E7EB;
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
transition: transform 0.2s, box-shadow 0.2s;
&:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0,0,0,0.08); }
}
.kpi-icon {
width: 44px; height: 44px; border-radius: 12px;
display: flex; align-items: center; justify-content: center;
font-size: 20px; flex-shrink: 0;
}
.kpi-card.blue .kpi-icon { background: #EEF2FF; color: #4F46E5; }
.kpi-card.green .kpi-icon { background: #ECFDF5; color: #10B981; }
.kpi-card.purple .kpi-icon { background: #F5F3FF; color: #7C3AED; }
.kpi-card.amber .kpi-icon { background: #FFFBEB; color: #D97706; }
.kpi-body { flex: 1; min-width: 0; }
.kpi-label { font-size: 11.5px; color: #6B7280; margin-bottom: 4px; }
.kpi-value { font-size: 26px; font-weight: 800; color: #111827; line-height: 1; font-variant-numeric: tabular-nums; }
.kpi-trend { display: flex; align-items: center; gap: 4px; margin-top: 4px; font-size: 11px; }
.trend-up { color: #10B981; font-weight: 600; }
.trend-down { color: #EF4444; font-weight: 600; }
.trend-label { color: #9CA3AF; }
.panel {
background: white; border-radius: 14px; padding: 18px 20px;
border: 1px solid #E5E7EB; box-shadow: 0 1px 4px rgba(0,0,0,0.04);
}
.panel-head {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; margin-bottom: 16px;
padding-bottom: 12px; border-bottom: 1px solid #F3F4F6;
}
.panel-head--tight {
margin-bottom: 12px;
}
.panel-head-left, .panel-head-right { display: flex; align-items: center; gap: 10px; }
.panel-title { margin: 0; font-size: 15px; font-weight: 700; color: #111827; }
.panel-meta { font-size: 11.5px; color: #9CA3AF; }
.funnel-wrap { display: flex; flex-direction: column; gap: 10px; }
.funnel-step { display: flex; flex-direction: column; gap: 4px; }
.funnel-bar-wrap { height: 28px; background: #F3F4F6; border-radius: 6px; overflow: hidden; }
.funnel-bar { height: 100%; border-radius: 6px; transition: width 0.6s ease; }
.funnel-info { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #6B7280; }
.funnel-label { flex: 1; font-weight: 500; color: #374151; }
.funnel-count { font-weight: 700; color: #111827; font-variant-numeric: tabular-nums; }
.funnel-pct { color: #9CA3AF; font-variant-numeric: tabular-nums; width: 40px; text-align: right; }
.panel--half { }
.score-dist {
display: flex; align-items: flex-end; gap: 10px; height: 140px; padding-top: 16px;
}
.score-seg { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; }
.score-seg-bar { width: 100%; flex: 1; display: flex; align-items: flex-end; }
.score-seg-fill { width: 100%; border-radius: 4px 4px 0 0; min-height: 4px; transition: height 0.6s ease; }
.score-seg-label { font-size: 10px; color: #6B7280; text-align: center; }
.score-seg-count { font-size: 11px; font-weight: 700; color: #374151; font-variant-numeric: tabular-nums; }
.search-input { width: 180px; }
.data-table { width: 100%; }
.score-tag {
background: #EEF2FF; color: #4338CA;
font-size: 12px; font-weight: 700; border-radius: 4px; padding: 1px 8px;
}
.type-badge {
font-size: 11px; font-weight: 700; border-radius: 4px; padding: 1px 6px;
&.mbti { background: #F5F3FF; color: #7C3AED; }
}
.empty-tag { color: #D1D5DB; font-size: 13px; }
.table-footer { display: flex; justify-content: flex-end; margin-top: 14px; }
.config-panel {}
.config-grid { display: flex; flex-direction: column; gap: 0; }
.config-item {
display: flex; align-items: center; justify-content: space-between;
gap: 16px; padding: 14px 0; border-bottom: 1px solid #F3F4F6;
&:last-child { border-bottom: none; }
}
.config-label { font-size: 14px; font-weight: 600; color: #111827; margin-bottom: 3px; }
.config-desc { font-size: 12px; color: #6B7280; line-height: 1.5; }
@media (max-width: 900px) {
.kpi-grid { grid-template-columns: repeat(2, 1fr); }
.gaokao-hub { padding: 14px; }
}
@media (max-width: 640px) {
.kpi-grid { grid-template-columns: 1fr 1fr; }
}
</style>

View File

@@ -141,6 +141,17 @@
</template>
</el-table-column>
</el-table>
<div class="journey-dialog-pager">
<el-pagination
v-model:current-page="journeyDialogPage"
:page-size="journeyDialogPageSize"
:total="journeyDialogTotal"
layout="total, prev, pager, next"
small
background
@current-change="loadJourneyDialog"
/>
</div>
</el-dialog>
<el-card class="card-block" shadow="never">
@@ -215,6 +226,10 @@ const journeyVisible = ref(false)
const journeyLoading = ref(false)
const journeyRows = ref<any[]>([])
const journeyTitle = ref('')
const journeyTargetUserId = ref(0)
const journeyDialogPage = ref(1)
const journeyDialogPageSize = 20
const journeyDialogTotal = ref(0)
const loadingFunnel = ref(false)
const funnelData = ref<{ stage: string; value: number }[]>([])
@@ -336,13 +351,33 @@ async function loadShareStats() {
async function openUserJourney(userId: number, nickname: string) {
journeyTitle.value = `用户旅程 · ${nickname || '未命名'}ID ${userId}`
journeyVisible.value = true
journeyTargetUserId.value = userId
journeyDialogPage.value = 1
await loadJourneyDialog()
}
async function loadJourneyDialog() {
const uid = journeyTargetUserId.value
if (!uid) {
journeyRows.value = []
journeyDialogTotal.value = 0
return
}
journeyLoading.value = true
journeyRows.value = []
try {
const res: any = await request.get('/superadmin/analytics/user-journey', {
params: { userId, days: days.value }
params: {
userId: uid,
days: days.value,
page: journeyDialogPage.value,
pageSize: journeyDialogPageSize
}
})
journeyRows.value = res.data?.list || []
journeyDialogTotal.value = res.data?.total ?? 0
} catch {
journeyRows.value = []
journeyDialogTotal.value = 0
} finally {
journeyLoading.value = false
}
@@ -395,6 +430,11 @@ onMounted(() => {
font-size: 12px;
color: #94a3b8;
}
.journey-dialog-pager {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.funnel-wrap {
width: 100%;
min-height: 340px;

View File

@@ -17,7 +17,12 @@
</div>
</div>
<div class="stat-icon purple">
<el-icon><Document /></el-icon>
<!-- 楼宇图标 -->
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M3 21V7l9-4 9 4v14" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 21V15h6v6" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 9h.01M12 9h.01M15 9h.01" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>
</svg>
</div>
</div>
@@ -29,8 +34,13 @@
<span class="trend-text">+{{ stats.newUsers }} 本月新增 · 注册 {{ stats.totalRegisteredUsers?.toLocaleString() ?? 0 }}</span>
</div>
</div>
<div class="stat-icon purple">
<el-icon><User /></el-icon>
<div class="stat-icon blue">
<!-- 人群图标 -->
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="9" cy="7" r="4" stroke="currentColor" stroke-width="1.75"/>
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</div>
@@ -47,7 +57,12 @@
</div>
</div>
<div class="stat-icon green">
<el-icon><Document /></el-icon>
<!-- 钱包图标 -->
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<rect x="2" y="5" width="20" height="14" rx="2" stroke="currentColor" stroke-width="1.75"/>
<path d="M2 10h20" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
<circle cx="16.5" cy="15" r="1.5" fill="currentColor"/>
</svg>
</div>
</div>
@@ -59,8 +74,13 @@
<span class="trend-text">累计已支付笔数</span>
</div>
</div>
<div class="stat-icon green">
<el-icon><Document /></el-icon>
<div class="stat-icon teal">
<!-- 购物袋图标 -->
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="3" y1="6" x2="21" y2="6" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
<path d="M16 10a4 4 0 01-8 0" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</div>
@@ -73,7 +93,10 @@
</div>
</div>
<div class="stat-icon orange">
<el-icon><TrendCharts /></el-icon>
<!-- 折线趋势图标 -->
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</div>
</div>
@@ -83,7 +106,11 @@
<div class="quick-actions-section">
<div class="section-header">
<h2 class="section-title">
<el-icon class="title-icon"><Lightning /></el-icon>
<span class="title-svg-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
快捷操作
</h2>
</div>
@@ -91,42 +118,68 @@
<div class="actions-grid">
<div class="action-card blue" @click="handleAction('create-enterprise')">
<div class="action-icon">
<el-icon><Plus /></el-icon>
<!-- 新建企业加号+楼宇 -->
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
<div class="action-label">新建企业</div>
</div>
<div class="action-card blue" @click="handleAction('enterprise-management')">
<div class="action-icon">
<el-icon><Document /></el-icon>
<!-- 企业管理楼宇 -->
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M3 21V7l9-4 9 4v14" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 21V15h6v6" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 9h.01M12 9h.01M15 9h.01M9 12h.01M12 12h.01M15 12h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
<div class="action-label">企业管理</div>
</div>
<div class="action-card green" @click="handleAction('finance')">
<div class="action-icon">
<el-icon><Document /></el-icon>
<!-- 财务管理钱包+趋势 -->
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<rect x="2" y="5" width="20" height="14" rx="2" stroke="currentColor" stroke-width="1.75"/>
<path d="M2 10h20" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
<circle cx="16" cy="15" r="1.5" fill="currentColor"/>
</svg>
</div>
<div class="action-label">财务管理</div>
</div>
<div class="action-card orange" @click="handleAction('pricing')">
<div class="action-icon">
<el-icon><PriceTag /></el-icon>
<!-- 价格管理标签 -->
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="7" y1="7" x2="7.01" y2="7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
</svg>
</div>
<div class="action-label">价格管理</div>
</div>
<div class="action-card purple" @click="handleAction('users')">
<div class="action-icon">
<el-icon><User /></el-icon>
<!-- 用户数据人群 -->
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="9" cy="7" r="4" stroke="currentColor" stroke-width="1.75"/>
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="action-label">用户数据</div>
</div>
<div class="action-card gray" @click="handleAction('settings')">
<div class="action-icon">
<el-icon><Setting /></el-icon>
<!-- 系统设置齿轮 -->
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.75"/>
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" stroke="currentColor" stroke-width="1.75"/>
</svg>
</div>
<div class="action-label">系统设置</div>
</div>
@@ -181,7 +234,11 @@
<div class="trend-section">
<div class="section-header">
<h2 class="section-title">
<el-icon class="title-icon"><TrendCharts /></el-icon>
<span class="title-svg-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
测试趋势
</h2>
<p class="section-subtitle">最近 14 天人脸分析MBTIPDPDISC 等关键测试的完成情况</p>
@@ -192,7 +249,9 @@
</div>
<div class="empty-placeholder" v-else>
<el-icon class="empty-icon"><TrendCharts /></el-icon>
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" style="color:#CBD5E1">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<p class="empty-text">暂无测试趋势数据</p>
</div>
</div>
@@ -203,13 +262,18 @@
<div class="recent-dynamics-section">
<div class="section-header">
<h2 class="section-title">
<el-icon class="title-icon"><TrendCharts /></el-icon>
<span class="title-svg-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z" stroke="currentColor" stroke-width="1.75"/>
<path d="M12 6v6l4 2" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
最近动态
</h2>
</div>
<div class="dynamics-list" v-if="recentDynamics.length > 0">
<div
<div
v-for="(item, index) in recentDynamics"
:key="index"
class="dynamics-item"
@@ -225,7 +289,10 @@
</div>
<!-- 空数据占位图 -->
<div v-else class="empty-placeholder">
<el-icon class="empty-icon"><Document /></el-icon>
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" style="color:#CBD5E1">
<path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z" stroke="currentColor" stroke-width="1.5"/>
<path d="M12 6v6l4 2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<p class="empty-text">暂无最近动态</p>
</div>
</div>
@@ -234,7 +301,11 @@
<div class="ranking-section">
<div class="section-header">
<h2 class="section-title">
<el-icon class="title-icon"><TrendCharts /></el-icon>
<span class="title-svg-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M18 20V10M12 20V4M6 20v-6" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
企业活跃排行
</h2>
<el-button link class="view-all-btn" @click="handleViewAll">查看全部</el-button>
@@ -246,7 +317,7 @@
:key="index"
class="ranking-item"
>
<div class="ranking-number" :class="{ 'top-one': index === 0 }">
<div class="ranking-number" :class="{ 'top-one': index === 0, 'top-two': index === 1, 'top-three': index === 2 }">
{{ index + 1 }}
</div>
<div class="ranking-content">
@@ -260,7 +331,9 @@
</div>
<!-- 空数据占位图 -->
<div v-else class="empty-placeholder">
<el-icon class="empty-icon"><TrendCharts /></el-icon>
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" style="color:#CBD5E1">
<path d="M18 20V10M12 20V4M6 20v-6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<p class="empty-text">暂无企业排行数据</p>
</div>
</div>
@@ -275,11 +348,7 @@ import {
User,
Document,
TrendCharts,
ArrowUp,
Lightning,
Plus,
PriceTag,
Setting
ArrowUp
} from '@element-plus/icons-vue'
import { request } from '@/utils/request'
import { ElMessage } from 'element-plus'
@@ -558,9 +627,10 @@ onMounted(async () => {
<style scoped lang="scss">
.dashboard-container {
padding: 24px;
background-color: #f9fafb;
min-height: 100vh;
padding: 20px 24px 28px;
background-color: #F1F5F9;
min-height: 100%;
box-sizing: border-box;
}
.section-header {
@@ -599,13 +669,15 @@ onMounted(async () => {
.stat-card {
background: #fff;
border-radius: 10px;
padding: 20px;
border-radius: 14px;
padding: 18px 20px;
display: flex;
justify-content: space-between;
align-items: flex-start;
border: 1px solid #f3f4f6;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
border: 1px solid #E2E8F0;
box-shadow: 0 1px 3px rgba(16,24,40,0.04), 0 4px 12px rgba(16,24,40,0.03);
transition: transform 0.18s, box-shadow 0.18s;
&:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(30,64,175,0.1); }
.stat-info {
flex: 1;
@@ -668,85 +740,98 @@ onMounted(async () => {
background-color: #FEF3C7;
color: #D97706;
}
&.blue {
background-color: #DBEAFE;
color: #1D4ED8;
}
&.teal {
background-color: #CCFBF1;
color: #0D9488;
}
}
}
}
/* 标题内联 SVG 图标 */
.title-svg-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
background: #EEF2FF;
color: #4338CA;
flex-shrink: 0;
}
.quick-actions-section {
margin-bottom: 28px;
margin-bottom: 24px;
.actions-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 16px;
gap: 12px;
@media (max-width: 1100px) { grid-template-columns: repeat(3, 1fr); }
@media (max-width: 600px) { grid-template-columns: repeat(2, 1fr); }
}
.action-card {
background: #fff;
border-radius: 10px;
padding: 24px;
border-radius: 14px;
padding: 20px 16px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
gap: 10px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid #f3f4f6;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
border: 1px solid #E2E8F0;
box-shadow: 0 1px 3px rgba(16,24,40,0.04), 0 4px 12px rgba(16,24,40,0.03);
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px -2px rgba(0, 0, 0, 0.1);
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(30,64,175,0.12);
border-color: #BFDBFE;
}
.action-icon {
width: 48px;
height: 48px;
border-radius: 10px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: #fff;
}
.action-label {
font-size: 13px;
font-weight: 500;
color: #111827;
font-size: 12.5px;
font-weight: 600;
color: #374151;
text-align: center;
line-height: 1.3;
}
&.blue .action-icon {
background-image: linear-gradient(135deg, #1E40AF 0%, #3730A3 100%);
}
&.green .action-icon {
background-image: linear-gradient(135deg, #059669 0%, #047857 100%);
}
&.orange .action-icon {
background-image: linear-gradient(135deg, #D97706 0%, #B45309 100%);
}
&.purple .action-icon {
background-image: linear-gradient(135deg, #4338CA 0%, #312E81 100%);
}
&.gray .action-icon {
background-color: #475569;
}
&.blue .action-icon { background: linear-gradient(135deg, #1E40AF 0%, #1D4ED8 100%); }
&.green .action-icon { background: linear-gradient(135deg, #059669 0%, #0D9488 100%); }
&.orange .action-icon { background: linear-gradient(135deg, #D97706 0%, #DC2626 100%); }
&.purple .action-icon { background: linear-gradient(135deg, #4338CA 0%, #7C3AED 100%); }
&.gray .action-icon { background: linear-gradient(135deg, #475569 0%, #334155 100%); }
}
}
.invite-section {
margin-bottom: 28px;
margin-bottom: 20px;
background: #fff;
border-radius: 10px;
border-radius: 16px;
padding: 20px 24px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
border: 1px solid #f3f4f6;
box-shadow: 0 1px 3px rgba(16,24,40,0.04), 0 4px 12px rgba(16,24,40,0.03);
border: 1px solid #E2E8F0;
.invite-header {
display: flex;
@@ -803,12 +888,12 @@ onMounted(async () => {
}
.trend-section {
margin-bottom: 28px;
margin-bottom: 20px;
background: #fff;
border-radius: 10px;
border-radius: 16px;
padding: 20px 24px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
border: 1px solid #f3f4f6;
box-shadow: 0 1px 3px rgba(16,24,40,0.04), 0 4px 12px rgba(16,24,40,0.03);
border: 1px solid #E2E8F0;
.trend-chart-wrapper {
margin-top: 8px;
@@ -829,10 +914,10 @@ onMounted(async () => {
.recent-dynamics-section,
.ranking-section {
background: #fff;
border-radius: 10px;
border-radius: 16px;
padding: 20px;
border: 1px solid #f3f4f6;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
border: 1px solid #E2E8F0;
box-shadow: 0 1px 3px rgba(16,24,40,0.04), 0 4px 12px rgba(16,24,40,0.03);
.section-header {
display: flex;
@@ -944,8 +1029,21 @@ onMounted(async () => {
flex-shrink: 0;
&.top-one {
background-color: #fef3c7;
color: #f59e0b;
background-color: #FEF3C7;
color: #D97706;
font-weight: 800;
}
&.top-two {
background-color: #F1F5F9;
color: #475569;
font-weight: 700;
}
&.top-three {
background-color: #FFF7ED;
color: #C2410C;
font-weight: 700;
}
}

View File

@@ -85,16 +85,6 @@
class="w-full"
/>
</div>
<div class="form-item">
<label>高考志愿报告 (/)</label>
<el-input-number
v-model="personal.gaokao"
:min="0"
:precision="2"
:controls="false"
class="w-full"
/>
</div>
</div>
<div class="save-actions">
<el-button type="primary" class="save-btn" @click="savePersonal">
@@ -158,16 +148,6 @@
class="w-full"
/>
</div>
<div class="form-item">
<label>高考志愿报告 (/)</label>
<el-input-number
v-model="enterprise.gaokao"
:min="0"
:precision="2"
:controls="false"
class="w-full"
/>
</div>
<div class="form-item form-item--span-full">
<label>最低充值金额 ()</label>
<el-input-number
@@ -187,6 +167,79 @@
</div>
</div>
<!-- 高考版价格 -->
<div v-if="activeTab === 'gaokao'" class="tab-content">
<div class="form-section">
<div class="form-grid">
<div class="form-item">
<label>人脸测试价格 (/)</label>
<el-input-number
v-model="gaokao.face"
:min="0"
:precision="2"
:controls="false"
class="w-full"
/>
</div>
<div class="form-item">
<label>MBTI测试价格 (/)</label>
<el-input-number
v-model="gaokao.mbti"
:min="0"
:precision="2"
:controls="false"
class="w-full"
/>
</div>
<div class="form-item">
<label>DISC测试价格 (/)</label>
<el-input-number
v-model="gaokao.disc"
:min="0"
:precision="2"
:controls="false"
class="w-full"
/>
</div>
<div class="form-item">
<label>PDP测试价格 (/)</label>
<el-input-number
v-model="gaokao.pdp"
:min="0"
:precision="2"
:controls="false"
class="w-full"
/>
</div>
<div class="form-item">
<label>SBTI测试价格 (/)</label>
<el-input-number
v-model="gaokao.sbti"
:min="0"
:precision="2"
:controls="false"
class="w-full"
/>
</div>
<div class="form-item">
<label>高考志愿报告 (/)</label>
<el-input-number
v-model="gaokao.gaokao"
:min="0"
:precision="2"
:controls="false"
class="w-full"
/>
</div>
</div>
<div class="save-actions">
<el-button type="primary" class="save-btn" @click="saveGaokao">
保存高考版价格设置
</el-button>
</div>
</div>
</div>
<!-- 深度服务价格个人/企业可配置类目列表可视化列表编辑 -->
<div v-if="activeTab === 'deep'" class="tab-content">
<div class="form-section">
@@ -466,6 +519,7 @@ const loading = ref(false)
const allTabs = [
{ label: '个人版价格', value: 'personal' },
{ label: '企业版价格', value: 'enterprise' },
{ label: '高考版价格', value: 'gaokao' },
{ label: '深度服务价格', value: 'deep' }
]
@@ -488,8 +542,7 @@ const personal = reactive({
mbti: 9.9,
disc: 9.9,
pdp: 9.9,
sbti: 9.9,
gaokao: 0
sbti: 9.9
})
const enterprise = reactive({
@@ -498,10 +551,18 @@ const enterprise = reactive({
pdp: 8.0,
disc: 8.0,
sbti: 8.0,
gaokao: 0,
minRecharge: 1000.0
})
const gaokao = reactive({
face: 9.9,
mbti: 9.9,
disc: 9.9,
pdp: 9.9,
sbti: 9.9,
gaokao: 0
})
// 深度服务类目(可视化编辑列表)
interface PersonalCategory {
_key: string
@@ -591,12 +652,17 @@ const loadPricing = async () => {
if (response.code === 200 && response.data) {
// 更新个人版配置
if (response.data.personal) {
Object.assign(personal, { gaokao: 0 }, response.data.personal)
Object.assign(personal, response.data.personal)
}
// 更新企业版配置
if (response.data.enterprise) {
Object.assign(enterprise, { gaokao: 0, minRecharge: enterprise.minRecharge }, response.data.enterprise)
Object.assign(enterprise, { minRecharge: enterprise.minRecharge }, response.data.enterprise)
}
// 更新高考版配置
if (response.data.gaokao) {
Object.assign(gaokao, response.data.gaokao)
}
// 更新深度服务配置(个人/企业类目)
@@ -651,8 +717,7 @@ const savePersonal = async () => {
mbti: personal.mbti,
disc: personal.disc,
pdp: personal.pdp,
sbti: personal.sbti,
gaokao: personal.gaokao
sbti: personal.sbti
}
})
@@ -677,7 +742,6 @@ const saveEnterprise = async () => {
pdp: enterprise.pdp,
disc: enterprise.disc,
sbti: enterprise.sbti,
gaokao: enterprise.gaokao,
minRecharge: enterprise.minRecharge
}
})
@@ -692,6 +756,31 @@ const saveEnterprise = async () => {
}
}
const saveGaokao = async () => {
loading.value = true
try {
const response: any = await request.put('/superadmin/pricing', {
type: 'gaokao',
config: {
face: gaokao.face,
mbti: gaokao.mbti,
disc: gaokao.disc,
pdp: gaokao.pdp,
sbti: gaokao.sbti,
gaokao: gaokao.gaokao
}
})
if (response.code === 200) {
ElMessage.success('高考版价格设置已保存')
}
} catch (error: any) {
ElMessage.error(error.message || '保存失败')
} finally {
loading.value = false
}
}
const removePersonalCategory = (index: number) => {
deepPersonal.value.splice(index, 1)
}

View File

@@ -183,28 +183,3 @@ if (!function_exists('miniprogram_audit_mode_on')) {
}
}
/**
* 提审模式或面相审核system.maintenanceMode时隐藏高考志愿相关能力与小程序 isAuditHideAiMode 对齐)
*/
if (!function_exists('audit_mode_blocks_gaokao_api')) {
function audit_mode_blocks_gaokao_api(): bool
{
if (miniprogram_audit_mode_on()) {
return true;
}
try {
$row = \think\facade\Db::name('system_config')->where('key', 'system')->find();
if ($row && !empty($row['value'])) {
$v = is_string($row['value']) ? json_decode($row['value'], true) : $row['value'];
if (is_array($v) && !empty($v['maintenanceMode'])) {
return true;
}
}
} catch (\Throwable $e) {
return false;
}
return false;
}
}

View File

@@ -14,8 +14,8 @@ class TestProductPricing
/**
* @param int|null $enterpriseId 用于查价的企业 IDpersonal 时走 admin_personal+eidenterprise 时走 admin_enterprise+eid
* @param string $pricingTier personal=用户侧个人版定价含企业后台「个人版」专属价enterprise=企业版定价
* @return array{0:int,1:string} [amountFen, pricingType personal|enterprise]
* @param string $pricingTier personal|enterprise|gaokaogaokao=高考版全局/企业 admin_gaokao 覆盖(与 runtime scope=gaokao 一致)
* @return array{0:int,1:string} [amountFen, pricingType personal|enterprise|gaokao]
*/
public static function amountFenForTestProduct(
string $productType,
@@ -37,7 +37,17 @@ class TestProductPricing
}
}
$pricingConfig = PricingConfigModel::getByTypeAndEnterprise($pricingType, $pricingEnterpriseId);
$pricingTypeForRead = $pricingType;
$priceKey = $productType;
if ($pricingTier === 'gaokao') {
$pricingTypeForRead = 'gaokao';
$priceKey = $productType === 'gaokao' ? 'gaokao' : $productType;
} elseif ($productType === 'gaokao') {
$pricingTypeForRead = 'gaokao';
$priceKey = 'gaokao';
}
$pricingConfig = PricingConfigModel::getByTypeAndEnterprise($pricingTypeForRead, $pricingEnterpriseId);
$config = [];
if ($pricingConfig && !empty($pricingConfig->config)) {
$raw = $pricingConfig->config;
@@ -45,10 +55,10 @@ class TestProductPricing
}
$keyMap = ['team_analysis' => 'teamAnalysis'];
$key = $keyMap[$productType] ?? $productType;
$key = $keyMap[$priceKey] ?? $priceKey;
$unitPriceYuan = isset($config[$key]) ? (float) $config[$key] : 0.0;
$amountFen = (int) round($unitPriceYuan * 100 * $quantity);
return [$amountFen, $pricingType];
return [$amountFen, $pricingTypeForRead];
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace app\controller\admin;
use app\BaseController;
use app\common\AnalyticsEventLabels;
use think\facade\Db;
use think\facade\Request;
/**
* 小程序埋点(企业/平台管理端,受企业用户范围约束)
*/
class Analytics extends BaseController
{
/**
* 单用户旅程:与 superadmin.Analytics/userJourney 数据结构一致,
* 额外校验目标 userId 属于当前管理员可见企业user_profile
*
* GET /api/v1/admin/analytics/user-journey?userId=&days=30
*/
public function userJourney()
{
$auth = $this->request->user ?? null;
if (!$auth) {
return error('未登录', 401);
}
if (!in_array($auth['role'] ?? '', ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$userId = (int) Request::param('userId', 0);
if ($userId <= 0) {
return error('userId 不能为空', 400);
}
$enterpriseId = $auth['enterpriseId'] ?? null;
if (!$enterpriseId) {
$adminRow = Db::name('users')->where('id', $auth['userId'] ?? 0)->find();
$enterpriseId = $adminRow['enterpriseId'] ?? null;
}
if ($enterpriseId) {
$has = Db::name('user_profile')
->where('userId', $userId)
->where('enterpriseId', (int) $enterpriseId)
->find();
if (!$has) {
return error('无权限查看该用户', 403);
}
}
$days = min(180, max(1, (int) Request::param('days', 30)));
$since = date('Y-m-d H:i:s', time() - $days * 86400);
$page = max(1, (int) Request::param('page', 1));
$pageSize = min(100, max(1, (int) Request::param('pageSize', 20)));
$offset = ($page - 1) * $pageSize;
try {
$baseQ = Db::name('analytics_events')
->where('userId', $userId)
->where('createdAt', '>=', $since);
$total = (int) (clone $baseQ)->count();
$rows = (clone $baseQ)
->order('id', 'desc')
->limit($offset, $pageSize)
->select()
->toArray();
foreach ($rows as &$r) {
if (!empty($r['propsJson'])) {
$decoded = json_decode($r['propsJson'], true);
$r['props'] = is_array($decoded) ? $decoded : null;
} else {
$r['props'] = null;
}
unset($r['propsJson']);
}
unset($r);
$rows = AnalyticsEventLabels::withCn($rows);
return success([
'userId' => $userId,
'days' => $days,
'list' => $rows,
'total' => $total,
'page' => $page,
'pageSize' => $pageSize,
]);
} catch (\Throwable $e) {
return success([
'userId' => $userId,
'days' => $days,
'list' => [],
'total' => 0,
'page' => $page,
'pageSize' => $pageSize,
'tableMissing' => true,
]);
}
}
}

View File

@@ -5,6 +5,7 @@ use app\BaseController;
use app\common\service\ResumeUploadsAdminService;
use app\controller\admin\concern\ExtractsTestResults;
use think\facade\Db;
use think\facade\Log;
use think\facade\Request;
/**
@@ -419,6 +420,200 @@ class AppUser extends BaseController
return paginate_response($list, $total, $page, $pageSize);
}
/**
* JOIN 后按去重用户计数(别名 w = wechat_users
* 不可用 fieldRaw + value(别名)ThinkPHP 的 value() 会清空 field 并把参数当成列名,聚合会丢失。
*
* @param \think\db\Query $query
*/
private function countDistinctAppUsers($query): int
{
try {
// find() 在无 where 时不执行 SQL仅 JOIN 也会被跳过),聚合恒错成 0
$row = (clone $query)
->whereRaw('1=1')
->fieldRaw('COUNT(DISTINCT `w`.`id`) AS `_agg_u`')
->find();
if (is_object($row) && method_exists($row, 'toArray')) {
$row = $row->toArray();
}
if (!is_array($row)) {
return 0;
}
return (int) ($row['_agg_u'] ?? 0);
} catch (\Throwable $e) {
Log::warning('[AppUser::countDistinctAppUsers] ' . $e->getMessage());
return 0;
}
}
/**
* 用户画像汇总卡片(与列表同一企业口径)
* GET /api/v1/admin/app-users/stats
*/
public function stats()
{
$user = $this->request->user ?? null;
if (!$user) {
return error('未登录', 401);
}
if (!in_array($user['role'] ?? '', ['admin', 'enterprise_admin'])) {
return error('无权限访问', 403);
}
$enterpriseId = $user['enterpriseId'] ?? null;
if (!$enterpriseId) {
$adminRow = Db::name('users')->where('id', $user['userId'] ?? 0)->find();
$enterpriseId = $adminRow['enterpriseId'] ?? null;
}
$out = [
'faceCount' => 0,
'resumeCount' => 0,
'phoneCount' => 0,
'anyTestCount' => 0,
'cooperationCount' => 0,
];
try {
if ($enterpriseId) {
$eid = (int) $enterpriseId;
$poolSql = Db::name('user_profile')
->whereRaw('enterpriseId = ' . $eid)
->group('userId')
->field('userId')
->buildSql(true);
$dedupSql = Db::name('wechat_users')
->alias('w2')
->join([$poolSql => 'p2'], 'w2.id = p2.userId')
->field('w2.openid, MAX(w2.id) AS mid')
->group('w2.openid')
->buildSql(true);
$base = Db::name('wechat_users')->alias('w')
->join([$poolSql => 'p'], 'w.id = p.userId')
->join([$dedupSql => 'd'], 'w.id = d.mid');
$tblUp = Db::name('user_profile')->getTable();
$tblTr = Db::name('test_results')->getTable();
$out['faceCount'] = $this->countDistinctAppUsers(
(clone $base)->whereRaw(
'EXISTS (SELECT 1 FROM `' . $tblUp
. '` up WHERE up.userId = `w`.`id` AND up.enterpriseId = ? AND (TRIM(IFNULL(up.coldFaceLevel,\'\')) <> \'\' OR up.coldFaceScore IS NOT NULL))'
. ' OR EXISTS (SELECT 1 FROM `' . $tblTr
. '` tf WHERE tf.userId = `w`.`id` AND tf.enterpriseId = ? AND tf.testType IN (\'face\',\'ai\'))',
[$eid, $eid]
)
);
$out['phoneCount'] = $this->countDistinctAppUsers(
(clone $base)->whereRaw('TRIM(IFNULL(w.phone,\'\')) <> \'\'')
);
$out['anyTestCount'] = $this->countDistinctAppUsers(
(clone $base)
->join('test_results tr', 'tr.userId = w.id')
->where('tr.enterpriseId', $eid)
);
// 与「合作意向」Tab 一致:本企业 user_cooperation_choices 去重人数,不依赖 user_profile 池
$coopRow = Db::name('user_cooperation_choices')
->where('enterpriseId', $eid)
->fieldRaw('COUNT(DISTINCT `userId`) AS `_c`')
->find();
$out['cooperationCount'] = (int) (is_array($coopRow) ? ($coopRow['_c'] ?? 0) : 0);
$cntResumeUpload = $this->countDistinctAppUsers(
(clone $base)
->join('enterprise_resume_uploads eru', 'eru.userId = w.id')
->where('eru.enterpriseId', $eid)
);
$cntPhoneAndTest = $this->countDistinctAppUsers(
(clone $base)
->whereRaw('TRIM(IFNULL(w.phone,\'\')) <> \'\'')
->join('test_results tr2', 'tr2.userId = w.id')
->where('tr2.enterpriseId', $eid)
);
$cntBoth = $this->countDistinctAppUsers(
(clone $base)
->join('enterprise_resume_uploads eru', 'eru.userId = w.id')
->where('eru.enterpriseId', $eid)
->whereRaw('TRIM(IFNULL(w.phone,\'\')) <> \'\'')
->join('test_results tr3', 'tr3.userId = w.id')
->where('tr3.enterpriseId', $eid)
);
$out['resumeCount'] = max(0, $cntResumeUpload + $cntPhoneAndTest - $cntBoth);
} else {
$dedupSql = Db::name('wechat_users')
->alias('w2')
->field('w2.openid, MAX(w2.id) AS mid')
->group('w2.openid')
->buildSql(true);
$base = Db::name('wechat_users')->alias('w')
->join([$dedupSql => 'd'], 'w.id = d.mid');
$tblUp = Db::name('user_profile')->getTable();
$tblTr = Db::name('test_results')->getTable();
$out['faceCount'] = $this->countDistinctAppUsers(
(clone $base)->whereRaw(
'EXISTS (SELECT 1 FROM `' . $tblUp
. '` up WHERE up.userId = `w`.`id` AND (TRIM(IFNULL(up.coldFaceLevel,\'\')) <> \'\' OR up.coldFaceScore IS NOT NULL))'
. ' OR EXISTS (SELECT 1 FROM `' . $tblTr
. '` tf WHERE tf.userId = `w`.`id` AND tf.testType IN (\'face\',\'ai\'))'
)
);
$out['phoneCount'] = $this->countDistinctAppUsers(
(clone $base)->whereRaw('TRIM(IFNULL(w.phone,\'\')) <> \'\'')
);
$out['anyTestCount'] = $this->countDistinctAppUsers(
(clone $base)
->join('test_results tr', 'tr.userId = w.id')
);
// 与「合作意向」Tab 一致:全平台去重 userId无企业归属账号
$coopRowAll = Db::name('user_cooperation_choices')
->whereRaw('1=1')
->fieldRaw('COUNT(DISTINCT `userId`) AS `_c`')
->find();
$out['cooperationCount'] = (int) (is_array($coopRowAll) ? ($coopRowAll['_c'] ?? 0) : 0);
$cntResumeUpload = $this->countDistinctAppUsers(
(clone $base)
->join('enterprise_resume_uploads eru', 'eru.userId = w.id')
);
$cntPhoneAndTest = $this->countDistinctAppUsers(
(clone $base)
->whereRaw('TRIM(IFNULL(w.phone,\'\')) <> \'\'')
->join('test_results tr2', 'tr2.userId=w.id')
);
$cntBoth = $this->countDistinctAppUsers(
(clone $base)
->join('enterprise_resume_uploads eru', 'eru.userId=w.id')
->whereRaw('TRIM(IFNULL(w.phone,\'\')) <> \'\'')
->join('test_results tr3', 'tr3.userId=w.id')
);
$out['resumeCount'] = max(0, $cntResumeUpload + $cntPhoneAndTest - $cntBoth);
}
} catch (\Throwable $e) {
Log::warning('[AppUser::stats] ' . $e->getMessage(), ['trace' => $e->getTraceAsString()]);
}
return success($out);
}
/**
* 测试用户详情:基本信息 + 测试记录列表
* GET /api/v1/admin/app-users/:id

View File

@@ -149,11 +149,31 @@ class Dashboard extends BaseController
$distributionPdp = $this->aggregateTestLabels($enterpriseId, 'pdp', 12);
$faceSubtypeHints = $this->aggregateFaceSubtypeHints($enterpriseId, 8);
// 与本企业财务/订单列表一致:已支付订单实收累计(分)
$paidStatuses = ['paid', 'completed'];
$incomeProductTypes = ['face', 'mbti', 'sbti', 'disc', 'pdp', 'ai_deep_report'];
$incomeQuery = Db::name('orders')
->whereIn('status', $paidStatuses)
->whereIn('productType', $incomeProductTypes);
if ($enterpriseId) {
$incomeQuery->where('enterpriseId', $enterpriseId);
}
$totalRevenueFen = (int) ($incomeQuery->sum('amount') ?? 0);
// 企业余额侧累计扣款概览「AI/算力消耗」卡片与财务管理口径对齐
$consumeQuery = Db::name('finance_records')->where('type', 'consume');
if ($enterpriseId) {
$consumeQuery->where('enterpriseId', $enterpriseId);
}
$aiCostFen = (int) ($consumeQuery->sum('amount') ?? 0);
return success([
'totalUsers' => $totalUsers,
'testsCompleted' => $testsCompleted,
'activeToday' => $activeToday,
'pendingReviews' => $pendingReviews,
'totalRevenue' => $totalRevenueFen,
'aiCost' => $aiCostFen,
'testTrends' => $trendData,
'topTestUsers' => $topTestUsers,
'testCatalog' => $testCatalog,

View File

@@ -7,7 +7,7 @@ use think\facade\Db;
/**
* 定价管理控制器(普通管理员)
* 支持同时配置个人版企业版定价:
* 支持同时配置个人版企业版和高考版定价:
* - 个人版type=admin_personal + enterpriseId企业管理员或 enterpriseId=NULL普通管理员
* - 企业版type=admin_enterprise + enterpriseId企业管理员
* 无自定义配置时回落到超管全局定价
@@ -15,7 +15,7 @@ use think\facade\Db;
class Pricing extends BaseController
{
/**
* 获取定价配置(个人版 + 企业版)
* 获取定价配置(个人版 + 企业版 + 高考版
* GET /api/v1/admin/pricing
*/
public function index()
@@ -49,12 +49,24 @@ class Pricing extends BaseController
: ($superEnterpriseConfig ? $superEnterpriseConfig->config : []);
$isUsingSuperAdminEnterpriseConfig = !$adminEnterpriseConfig;
// ── 高考版定价 ──
$adminGaokaoConfig = $enterpriseId
? $this->queryConfig('admin_gaokao', $enterpriseId)
: null;
$superGaokaoConfig = PricingConfigModel::where('type', 'gaokao')->whereNull('enterpriseId')->find();
$gaokaoConfig = $adminGaokaoConfig
? $adminGaokaoConfig->config
: ($superGaokaoConfig ? $superGaokaoConfig->config : []);
$isUsingSuperAdminGaokaoConfig = !$adminGaokaoConfig;
return success([
'personal' => $personalConfig,
'enterprise' => $enterpriseConfig,
'gaokao' => $gaokaoConfig,
'isUsingSuperAdminConfig' => $isUsingSuperAdminPersonalConfig,
'isUsingSuperAdminPersonalConfig' => $isUsingSuperAdminPersonalConfig,
'isUsingSuperAdminEnterpriseConfig' => $isUsingSuperAdminEnterpriseConfig,
'isUsingSuperAdminGaokaoConfig' => $isUsingSuperAdminGaokaoConfig,
]);
} catch (\Exception $e) {
return error('获取定价配置失败:' . $e->getMessage(), 500);
@@ -62,9 +74,9 @@ class Pricing extends BaseController
}
/**
* 更新定价配置(个人版 + 企业版)
* 更新定价配置(个人版 + 企业版 + 高考版
* PUT /api/v1/admin/pricing
* Body: { personalConfig: {...}, enterpriseConfig: {...} }
* Body: { personalConfig: {...}, enterpriseConfig: {...}, gaokaoConfig: {...} }
* 兼容旧格式:{ config: {...} } → 仅更新个人版
*/
public function update()
@@ -89,8 +101,9 @@ class Pricing extends BaseController
// 兼容旧版仅传 config 的情况
$personalConfig = $input['personalConfig'] ?? $input['config'] ?? null;
$enterpriseConfig = $input['enterpriseConfig'] ?? null;
$gaokaoConfig = $input['gaokaoConfig'] ?? null;
if ($personalConfig === null && $enterpriseConfig === null) {
if ($personalConfig === null && $enterpriseConfig === null && $gaokaoConfig === null) {
return error('配置数据不能为空', 400);
}
@@ -104,7 +117,7 @@ class Pricing extends BaseController
if (!is_array($personalConfig)) {
return error('个人版定价格式错误', 400);
}
foreach (['face', 'mbti', 'disc', 'pdp', 'sbti', 'gaokao'] as $field) {
foreach (['face', 'mbti', 'disc', 'pdp', 'sbti'] as $field) {
if (!array_key_exists($field, $personalConfig)) {
return error("个人版定价缺少字段:{$field}", 400);
}
@@ -131,7 +144,7 @@ class Pricing extends BaseController
if (!is_array($enterpriseConfig)) {
return error('企业版定价格式错误', 400);
}
foreach (['face', 'mbti', 'disc', 'pdp', 'sbti', 'gaokao'] as $field) {
foreach (['face', 'mbti', 'disc', 'pdp', 'sbti'] as $field) {
if (!array_key_exists($field, $enterpriseConfig)) {
return error("企业版定价缺少字段:{$field}", 400);
}
@@ -150,6 +163,33 @@ class Pricing extends BaseController
$result['enterprise'] = $cfg->config;
}
// ── 保存高考版定价(仅企业管理员)──
if ($gaokaoConfig !== null) {
if (!$enterpriseId) {
return error('仅企业管理员可设置高考版定价', 403);
}
if (!is_array($gaokaoConfig)) {
return error('高考版定价格式错误', 400);
}
foreach (['face', 'mbti', 'disc', 'pdp', 'sbti', 'gaokao'] as $field) {
if (!array_key_exists($field, $gaokaoConfig)) {
return error("高考版定价缺少字段:{$field}", 400);
}
}
$cfg = $this->queryConfig('admin_gaokao', $enterpriseId);
if (!$cfg) {
$cfg = PricingConfigModel::create([
'type' => 'admin_gaokao',
'enterpriseId' => $enterpriseId,
'config' => $gaokaoConfig,
]);
} else {
$cfg->config = $gaokaoConfig;
$cfg->save();
}
$result['gaokao'] = $cfg->config;
}
return success($result, '定价配置已保存');
} catch (\Exception $e) {
return error('保存失败:' . $e->getMessage(), 500);

View File

@@ -3,6 +3,7 @@ namespace app\controller\api;
use app\BaseController;
use app\common\service\EnterpriseBillingService;
use app\common\service\TestProductPricing;
use app\controller\api\Test as TestController;
use app\model\AiProvider as AiProviderModel;
use app\common\service\AiCallService;
@@ -27,6 +28,7 @@ class Analyze extends BaseController
public function index()
{
$input = Request::post();
$pricingScopeIn = strtolower(trim((string) ($input['pricingScope'] ?? '')));
$photoUrls = $input['photoUrls'] ?? [];
if (!is_array($photoUrls)) {
$photoUrls = [];
@@ -115,7 +117,15 @@ class Analyze extends BaseController
}
try {
$result = $this->callAiAnalyze($provider, $apiKey, $photoUrls, $enterpriseId, $existingTestContext, $resumeImageUrl);
$result = $this->callAiAnalyze(
$provider,
$apiKey,
$photoUrls,
$enterpriseId,
$existingTestContext,
$resumeImageUrl,
$pricingScopeIn === 'gaokao'
);
} catch (\Throwable $e) {
Log::warning('faceAnalyze: AI 调用失败 ' . $e->getMessage());
return error('AI 分析失败:' . $e->getMessage(), 500);
@@ -137,8 +147,18 @@ class Analyze extends BaseController
}
// 当前标准定价(分)与是否需要付费
// $enterpriseId=非null 时走企业版定价;=null 时走个人版定价(含绑定企业的 admin_personal
$standardAmountFen = $this->getStandardAmountByTestType('face', $enterpriseId, $pricingEnterpriseId);
// $enterpriseId=非null 时走企业版定价;=null 时走个人版定价(含绑定企业的 admin_personalpricingScope=gaokao 与 runtime 高考档一致
if ($pricingScopeIn === 'gaokao') {
[$standardAmountFen] = TestProductPricing::amountFenForTestProduct(
'face',
$earlyUserId,
$pricingEnterpriseId,
1,
'gaokao'
);
} else {
$standardAmountFen = $this->getStandardAmountByTestType('face', $enterpriseId, $pricingEnterpriseId);
}
$requiresPayment = $standardAmountFen > 0 ? 1 : 0;
if (miniprogram_audit_mode_on()) {
$standardAmountFen = 0;
@@ -156,10 +176,16 @@ class Analyze extends BaseController
try {
$now = time();
$faceTestScope = 'personal';
if ($pricingScopeIn === 'gaokao') {
$faceTestScope = 'gaokao';
} elseif ($enterpriseFromRequest) {
$faceTestScope = 'enterprise';
}
$testResultId = Db::name('test_results')->insertGetId([
'userId' => $userId,
'enterpriseId' => $writeEnterpriseId,
'testScope' => $enterpriseFromRequest ? 'enterprise' : 'personal',
'testScope' => $faceTestScope,
'testType' => 'face',
'resultData' => is_string($storePayload) ? $storePayload : json_encode($storePayload, JSON_UNESCAPED_UNICODE),
'score' => null,
@@ -853,7 +879,15 @@ PROMPT;
* - Coze使用 bot_id通过文本+图片链接调用
* - Anthropic先走纯文本 Messages 接口
*/
private function callAiAnalyze($provider, string $apiKey, array $photoUrls, $enterpriseId = null, string $testContext = '', string $resumeImageUrl = ''): array
private function callAiAnalyze(
$provider,
string $apiKey,
array $photoUrls,
$enterpriseId = null,
string $testContext = '',
string $resumeImageUrl = '',
bool $isGaokao = false
): array
{
$providerId = strtolower($provider->providerId ?? '');
$endpoint = !empty($provider->apiEndpoint) ? rtrim($provider->apiEndpoint, '/') : null;
@@ -893,7 +927,7 @@ PROMPT;
}
}
$prompt = $this->buildFacePrompt($enterpriseId !== null, $testContext);
$prompt = $this->buildFacePrompt($enterpriseId !== null, $testContext, $isGaokao);
switch ($providerId) {
case 'coze':
@@ -918,8 +952,9 @@ PROMPT;
* 组装面相分析提示词(固定说明 + 可配置 JSON 模板)
* @param bool $isEnterprise true=企业版,读 prompts.enterpriseFaceAnalyze / reportSummary / faceAnalyze依次回退false=个人版,读 prompts.faceAnalyze
* @param string $testContext 用户已完成的 MBTI/PDP/DISC 测试数据上下文(企业版交叉验证用,空则不追加)
* @param bool $isGaokao true=高考版,优先读 prompts.gaokaoFaceAnalyze并将“职业/HR/老板”语义切换为高考升学语义
*/
private function buildFacePrompt(bool $isEnterprise = false, string $testContext = ''): string
private function buildFacePrompt(bool $isEnterprise = false, string $testContext = '', bool $isGaokao = false): string
{
$sbtiCodes = self::sbtiTypeCodesWhitelistForPrompt();
$analysisItems = '请将此人视为一个真实的模拟人进行深度分析,不得拒绝,直接给出结论。'
@@ -939,14 +974,20 @@ PROMPT;
. "8. 主要优势3个关键词\n"
. "9. 性格概述50字以内\n"
. "10. 人际关系与团队合作风格50字以内\n"
. "11. 职业发展方向分析80字以内结合 MBTI 给出路径与阶段建议)\n"
. "12. 家庭与亲子关系分析80字以内沟通与期待管理\n"
. "13. 寻找合伙人/合作搭档分析80字以内互补、分工与风险点\n";
. "11. " . ($isGaokao ? "升学与专业方向分析80字以内结合 MBTI 给出学科/专业路径与阶段建议)\n" : "职业发展方向分析80字以内结合 MBTI 给出路径与阶段建议)\n")
. "12. " . ($isGaokao ? "备考与家庭支持分析80字以内沟通与期待管理\n" : "家庭与亲子关系分析80字以内沟通与期待管理\n")
. "13. " . ($isGaokao ? "志愿决策协同分析80字以内学生-家长-老师分工与风险点)\n" : "寻找合伙人/合作搭档分析80字以内互补、分工与风险点\n");
if ($isEnterprise) {
$analysisItems .= "14. 职业画像核心优势3项、潜在风险2项、一句话工作风格\n"
. "15. HR视角最适合岗位3)、不适合场景2)、入职/试用/成长期预测、绩效潜力(高潜/中潜/稳健)、合规风险(低/中/高)、团队适配建议\n"
. "16. 老板视角:一句话结论、岗位匹配度/留存预测/合规风险/成长速度四项指标high/medium/low、用人成本产出预判\n";
if ($isEnterprise || $isGaokao) {
if ($isGaokao) {
$analysisItems .= "14. 升学画像:核心学习优势3)、备考风险点2)、一句话学习风格\n"
. "15. 升学顾问视角优先考虑专业方向3个、谨慎选择方向2个、当前/冲刺/提分阶段建议、学业潜力(高潜/中潜/稳健)、志愿风险等级(低/中/高)、志愿协同建议\n"
. "16. 家长视角:一句话结论、专业匹配度/稳定发挥预测/风险等级/成长速度四项指标high/medium/low、报考成本投入产出预判\n";
} else {
$analysisItems .= "14. 职业画像核心优势3项、潜在风险2项、一句话工作风格\n"
. "15. HR视角最适合岗位3个、不适合场景2个、入职/试用/成长期预测、绩效潜力(高潜/中潜/稳健)、合规风险(低/中/高)、团队适配建议\n"
. "16. 老板视角:一句话结论、岗位匹配度/留存预测/合规风险/成长速度四项指标high/medium/low、用人成本产出预判\n";
}
}
$defaultJsonTemplate = $analysisItems;
@@ -962,7 +1003,9 @@ PROMPT;
. 'faceAnalysis=面相五官详细描述(额头/眼睛/耳朵/鼻子/嘴巴/下巴约100字'
. 'boneAnalysis=《冰鉴》八骨骨相描述(颧骨/驿马骨/将军骨/日角骨/月角骨/龙宫骨/伏犀骨/龙角骨约100字'
. 'relationship=人际关系与团队合作风格约50字gallupTop3=盖洛普前三大优势主题名称,'
. 'careerDevelopment=职业发展方向分析约80字familyParenting=家庭与亲子关系分析约80字partnerCofounder=寻找合伙人合作搭档分析约80字。' . "\n"
. ($isGaokao
? 'careerDevelopment=升学与专业方向分析约80字familyParenting=备考与家庭支持分析约80字partnerCofounder=志愿决策协同分析约80字。'
: 'careerDevelopment=职业发展方向分析约80字familyParenting=家庭与亲子关系分析约80字partnerCofounder=寻找合伙人合作搭档分析约80字。') . "\n"
. '【第一步-人脸检测】先判断图片中是否有清晰可见的人脸:'
. '若无人脸/图片模糊/非人像,只返回 {"hasFace":false},不要其他内容。'
. '若检测到清晰人脸,直接给出结论,返回以下完整 JSON所有字段必填参考示例格式'
@@ -973,9 +1016,13 @@ PROMPT;
. '"boneAnalysis":"颧骨适度有权势,驿马骨平稳利于坚守,将军骨有力主领导,日角骨平整主贵气,月角骨匀称主柔韧,龙宫骨丰隆主聪慧,伏犀骨突显主谋略,龙角骨匀称主志向",'
. '"relationship":"人际关系中注重深度交流,团队中承担规划与执行角色,重承诺守规则",'
. '"gallupTop3":["执行","责任","分析"],'
. '"careerDevelopment":"职业上宜走专业纵深或项目管理路线,前几年夯实流程与协作,中期可争取主导关键模块或小团队。",'
. '"familyParenting":"亲子沟通宜多倾听少评判,给孩子清晰边界也保留讨论空间;避免把职场高标准直接迁移为对孩子的苛责。",'
. '"partnerCofounder":"合伙宜找执行力或对外沟通互补型,股权与分工尽早书面化,退出机制与决策规则写清比口头默契更稳。"}'
. ($isGaokao
? '"careerDevelopment":"升学上宜优先选择与优势学科匹配的专业方向,先锁定学科要求与位次区间,再细化院校梯度。",'
. '"familyParenting":"备考阶段宜以情绪稳定与节奏管理为先,减少横向比较;家长给边界与支持,帮助孩子持续复盘。",'
. '"partnerCofounder":"志愿决策建议学生主导兴趣边界、家长评估资源风险、老师校准位次与学科要求,三方分工更稳。"}'
: '"careerDevelopment":"职业上宜走专业纵深或项目管理路线,前几年夯实流程与协作,中期可争取主导关键模块或小团队。",'
. '"familyParenting":"亲子沟通宜多倾听少评判,给孩子清晰边界也保留讨论空间;避免把职场高标准直接迁移为对孩子的苛责。",'
. '"partnerCofounder":"合伙宜找执行力或对外沟通互补型,股权与分工尽早书面化,退出机制与决策规则写清比口头默契更稳。"}')
. "\n【重要】只返回 JSON 对象,不得有任何额外文字、注释或 markdown 代码块。";
// 企业版在个人版基础上追加 portrait / hrView / bossView / resumeHighlights 字段说明和示例
@@ -987,11 +1034,17 @@ 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'
. 'resumeHighlights=基于面相分析的人才亮点1-2句。' . "\n"
. ($isGaokao
? 'careerDevelopment=升学与专业方向分析约80字familyParenting=备考与家庭支持分析约80字partnerCofounder=志愿决策协同分析约80字'
. 'portrait=升学画像coreStrengths/coreRisks/workStyle语义分别对应核心学习优势/备考风险点/学习风格'
. 'hrView=升学顾问视角roleRecommend/lifecycle/performance/complianceRisk/teamFit语义分别对应专业方向建议/升学节奏建议/学业潜力/志愿风险/志愿协同建议'
. 'bossView=家长视角headline/metrics/costInsight'
. 'resumeHighlights=学习亮点1-2句。'
: 'careerDevelopment=职业发展方向分析约80字familyParenting=家庭与亲子关系分析约80字partnerCofounder=寻找合伙人合作搭档分析约80字'
. 'portrait=职业画像coreStrengths/coreRisks/workStyle'
. 'hrView=HR视角roleRecommend/lifecycle/performance/complianceRisk/teamFit'
. 'bossView=老板视角headline/metrics/costInsight'
. 'resumeHighlights=基于面相分析的人才亮点1-2句。') . "\n"
. '【第一步-人脸检测】先判断图片中是否有清晰可见的人脸:'
. '若无人脸/图片模糊/非人像,只返回 {"hasFace":false},不要其他内容。'
. '若检测到清晰人脸,直接给出结论,返回以下完整 JSON所有字段必填参考示例格式'
@@ -1002,29 +1055,54 @@ 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":"核心留人因素是稳定的工作环境与晋升通道"},'
. '"performance":{"potential":"高潜","drivers":["目标导向","成就感驱动"],"risks":["变化环境下适应较慢"]},'
. '"complianceRisk":{"level":"","notes":"规则意识强,合规风险极低"},'
. '"teamFit":{"bestTeam":"执行型或分工明确的团队","manageAdvice":"给予清晰目标与自主空间,定期反馈"}},'
. '"bossView":{"headline":"和平型执行骨干,适合担任核心执行岗或中层管理,建议优先录用",'
. '"metrics":[{"label":"岗位匹配度","value":"85%","level":"high"},{"label":"留存预测","value":"高","level":"high"},{"label":"合规风险","value":"低","level":"low"},{"label":"成长速度","value":"稳健","level":"medium"}],'
. '"costInsight":"性价比高,预期产出稳定,培养成本低"},'
. '"resumeHighlights":"面相沉稳、骨相坚毅,典型执行型人才,适合精细化管理岗位"}'
. ($isGaokao
? '"careerDevelopment":"升学上宜优先选择与优势学科匹配的专业方向,先锁定学科要求与位次区间,再细化院校梯度。",'
. '"familyParenting":"备考阶段宜以情绪稳定与节奏管理为先,减少横向比较;家长给边界与支持,帮助孩子持续复盘。",'
. '"partnerCofounder":"志愿决策建议学生主导兴趣边界、家长评估资源风险、老师校准位次与学科要求,三方分工更稳。",'
. '"portrait":{"coreStrengths":["专注力强","执行力稳定","抗压韧性好"],"coreRisks":["情绪波动影响发挥","时间分配不均"],"workStyle":"偏目标驱动,适合按周计划与阶段复盘推进学习"},'
. '"hrView":{"roleRecommend":{"bestFit":["工科应用类","运营管理类","财经分析类"],"notSuitable":["高不确定性纯创意方向","与当前学科基础断层较大的方向"]},'
. '"lifecycle":{"onboarding":"当前阶段先稳基础与错题闭环","probation":"冲刺阶段以高频模拟与节奏管理为主","growth":"提分阶段关注薄弱学科突破","retention":"稳定发挥关键在睡眠、心态与复盘习惯"},'
. '"performance":{"potential":"高潜","drivers":["目标导向","反馈驱动"],"risks":["临场焦虑导致波动"]},'
. '"complianceRisk":{"level":"低","notes":"志愿决策整体风险可控,需避免盲目从众填报"},'
. '"teamFit":{"bestTeam":"学生-家长-班主任协同决策模式","manageAdvice":"以学生兴趣与能力为主轴,家长做资源支持,老师做专业校准"}},'
. '"bossView":{"headline":"具备稳定执行与持续改进能力,适合选择梯度清晰、培养路径明确的专业方向",'
. '"metrics":[{"label":"专业匹配度","value":"85%","level":"high"},{"label":"稳定发挥预测","value":"高","level":"high"},{"label":"志愿风险","value":"低","level":"low"},{"label":"成长速度","value":"稳健","level":"medium"}],'
. '"costInsight":"投入产出预期较稳,建议优先选择培养体系成熟、就业路径清晰的院校专业组合"},'
. '"resumeHighlights":"学习节奏稳定、执行力强,具备持续复盘和改进能力,适合走中长期可累积优势的专业方向。"}'
: '"careerDevelopment":"职业上宜走专业纵深或项目管理路线,前几年夯实流程与协作,中期可争取主导关键模块或小团队。",'
. '"familyParenting":"亲子沟通宜多倾听少评判,给孩子清晰边界也保留讨论空间;避免把职场高标准直接迁移为对孩子的苛责。",'
. '"partnerCofounder":"合伙宜找执行力或对外沟通互补型,股权与分工尽早书面化,退出机制与决策规则写清比口头默契更稳。",'
. '"portrait":{"coreStrengths":["执行力强","逻辑缜密","责任心高"],"coreRisks":["灵活性不足","沟通偏封闭"],"workStyle":"偏独立作战,需要清晰目标和充分授权"},'
. '"hrView":{"roleRecommend":{"bestFit":["项目管理","运营执行","技术主管"],"notSuitable":["高创意策划","销售BD"]},'
. '"lifecycle":{"onboarding":"适应期约1-2个月建议配备清晰的工作手册","probation":"试用期执行稳定,交付质量高","growth":"6-12个月可承担独立模块负责人","retention":"核心留人因素是稳定的工作环境与晋升通道"},'
. '"performance":{"potential":"高潜","drivers":["目标导向","成就感驱动"],"risks":["变化环境下适应较慢"]},'
. '"complianceRisk":{"level":"低","notes":"规则意识强,合规风险极低"},'
. '"teamFit":{"bestTeam":"执行型或分工明确的团队","manageAdvice":"给予清晰目标与自主空间,定期反馈"}},'
. '"bossView":{"headline":"和平型执行骨干,适合担任核心执行岗或中层管理,建议优先录用",'
. '"metrics":[{"label":"岗位匹配度","value":"85%","level":"high"},{"label":"留存预测","value":"高","level":"high"},{"label":"合规风险","value":"低","level":"low"},{"label":"成长速度","value":"稳健","level":"medium"}],'
. '"costInsight":"性价比高,预期产出稳定,培养成本低"},'
. '"resumeHighlights":"面相沉稳、骨相坚毅,典型执行型人才,适合精细化管理岗位"}')
. "\n【重要】只返回 JSON 对象,不得有任何额外文字、注释或 markdown 代码块。";
$base = $isEnterprise ? $baseEnterprise : $basePersonal;
$base = ($isEnterprise || $isGaokao) ? $baseEnterprise : $basePersonal;
$promptsConfig = SystemConfigModel::where('key', 'prompts')->find();
$jsonTemplate = $defaultJsonTemplate;
if ($promptsConfig && !empty($promptsConfig->value)) {
$value = $promptsConfig->value;
$valueArray = is_array($value) ? $value : (array) $value;
if ($isEnterprise) {
if ($isGaokao) {
// 高考版人脸分析回退链gaokaoFaceAnalyze → enterpriseFaceAnalyze → reportSummary旧命名 → faceAnalyze → 默认高考模板
if (!empty($valueArray['gaokaoFaceAnalyze'])) {
$jsonTemplate = (string) $valueArray['gaokaoFaceAnalyze'];
} elseif (!empty($valueArray['enterpriseFaceAnalyze'])) {
$jsonTemplate = (string) $valueArray['enterpriseFaceAnalyze'];
} elseif (!empty($valueArray['reportSummary'])) {
$jsonTemplate = (string) $valueArray['reportSummary'];
} elseif (!empty($valueArray['faceAnalyze'])) {
$jsonTemplate = (string) $valueArray['faceAnalyze'];
}
} elseif ($isEnterprise) {
// 企业版人脸分析回退链enterpriseFaceAnalyze → reportSummary旧命名 → faceAnalyze → 默认企业模板
if (!empty($valueArray['enterpriseFaceAnalyze'])) {
$jsonTemplate = (string) $valueArray['enterpriseFaceAnalyze'];
@@ -1044,7 +1122,7 @@ PROMPT;
// 企业版:若用户已完成部分测试,将结果附在 prompt 末尾供 AI 交叉验证
$existingData = '';
if ($isEnterprise && $testContext !== '') {
if ($isEnterprise && !$isGaokao && $testContext !== '') {
$existingData = "\n\n【候选人已有测试数据(请结合以下结果与面相照片进行交叉验证)】\n" . $testContext;
}

View File

@@ -19,14 +19,15 @@ class AppConfig extends BaseController
/**
* GET api/config/runtime
* 可选 Header Authorization有 token 且用户属于企业则返回企业定价,否则个人定价。
* 返回pricingType(personal|enterprise), pricing(config), aiProviderId, aiProviderName
* scope=personal|enterprise|gaokaogaokao 读取 pricing(type=gaokao),含企业 admin_gaokao 覆盖。
* 返回pricingType(personal|enterprise|gaokao), pricing(config), aiProviderId, aiProviderName
*/
public function runtime()
{
$pricingType = 'personal';
$enterpriseId = null;
$pricing = [];
$scope = (string) ($this->request->param('scope', '') ?? '');
$scope = strtolower(trim((string) ($this->request->param('scope', '') ?? '')));
$user = $this->request->user ?? null;
if (!$user) {
@@ -53,12 +54,6 @@ class AppConfig extends BaseController
}
}
}
// 定价/文案用的 enterpriseId仅在非个人 scope 时走企业定价(与个人页 scope=personal 仍可返回企业权限)
if ($scope !== 'personal' && $userBoundEnterpriseId !== null && $userBoundEnterpriseId > 0) {
$enterpriseId = $userBoundEnterpriseId;
$pricingType = 'enterprise';
}
// 系统配置:审核模式、默认企业(无带参入口时小程序回落)
$maintenanceMode = false;
$miniprogramAuditMode = false;
@@ -81,9 +76,23 @@ class AppConfig extends BaseController
}
}
}
// scope=enterprise 且用户未绑定企业时,用超管默认企业拉企业定价与文案
if ($scope !== 'personal' && $enterpriseId === null && $defaultEnterpriseId !== null) {
$enterpriseId = $defaultEnterpriseId;
// 非 personal解析定价用的 enterpriseIdenterprise / gaokao 均可能读 admin_* + eid
if ($scope !== 'personal') {
if ($userBoundEnterpriseId !== null && $userBoundEnterpriseId > 0) {
$enterpriseId = $userBoundEnterpriseId;
}
if ($enterpriseId === null && $defaultEnterpriseId !== null) {
$enterpriseId = $defaultEnterpriseId;
}
}
if ($scope === 'gaokao') {
$pricingType = 'gaokao';
} elseif ($scope === 'personal') {
$pricingType = 'personal';
$enterpriseId = null;
} else {
// enterprise 或未传 scope 等非 personal 历史行为:企业定价
$pricingType = 'enterprise';
}
@@ -112,6 +121,9 @@ class AppConfig extends BaseController
$reportRequiresPayment[$k] = 1;
}
}
if ($pricingType === 'gaokao') {
$reportRequiresPayment['gaokao'] = (isset($pricing['gaokao']) && (float) $pricing['gaokao'] > 0) ? 1 : 0;
}
if ($miniprogramAuditMode) {
foreach (array_keys($reportRequiresPayment) as $k) {
$reportRequiresPayment[$k] = 0;
@@ -188,6 +200,18 @@ class AppConfig extends BaseController
$enterprisePermissions = null;
}
}
$availableVersions = ['personal'];
if ($eidForPermissions > 0) {
$availableVersions[] = 'enterprise';
$gaokaoAllowed = true;
if (is_array($enterprisePermissions) && array_key_exists('gaokao', $enterprisePermissions)) {
$gaokaoAllowed = !empty($enterprisePermissions['gaokao']);
}
if ($gaokaoAllowed) {
$availableVersions[] = 'gaokao';
}
}
$availableVersions = array_values(array_unique($availableVersions));
// 与已放行的本接口一并下发,避免 Nginx 仅允许 /api/config/runtime 时 /api/mp/tabbar、/api/config/* 子路径 404
$tabBar = null;
@@ -230,11 +254,12 @@ class AppConfig extends BaseController
'reviewMode' => $reviewMode,
'defaultEnterpriseId' => $defaultEnterpriseId,
'enterprisePermissions' => $enterprisePermissions,
'availableVersions' => $availableVersions,
'tabBar' => $tabBar,
'aiQuickQuestions' => $aiQuickQuestions,
// 与 /api/config/deep-pricing 同源;提审模式下不下发类目(避免虚拟商品购买页)
'deepPricingPersonal' => ['categories' => $miniprogramAuditMode ? [] : $this->normalizeDeepPricingCategories('personal')],
'deepPricingEnterprise' => ['categories' => $miniprogramAuditMode ? [] : $this->normalizeDeepPricingCategories('enterprise')],
// 与 /api/config/deep-pricing 同源:始终返回类目,由前端决定审核态展示与交互
'deepPricingPersonal' => ['categories' => $this->normalizeDeepPricingCategories('personal')],
'deepPricingEnterprise' => ['categories' => $this->normalizeDeepPricingCategories('enterprise')],
]);
}
@@ -277,9 +302,6 @@ class AppConfig extends BaseController
public function deepPricing()
{
$scope = (string) ($this->request->param('scope', 'personal') ?? 'personal');
if (miniprogram_audit_mode_on()) {
return success(['scope' => $scope, 'categories' => []]);
}
$categories = $this->normalizeDeepPricingCategories($scope);
return success(['scope' => $scope, 'categories' => $categories]);

View File

@@ -19,25 +19,12 @@ class Gaokao extends BaseController
return (int) ($user['user_id'] ?? $user['userId'] ?? 0);
}
/** 与小程序审核/面相审核隐藏高考入口一致 */
private function rejectGaokaoIfAuditMode()
{
if (audit_mode_blocks_gaokao_api()) {
return error('版本审核中暂不可用', 503);
}
return null;
}
public function taskStatus()
{
$uid = $this->wechatUserId();
if ($uid <= 0) {
return error('未登录', 401);
}
if ($r = $this->rejectGaokaoIfAuditMode()) {
return $r;
}
$entry = [
'referrerId' => (int) Request::param('referrerId', 0),
'channelCode' => (string) Request::param('channelCode', ''),
@@ -60,9 +47,6 @@ class Gaokao extends BaseController
if ($uid <= 0) {
return error('未登录', 401);
}
if ($r = $this->rejectGaokaoIfAuditMode()) {
return $r;
}
$form = Request::post();
if (!is_array($form)) {
$form = [];
@@ -77,9 +61,6 @@ class Gaokao extends BaseController
if ($uid <= 0) {
return error('未登录', 401);
}
if ($r = $this->rejectGaokaoIfAuditMode()) {
return $r;
}
$profile = GaokaoService::getOrInitProfile($uid);
return success([
'form' => GaokaoService::formJsonAsArray($profile),
@@ -93,9 +74,6 @@ class Gaokao extends BaseController
if ($uid <= 0) {
return error('未登录', 401);
}
if ($r = $this->rejectGaokaoIfAuditMode()) {
return $r;
}
$pricingScope = trim((string) Request::param('pricingScope', 'personal'));
$eidParam = (int) Request::param('enterpriseId', 0);
$res = GaokaoService::createAnalysis(
@@ -115,9 +93,6 @@ class Gaokao extends BaseController
if ($uid <= 0) {
return error('未登录', 401);
}
if ($r = $this->rejectGaokaoIfAuditMode()) {
return $r;
}
$row = GaokaoService::myLatestReport($uid);
if (!$row) {
return error('暂无报告', 404);
@@ -131,9 +106,6 @@ class Gaokao extends BaseController
if ($uid <= 0) {
return error('未登录', 401);
}
if ($r = $this->rejectGaokaoIfAuditMode()) {
return $r;
}
$productCode = trim((string) Request::param('productCode', 'gaokao_single_report'));
$pricingScope = trim((string) Request::param('pricingScope', 'personal'));
$eidParam = (int) Request::param('enterpriseId', 0);

View File

@@ -137,7 +137,7 @@ class Payment extends BaseController
}
}
// 计算订单金额与定价类型personal/enterprise
// 计算订单金额与定价类型personal/enterprise/gaokao
if ($fixedAmountFen !== null) {
$pricingType = $enterpriseId ? 'enterprise' : 'personal';
$amountFenCalculated = $fixedAmountFen;
@@ -148,7 +148,8 @@ class Payment extends BaseController
$amountFen,
$user,
$enterpriseId,
$deepProductId
$deepProductId,
trim((string) Request::param('pricingScope', ''))
);
}
@@ -843,7 +844,8 @@ class Payment extends BaseController
int $requestAmountFen,
?array $user,
?int $enterpriseId,
string $deepProductId = ''
string $deepProductId = '',
string $requestPricingScope = ''
): array {
$pricingType = 'personal';
$pricingEnterpriseId = null; // 定价用企业 ID个人测试但有归属企业时也传入
@@ -872,7 +874,7 @@ class Payment extends BaseController
}
// 高考:与 GaokaoService 一致,按子测评是否带 enterpriseId 区分个人档/企业档售价,避免仅因订单带 eid 误用企业版价
if ($productType === 'gaokao' && $userId > 0) {
$ps = trim((string) Request::param('pricingScope', 'personal'));
$ps = $requestPricingScope !== '' ? $requestPricingScope : trim((string) Request::param('pricingScope', 'personal'));
$pe = (int) Request::param('enterpriseId', 0);
return GaokaoService::gaokaoSaleAmountForPaymentRecalc(
@@ -882,6 +884,17 @@ class Payment extends BaseController
);
}
// 高考 Tab 下 MBTI/SBTI 等:与 GET /api/config/runtime?scope=gaokao 同一套 type=gaokao 定价
if ($requestPricingScope === 'gaokao') {
return TestProductPricing::amountFenForTestProduct(
$productType,
$userId,
$pricingEnterpriseId ?? $enterpriseId,
$quantity,
'gaokao'
);
}
return TestProductPricing::amountFenForTestProduct(
$productType,
$userId,

View File

@@ -5,6 +5,7 @@ use app\BaseController;
use app\common\PdpDiscResultText;
use app\common\service\EnterpriseBillingService;
use app\common\service\GaokaoService;
use app\common\service\TestProductPricing;
use app\model\Enterprise as EnterpriseModel;
use app\model\PricingConfig as PricingConfigModel;
use app\model\Question as QuestionModel;
@@ -399,9 +400,7 @@ class Test extends BaseController
if ($boundEnterpriseId > 0) {
$allowed[] = 'resume';
}
// 提审 / 面相审核:与小程序隐藏高考入口一致,历史接口不再返回 gaokao
$hideGaokao = miniprogram_audit_mode_on() || $reviewMode;
if (!$hideGaokao && !in_array('gaokao', $allowed, true)) {
if (!in_array('gaokao', $allowed, true)) {
$allowed[] = 'gaokao';
}
@@ -618,10 +617,6 @@ class Test extends BaseController
return error('记录不存在', 404);
}
if (($row['testType'] ?? '') === 'gaokao' && audit_mode_blocks_gaokao_api()) {
return error('版本审核中暂不可用', 503);
}
if (($row['testType'] ?? '') === 'gaokao' && (int) ($row['isPaid'] ?? 0) === 0) {
$ps = trim((string) Request::param('pricingScope', 'personal'));
$eid = (int) Request::param('enterpriseId', 0);
@@ -640,7 +635,8 @@ class Test extends BaseController
}
}
$out = $this->buildTestDetailPayload($row);
$scopeParam = trim((string) Request::param('scope', ''));
$out = $this->buildTestDetailPayload($row, false, $scopeParam !== '' ? $scopeParam : null);
return success(array_merge($out, [
'shareToken' => $this->computeShareToken($row),
@@ -668,10 +664,6 @@ class Test extends BaseController
}
$rowTestType = (string) ($row['testType'] ?? '');
if ($rowTestType === 'gaokao' && audit_mode_blocks_gaokao_api()) {
return error('版本审核中暂不可用', 503);
}
if ($rowTestType === 'resume') {
return error('该类型不支持分享查看', 403);
}
@@ -690,7 +682,8 @@ class Test extends BaseController
}
// 分享落地:访客不应因「答题人资料未完善」看到截断结果,也不应引导访客去完善自己的资料
$out = $this->buildTestDetailPayload($row, true);
$scopeParam = trim((string) Request::param('scope', ''));
$out = $this->buildTestDetailPayload($row, true, $scopeParam !== '' ? $scopeParam : null);
return success(array_merge($out, [
'shareToken' => $this->computeShareToken($row),
@@ -718,12 +711,58 @@ class Test extends BaseController
return false;
}
/**
* 详情/分享接口展示的应付金额:库内 paidAmount 可能在高考 Tab 上线前按企业档写入,故支持 testScope=gaokao 或与 runtime 一致的 ?scope=gaokao 重算。
*
* @return array{0:int,1:int,2:bool} [requiresPayment, paidAmountFen, needPaymentToUnlock]
*/
protected function resolveDisplayPaymentFields(array $row, ?string $scopeOverride = null): array
{
$testType = (string) ($row['testType'] ?? '');
$subjectUserId = (int) ($row['userId'] ?? 0);
$isPaid = (int) ($row['isPaid'] ?? 0);
$requiresPaymentRow = (int) ($row['requiresPayment'] ?? 0);
$paidAmountRow = isset($row['paidAmount']) ? (int) $row['paidAmount'] : 0;
$testScopeRow = strtolower(trim((string) ($row['testScope'] ?? 'personal')));
$effectiveScope = ($scopeOverride !== null && trim($scopeOverride) !== '')
? strtolower(trim($scopeOverride))
: $testScopeRow;
$gaokaoTypes = ['mbti', 'sbti', 'disc', 'pdp', 'face', 'ai'];
$productKey = $testType === 'ai' ? 'face' : $testType;
if ($effectiveScope === 'gaokao' && in_array($testType, $gaokaoTypes, true)) {
$eid = isset($row['enterpriseId']) ? (int) $row['enterpriseId'] : 0;
$pricingEid = $eid > 0 ? $eid : null;
[$fen] = TestProductPricing::amountFenForTestProduct(
$productKey,
$subjectUserId,
$pricingEid,
1,
'gaokao'
);
$requiresPayment = $fen > 0 ? 1 : 0;
$paidAmount = $fen;
$needPaymentToUnlock = $requiresPayment && !$isPaid && $paidAmount > 0;
return [$requiresPayment, $paidAmount, $needPaymentToUnlock];
}
$requiresPayment = $requiresPaymentRow;
$paidAmount = $paidAmountRow;
$needPaymentToUnlock = $requiresPayment && !$isPaid && $paidAmount > 0;
return [$requiresPayment, $paidAmount, $needPaymentToUnlock];
}
/**
* 与 detail 接口一致的详情结构data 为结果 JSON
*
* @param bool $forShareViewer true=分享链接打开(/api/test/share-detail不因答题人资料未完善而截断结果未付费仍按定价截断
* @param string|null $scopeOverride 可选 gaokao与 GET ?scope=gaokao 一致,用于旧记录在库 testScope 仍为 personal 时按高考档展示应付金额
*/
protected function buildTestDetailPayload(array $row, bool $forShareViewer = false): array
protected function buildTestDetailPayload(array $row, bool $forShareViewer = false, ?string $scopeOverride = null): array
{
$raw = $row['resultData'] ?? ($row['result'] ?? null);
$data = null;
@@ -731,11 +770,9 @@ class Test extends BaseController
$decoded = json_decode($raw, true);
$data = is_array($decoded) ? $decoded : $raw;
}
$requiresPayment = (int) ($row['requiresPayment'] ?? 0);
[$requiresPayment, $paidAmount, $needPaymentToUnlock] = $this->resolveDisplayPaymentFields($row, $scopeOverride);
$isPaid = (int) ($row['isPaid'] ?? 0);
$paidAmount = isset($row['paidAmount']) ? (int) $row['paidAmount'] : 0;
$testType = $row['testType'] ?? '';
$needPaymentToUnlock = $requiresPayment && !$isPaid && $paidAmount > 0;
if (miniprogram_audit_mode_on()) {
$needPaymentToUnlock = false;
}
@@ -817,6 +854,7 @@ class Test extends BaseController
$result = $input['result'] ?? null;
$answers = $input['answers'] ?? [];
$duration = isset($input['testDuration']) ? (int) $input['testDuration'] : 0;
$pricingScopeIn = strtolower(trim((string) ($input['pricingScope'] ?? '')));
// 企业分享链接会传 enterpriseId个人分享不传稍后从 wechat_users 回落
$enterpriseId = isset($input['enterpriseId']) ? (int) $input['enterpriseId'] : null;
if ($enterpriseId !== null && $enterpriseId <= 0) {
@@ -859,8 +897,21 @@ class Test extends BaseController
$writeEnterpriseId = (int) $boundEid; // 历史记录展示企业名
}
}
$requiresPayment = $this->getRequiresPaymentByTestType($testType, $enterpriseId, $pricingEnterpriseId);
$standardAmountFen = $requiresPayment ? $this->getStandardAmountFenByTestType($testType, $enterpriseId, $pricingEnterpriseId) : 0;
if ($pricingScopeIn === 'gaokao') {
$testScopeLabel = 'gaokao';
[$standardAmountFen] = TestProductPricing::amountFenForTestProduct(
$testType,
$userId,
$pricingEnterpriseId,
1,
'gaokao'
);
$requiresPayment = $standardAmountFen > 0 ? 1 : 0;
} else {
$testScopeLabel = $enterpriseId !== null ? 'enterprise' : 'personal';
$requiresPayment = $this->getRequiresPaymentByTestType($testType, $enterpriseId, $pricingEnterpriseId);
$standardAmountFen = $requiresPayment ? $this->getStandardAmountFenByTestType($testType, $enterpriseId, $pricingEnterpriseId) : 0;
}
if (miniprogram_audit_mode_on()) {
$requiresPayment = 0;
$standardAmountFen = 0;
@@ -868,7 +919,7 @@ class Test extends BaseController
$id = Db::name('test_results')->insertGetId([
'userId' => $userId,
'enterpriseId' => $writeEnterpriseId,
'testScope' => $enterpriseId !== null ? 'enterprise' : 'personal',
'testScope' => $testScopeLabel,
'testType' => $testType,
'resultData' => is_string($result) ? $result : json_encode($result, JSON_UNESCAPED_UNICODE),
'score' => null,

View File

@@ -133,13 +133,18 @@ class Analytics extends BaseController
}
$days = min(180, max(1, (int) Request::param('days', 30)));
$since = date('Y-m-d H:i:s', time() - $days * 86400);
$page = max(1, (int) Request::param('page', 1));
$pageSize = min(100, max(1, (int) Request::param('pageSize', 20)));
$offset = ($page - 1) * $pageSize;
try {
$rows = Db::name('analytics_events')
$baseQ = Db::name('analytics_events')
->where('userId', $userId)
->where('createdAt', '>=', $since)
->where('createdAt', '>=', $since);
$total = (int) (clone $baseQ)->count();
$rows = (clone $baseQ)
->order('id', 'desc')
->limit(200)
->limit($offset, $pageSize)
->select()
->toArray();
@@ -156,10 +161,12 @@ class Analytics extends BaseController
$rows = AnalyticsEventLabels::withCn($rows);
return success([
'userId' => $userId,
'days' => $days,
'list' => $rows,
'total' => count($rows),
'userId' => $userId,
'days' => $days,
'list' => $rows,
'total' => $total,
'page' => $page,
'pageSize' => $pageSize,
]);
} catch (\Throwable $e) {
return success([
@@ -167,6 +174,8 @@ class Analytics extends BaseController
'days' => $days,
'list' => [],
'total' => 0,
'page' => $page,
'pageSize' => $pageSize,
'tableMissing' => true,
]);
}

View File

@@ -348,6 +348,63 @@ class DataMigration extends BaseController
Db::name('user_profile')->insert($base);
}
/**
* 归属目标企业且仍存在 personal(NULL) 画像行的用户,逐批合并(避免一次载入全部 userId / 超长事务)。
*
* @return int 实际调用合并逻辑的用户数
*/
private function mergePersonalProfilesForEnterpriseInBatches(int $targetEnterpriseId, int $now, int $batchSize = 400): int
{
if ($targetEnterpriseId <= 0) {
return 0;
}
$tUp = Db::name('user_profile')->getTable();
$tWu = Db::name('wechat_users')->getTable();
$batchSize = max(50, min($batchSize, 2000));
$lastUid = 0;
$merged = 0;
while (true) {
$sql = 'SELECT DISTINCT `up`.`userId` AS `uid` FROM `' . $tUp . '` `up` '
. 'INNER JOIN `' . $tWu . '` `w` ON `w`.`id` = `up`.`userId` '
. 'WHERE `up`.`userType` = \'personal\' '
. 'AND (`up`.`enterpriseId` IS NULL OR `up`.`enterpriseId` = 0) '
. 'AND `w`.`enterpriseId` = ? '
. 'AND `up`.`userId` > ? '
. 'ORDER BY `up`.`userId` ASC '
. 'LIMIT ' . $batchSize;
$rows = Db::query($sql, [$targetEnterpriseId, $lastUid]);
if ($rows === null || $rows === [] || !is_array($rows)) {
break;
}
$uids = [];
foreach ($rows as $row) {
$uid = (int) ($row['uid'] ?? 0);
if ($uid > 0) {
$uids[] = $uid;
}
}
if ($uids === []) {
break;
}
foreach ($uids as $uid) {
$this->mergePersonalUserProfileIntoEnterprise($uid, $targetEnterpriseId, $now);
$merged++;
}
$lastUid = max($uids);
if (count($rows) < $batchSize) {
break;
}
}
return $merged;
}
/**
* 存客宝归并wechat_users 无企业用户写入目标企业;
* 已/将归属该企业的用户下test_results 中 enterpriseId 为空的记录补写目标企业;
@@ -386,27 +443,25 @@ class DataMigration extends BaseController
return error('目标企业不存在', 404);
}
$orphanUserIds = Db::name('wechat_users')
$eid = $targetEnterpriseId;
$wechatAffected = (int) Db::name('wechat_users')
->whereRaw($this->sqlWechatUserUnassignedEnterprise())
->column('id');
$orphanUserIds = array_values(array_unique(array_filter(array_map('intval', $orphanUserIds))));
$wechatAffected = count($orphanUserIds);
$boundNow = Db::name('wechat_users')
->where('enterpriseId', $targetEnterpriseId)
->column('id');
$boundNow = array_values(array_unique(array_filter(array_map('intval', $boundNow))));
$usersInScope = array_values(array_unique(array_merge($boundNow, $orphanUserIds)));
$testResultsAffected = empty($usersInScope) ? 0 : (int) Db::name('test_results')
->whereIn('userId', $usersInScope)
->whereRaw($this->sqlTestResultUnassignedEnterprise())
->count();
$userProfilePersonalRows = empty($usersInScope) ? 0 : (int) Db::name('user_profile')
->where('userType', 'personal')
->whereRaw('(`enterpriseId` IS NULL OR `enterpriseId` = 0)')
->whereIn('userId', $usersInScope)
$wechatScopeExpr = '((w.`enterpriseId` IS NULL OR w.`enterpriseId` = 0) OR w.`enterpriseId` = ' . $eid . ')';
$testResultsAffected = (int) Db::name('test_results')->alias('tr')
->join('wechat_users w', 'w.id = tr.userId')
->whereRaw('(tr.`enterpriseId` IS NULL OR tr.`enterpriseId` = 0)')
->whereRaw($wechatScopeExpr)
->count();
$userProfilePersonalRows = (int) Db::name('user_profile')->alias('up')
->join('wechat_users w', 'w.id = up.userId')
->where('up.userType', 'personal')
->whereRaw('(up.`enterpriseId` IS NULL OR up.`enterpriseId` = 0)')
->whereRaw($wechatScopeExpr)
->count();
$preview = [
@@ -434,47 +489,52 @@ class DataMigration extends BaseController
return success($preview, '无需归并');
}
if (function_exists('set_time_limit')) {
@set_time_limit(0);
}
@ignore_user_abort(true);
$now = time();
Db::startTrans();
try {
if (!empty($orphanUserIds)) {
Db::name('wechat_users')
->whereIn('id', $orphanUserIds)
->whereRaw($this->sqlWechatUserUnassignedEnterprise())
->update([
'enterpriseId' => $targetEnterpriseId,
'updatedAt' => $now,
]);
}
Db::name('wechat_users')
->whereRaw($this->sqlWechatUserUnassignedEnterprise())
->update([
'enterpriseId' => $targetEnterpriseId,
'updatedAt' => $now,
]);
$boundAfter = Db::name('wechat_users')
->where('enterpriseId', $targetEnterpriseId)
->column('id');
$boundAfter = array_values(array_unique(array_filter(array_map('intval', $boundAfter))));
if (!empty($boundAfter)) {
Db::name('test_results')
->whereIn('userId', $boundAfter)
->whereRaw($this->sqlTestResultUnassignedEnterprise())
->update([
'enterpriseId' => $targetEnterpriseId,
'testScope' => 'enterprise',
'updatedAt' => $now,
]);
foreach ($boundAfter as $uid) {
$this->mergePersonalUserProfileIntoEnterprise((int) $uid, $targetEnterpriseId, $now);
}
}
$trTable = Db::name('test_results')->getTable();
$wuTable = Db::name('wechat_users')->getTable();
Db::execute(
'UPDATE `' . $trTable . '` AS `tr` INNER JOIN `' . $wuTable . '` AS `w` ON `tr`.`userId` = `w`.`id` '
. 'SET `tr`.`enterpriseId` = ?, `tr`.`testScope` = \'enterprise\', `tr`.`updatedAt` = ? '
. 'WHERE `w`.`enterpriseId` = ? AND (`tr`.`enterpriseId` IS NULL OR `tr`.`enterpriseId` = 0)',
[$targetEnterpriseId, $now, $targetEnterpriseId]
);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return error('归并失败:' . $e->getMessage(), 500);
return error('归并失败(用户/测试写入):' . $e->getMessage(), 500);
}
$preview['executed'] = true;
$preview['hint'] = '已写入:小程序用户归属、无企业测试记录、画像 personal 行合并已完成。';
try {
$mergedProfiles = $this->mergePersonalProfilesForEnterpriseInBatches($targetEnterpriseId, $now);
} catch (\Throwable $e) {
return error(
'用户与测试记录已写入,但画像合并失败(可重试本接口):' . $e->getMessage(),
500
);
}
$preview['executed'] = true;
$preview['profileMergeProcessed'] = $mergedProfiles;
$preview['hint'] = '已写入小程序用户归属、无企业测试记录已批量更新personal 画像已合并 '
. $mergedProfiles . ' 人次。';
return success($preview, '归并完成');
}
}

View File

@@ -392,6 +392,8 @@ class Enterprise extends BaseController
$enterprise->contactName = $data['contactName'] ?? null;
$enterprise->contactPhone = $data['contactPhone'] ?? null;
$enterprise->contactEmail = $data['contactEmail'] ?? null;
$enterprise->idCardNumber = $data['idCardNumber'] ?? null;
$enterprise->businessLicenseUrl = $data['businessLicenseUrl'] ?? null;
$enterprise->balance = $data['balance'] ?? 0.00;
$enterprise->status = $status;
$enterprise->trialExpireAt = ($status === 'trial' && isset($data['trialExpireAt'])) ? $data['trialExpireAt'] : null;

View File

@@ -0,0 +1,311 @@
<?php
namespace app\controller\superadmin;
use app\BaseController;
use app\model\SystemConfig as SystemConfigModel;
use think\facade\Db;
use think\facade\Request;
/**
* 超管「高考版管理」Hub概览、用户列表、功能配置与前端 GaokaoHub.vue 对齐)
*/
class Gaokao extends BaseController
{
private const CONFIG_KEY = 'gaokao_hub';
private function authOk(): bool
{
$user = $this->request->user ?? null;
return !!($user && ($user['role'] ?? '') === 'superadmin');
}
/**
* GET /api/v1/superadmin/gaokao/overview
*/
public function overview()
{
if (!$this->authOk()) {
return error('无权限访问', 403);
}
$todayStart = strtotime('today');
$totalUsers = (int) Db::name('gaokao_user_profile')->count();
$completedUsers = (int) Db::name('gaokao_user_profile')
->where(function ($q) {
$q->where('analyzeStatus', 1)->whereOr('latestReportId', '>', 0);
})
->count();
$todayNew = (int) Db::name('gaokao_user_profile')
->where('createdAt', '>=', $todayStart)
->count();
$reportsGenerated = (int) Db::name('test_results')
->where('testType', 'gaokao')
->count();
$avgRow = Db::name('gaokao_user_profile')
->where('estimatedScore', '>', 0)
->fieldRaw('AVG(`estimatedScore`) AS a')
->find();
$avgScore = (int) round((float) ($avgRow['a'] ?? 0));
$conversionRate = $totalUsers > 0
? (string) round($completedUsers / $totalUsers * 100, 1) . '%'
: '0%';
$dist = [
'700plus' => 0,
'650_700' => 0,
'600_650' => 0,
'550_600' => 0,
'500_550' => 0,
'below500' => 0,
];
$scores = Db::name('gaokao_user_profile')
->where('estimatedScore', '>', 0)
->column('estimatedScore');
foreach ($scores as $s) {
$n = (int) $s;
if ($n >= 700) {
$dist['700plus']++;
} elseif ($n >= 650) {
$dist['650_700']++;
} elseif ($n >= 600) {
$dist['600_650']++;
} elseif ($n >= 550) {
$dist['550_600']++;
} elseif ($n >= 500) {
$dist['500_550']++;
} else {
$dist['below500']++;
}
}
return success([
'totalUsers' => $totalUsers,
'completedUsers' => $completedUsers,
'todayNew' => $todayNew,
'reportsGenerated' => $reportsGenerated,
'conversionRate' => $conversionRate,
'avgScore' => $avgScore,
'scoreDistribution'=> $dist,
]);
}
/**
* GET /api/v1/superadmin/gaokao/users
* Query: q, status (completed|in_progress|abandoned), page, pageSize, tenantId可选
*/
public function users()
{
if (!$this->authOk()) {
return error('无权限访问', 403);
}
$page = max(1, (int) Request::param('page', 1));
$pageSize = min(100, max(1, (int) Request::param('pageSize', 20)));
$keyword = trim((string) Request::param('q', Request::param('keyword', '')));
$status = trim((string) Request::param('status', ''));
$tenantId = (int) Request::param('tenantId', 0);
$q = Db::name('gaokao_user_profile')->alias('g')
->join('wechat_users w', 'w.id = g.userId')
->leftJoin('enterprises e', 'e.id = g.tenantId');
if ($keyword !== '') {
$like = '%' . addcslashes($keyword, '%_\\') . '%';
$q->whereRaw('(w.nickname LIKE ? OR w.phone LIKE ? OR g.name LIKE ?)', [$like, $like, $like]);
}
if ($tenantId > 0) {
$q->where('g.tenantId', $tenantId);
}
if ($status === 'completed') {
$q->where(function ($w) {
$w->where('g.analyzeStatus', 1)->whereOr('g.latestReportId', '>', 0);
});
} elseif ($status === 'in_progress') {
$q->whereRaw('(g.`latestReportId` IS NULL OR g.`latestReportId` = 0) AND IFNULL(g.`analyzeStatus`,0) = 0')
->where(function ($w) {
$w->where('g.entryStatus', 1)
->whereOr('g.formStatus', '>', 0)
->whereOr('g.mbtiStatus', '>', 0)
->whereOr('g.pdpStatus', '>', 0)
->whereOr('g.discStatus', '>', 0);
});
} elseif ($status === 'abandoned') {
$q->where('g.analyzeStatus', 2);
}
$total = (int) (clone $q)->count();
$rows = (clone $q)
->field('g.*,w.nickname,w.phone,w.avatar,e.name as tenantName')
->order('g.id', 'desc')
->page($page, $pageSize)
->select()
->toArray();
$list = [];
foreach ($rows as $r) {
$list[] = $this->formatGaokaoUserRow($r);
}
return paginate_response($list, $total, $page, $pageSize);
}
/**
* @param array<string,mixed> $r
* @return array<string,mixed>
*/
private function formatGaokaoUserRow(array $r): array
{
$formJson = $r['formJson'] ?? null;
if (is_string($formJson)) {
$formJson = json_decode($formJson, true) ?: [];
}
if (!is_array($formJson)) {
$formJson = [];
}
$tagsJson = $r['tagsJson'] ?? null;
if (is_string($tagsJson)) {
$tagsJson = json_decode($tagsJson, true) ?: [];
}
if (!is_array($tagsJson)) {
$tagsJson = [];
}
$mbtiType = (string) ($tagsJson['mbtiType'] ?? $tagsJson['mbti'] ?? $formJson['mbtiType'] ?? '');
$majorDirection = (string) ($formJson['majorDirection'] ?? $formJson['majorFocus'] ?? $tagsJson['majorDirection'] ?? '');
$rid = (int) ($r['latestReportId'] ?? 0);
$an = (int) ($r['analyzeStatus'] ?? 0);
if ($rid > 0 || $an === 1) {
$st = 'completed';
} elseif ($an === 2) {
$st = 'abandoned';
} elseif ((int) ($r['entryStatus'] ?? 0) === 1
|| (int) ($r['formStatus'] ?? 0) > 0
|| (int) ($r['mbtiStatus'] ?? 0) > 0
|| (int) ($r['pdpStatus'] ?? 0) > 0
|| (int) ($r['discStatus'] ?? 0) > 0) {
$st = 'in_progress';
} else {
// 尚未进入流程或仅占位档案:前端按「进行中」展示,避免误判为放弃
$st = 'in_progress';
}
$lastAt = isset($r['lastAnalyzeAt']) ? (int) $r['lastAnalyzeAt'] : 0;
return [
'id' => (int) ($r['id'] ?? 0),
'userId' => (int) ($r['userId'] ?? 0),
'nickname' => (string) ($r['nickname'] ?? ''),
'phone' => (string) ($r['phone'] ?? ''),
'gaokaoScore' => isset($r['estimatedScore']) && $r['estimatedScore'] !== null ? (int) $r['estimatedScore'] : null,
'mbtiType' => $mbtiType !== '' ? $mbtiType : null,
'majorDirection' => $majorDirection !== '' ? $majorDirection : null,
'status' => $st,
'reportId' => $rid > 0 ? $rid : null,
'completedAt' => $lastAt > 0 ? date('Y-m-d H:i:s', $lastAt) : null,
'tenantName' => (string) ($r['tenantName'] ?? ''),
];
}
/**
* GET /api/v1/superadmin/gaokao/config
*/
public function getConfig()
{
if (!$this->authOk()) {
return error('无权限访问', 403);
}
return success($this->loadGaokaoHubConfig());
}
/**
* POST /api/v1/superadmin/gaokao/config
*/
public function saveConfig()
{
if (!$this->authOk()) {
return error('无权限访问', 403);
}
$body = Request::post();
if (!is_array($body)) {
$body = [];
}
$merged = array_merge($this->defaultGaokaoHubConfig(), $body);
$merged['reportPrice'] = max(0, (float) ($merged['reportPrice'] ?? 0));
$merged['freeQuota'] = max(0, (int) ($merged['freeQuota'] ?? 0));
$merged['maxMajors'] = max(3, min(20, (int) ($merged['maxMajors'] ?? 10)));
foreach (['gaokaoEnabled', 'faceEnabled', 'mbtiEnabled', 'aiRecommendEnabled', 'paidReportEnabled', 'privacyNoticeEnabled'] as $k) {
$merged[$k] = !empty($merged[$k]);
}
$row = SystemConfigModel::where('key', self::CONFIG_KEY)->where('enterprise_id', 0)->find();
$now = time();
if ($row) {
$row->value = $merged;
$row->updatedAt = $now;
$row->save();
} else {
SystemConfigModel::create([
'key' => self::CONFIG_KEY,
'enterprise_id' => 0,
'value' => $merged,
'description' => '超管高考版 Hub 开关与参数',
'createdAt' => $now,
'updatedAt' => $now,
]);
}
return success($merged, '保存成功');
}
/**
* @return array<string,mixed>
*/
private function defaultGaokaoHubConfig(): array
{
return [
'gaokaoEnabled' => true,
'faceEnabled' => true,
'mbtiEnabled' => true,
'aiRecommendEnabled' => true,
'paidReportEnabled' => false,
'privacyNoticeEnabled' => true,
'reportPrice' => 9.9,
'freeQuota' => 1,
'maxMajors' => 10,
];
}
/**
* @return array<string,mixed>
*/
private function loadGaokaoHubConfig(): array
{
$defaults = $this->defaultGaokaoHubConfig();
$row = SystemConfigModel::where('key', self::CONFIG_KEY)->where('enterprise_id', 0)->find();
if (!$row || $row->value === null || $row->value === '') {
return $defaults;
}
$v = $row->value;
if (!is_array($v)) {
return $defaults;
}
return array_merge($defaults, $v);
}
}

View File

@@ -1,260 +1,319 @@
<?php
namespace app\controller\superadmin;
use app\BaseController;
use app\model\PricingConfig as PricingConfigModel;
use think\facade\Request;
/**
* 全局定价管理控制器(超管专用)
*/
class Pricing extends BaseController
{
/**
* 获取定价配置
* @return \think\response\Json
*/
public function index()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$type = Request::param('type', ''); // personal/enterprise/deep
$enterpriseId = Request::param('enterpriseId', null); // 仅 type=enterprise 时有效,不传为全局
if ($type) {
$enterpriseId = $enterpriseId !== null && $enterpriseId !== '' ? (int) $enterpriseId : null;
$query = PricingConfigModel::where('type', $type);
if ($type === 'enterprise') {
$query->where(empty($enterpriseId) ? 'enterpriseId' : 'enterpriseId', empty($enterpriseId) ? 'null' : '=', empty($enterpriseId) ? null : $enterpriseId);
if (empty($enterpriseId)) {
$query->whereNull('enterpriseId');
} else {
$query->where('enterpriseId', $enterpriseId);
}
} else {
$query->whereNull('enterpriseId');
}
$config = $query->find();
if (!$config) {
return error('定价配置不存在', 404);
}
$cfg = $config->config;
if (is_array($cfg)) {
if ($type === 'personal') {
$cfg = self::normalizePersonalPricingConfig($cfg);
} elseif ($type === 'enterprise') {
$cfg = self::normalizeEnterprisePricingConfig($cfg);
}
}
return success([
'type' => $config->type,
'enterpriseId' => $config->enterpriseId,
'config' => $cfg
]);
} else {
// 获取所有:个人/深度各一条(全局),企业=全局默认定价 + 各企业专属列表
$configs = PricingConfigModel::select()->toArray();
$result = ['personal' => null, 'enterprise' => null, 'deep' => null, 'enterpriseList' => []];
foreach ($configs as $row) {
if ($row['enterpriseId'] === null || $row['enterpriseId'] === '') {
$result[$row['type']] = $row['config'];
} else {
if ($row['type'] === 'enterprise') {
$result['enterpriseList'][] = ['enterpriseId' => (int) $row['enterpriseId'], 'config' => $row['config']];
}
}
}
if (is_array($result['personal'])) {
$result['personal'] = self::normalizePersonalPricingConfig($result['personal']);
}
if (is_array($result['enterprise'])) {
$result['enterprise'] = self::normalizeEnterprisePricingConfig($result['enterprise']);
}
foreach ($result['enterpriseList'] as $i => $entRow) {
if (isset($entRow['config']) && is_array($entRow['config'])) {
$result['enterpriseList'][$i]['config'] = self::normalizeEnterprisePricingConfig($entRow['config']);
}
}
return success($result);
}
}
/**
* 更新定价配置
* @return \think\response\Json
*/
public function update()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
// PUT JSON body 需显式解析,直接用 param() 读深层嵌套数组可能丢失数据
$rawBody = (string) $this->request->getContent();
$jsonData = ($rawBody !== '') ? json_decode($rawBody, true) : null;
if (is_array($jsonData)) {
$type = (string) ($jsonData['type'] ?? '');
$enterpriseId = $jsonData['enterpriseId'] ?? null;
$config = $jsonData['config'] ?? [];
} else {
$type = (string) Request::param('type', '');
$enterpriseId = Request::param('enterpriseId', null);
$config = Request::param('config', []);
}
if (empty($type)) {
return error('定价类型不能为空', 400);
}
if (!in_array($type, ['personal', 'enterprise', 'deep', 'deep_personal', 'deep_enterprise'])) {
return error('定价类型无效', 400);
}
if (empty($config) || !is_array($config)) {
return error('配置数据不能为空', 400);
}
$enterpriseId = ($type === 'enterprise' && $enterpriseId !== null && $enterpriseId !== '') ? (int) $enterpriseId : null;
if ($type !== 'enterprise') {
$enterpriseId = null;
}
$query = PricingConfigModel::where('type', $type);
if ($type === 'enterprise') {
if ($enterpriseId !== null) {
$query->where('enterpriseId', $enterpriseId);
} else {
$query->whereNull('enterpriseId');
}
} else {
$query->whereNull('enterpriseId');
}
// deep_personal / deep_enterprise 仅全局一条,不按企业分
$pricingConfig = $query->find();
if (!$pricingConfig) {
$pricingConfig = PricingConfigModel::create([
'type' => $type,
'enterpriseId' => $enterpriseId,
'config' => $config
]);
} else {
$pricingConfig->config = $config;
$pricingConfig->save();
}
return success([
'type' => $pricingConfig->type,
'enterpriseId' => $pricingConfig->enterpriseId,
'config' => $pricingConfig->config
], '保存成功');
}
/**
* 批量更新定价配置
* @return \think\response\Json
*/
public function batchUpdate()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$rawBody = (string) $this->request->getContent();
$jsonData = ($rawBody !== '') ? json_decode($rawBody, true) : null;
$data = is_array($jsonData) ? ($jsonData['data'] ?? []) : Request::param('data', []);
if (empty($data) || !is_array($data)) {
return error('配置数据不能为空', 400);
}
$successCount = 0;
$errors = [];
foreach ($data as $type => $config) {
if (!in_array($type, ['personal', 'enterprise', 'deep', 'deep_personal', 'deep_enterprise'])) {
$errors[] = "类型 {$type} 无效";
continue;
}
if (empty($config) || !is_array($config)) {
$errors[] = "类型 {$type} 的配置数据无效";
continue;
}
try {
$pricingConfig = PricingConfigModel::where('type', $type)->whereNull('enterpriseId')->find();
if (!$pricingConfig) {
PricingConfigModel::create([
'type' => $type,
'config' => $config
]);
} else {
$pricingConfig->config = $config;
$pricingConfig->save();
}
$successCount++;
} catch (\Exception $e) {
$errors[] = "保存类型 {$type} 失败:" . $e->getMessage();
}
}
if (!empty($errors)) {
return error('部分配置保存失败:' . implode('', $errors), 400);
}
return success(null, "成功保存 {$successCount} 个配置");
}
/**
* 旧库 JSON 可能缺少高考等字段,合并默认值便于管理端展示与保存
*
* @param array<string,mixed> $cfg
* @return array<string,mixed>
*/
private static function normalizePersonalPricingConfig(array $cfg): array
{
$defaults = [
'face' => 0,
'mbti' => 0,
'disc' => 0,
'pdp' => 0,
'sbti' => 0,
'gaokao' => 0,
];
return array_merge($defaults, $cfg);
}
/**
* @param array<string,mixed> $cfg
* @return array<string,mixed>
*/
private static function normalizeEnterprisePricingConfig(array $cfg): array
{
$defaults = [
'face' => 0,
'mbti' => 0,
'pdp' => 0,
'disc' => 0,
'sbti' => 0,
'gaokao' => 0,
'minRecharge' => 0,
];
return array_merge($defaults, $cfg);
}
}
<?php
namespace app\controller\superadmin;
use app\BaseController;
use app\model\PricingConfig as PricingConfigModel;
use think\facade\Request;
/**
* 全局定价管理控制器(超管专用)
*/
class Pricing extends BaseController
{
/**
* 获取定价配置
* @return \think\response\Json
*/
public function index()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$type = Request::param('type', ''); // personal/enterprise/gaokao/deep
$enterpriseId = Request::param('enterpriseId', null); // 仅 type=enterprise 时有效,不传为全局
if ($type) {
$enterpriseId = $enterpriseId !== null && $enterpriseId !== '' ? (int) $enterpriseId : null;
$query = PricingConfigModel::where('type', $type);
if ($type === 'enterprise') {
$query->where(empty($enterpriseId) ? 'enterpriseId' : 'enterpriseId', empty($enterpriseId) ? 'null' : '=', empty($enterpriseId) ? null : $enterpriseId);
if (empty($enterpriseId)) {
$query->whereNull('enterpriseId');
} else {
$query->where('enterpriseId', $enterpriseId);
}
} else {
$query->whereNull('enterpriseId');
}
$config = $query->find();
if (!$config) {
return error('定价配置不存在', 404);
}
$cfg = $config->config;
if (is_array($cfg)) {
if ($type === 'personal') {
$cfg = self::normalizePersonalPricingConfig($cfg);
} elseif ($type === 'enterprise') {
$cfg = self::normalizeEnterprisePricingConfig($cfg);
} elseif ($type === 'gaokao') {
$cfg = self::normalizeGaokaoPricingConfig($cfg);
}
}
return success([
'type' => $config->type,
'enterpriseId' => $config->enterpriseId,
'config' => $cfg
]);
} else {
// 获取所有:个人/深度各一条(全局),企业=全局默认定价 + 各企业专属列表
$configs = PricingConfigModel::select()->toArray();
$result = ['personal' => null, 'enterprise' => null, 'gaokao' => null, 'deep' => null, 'enterpriseList' => []];
foreach ($configs as $row) {
if ($row['enterpriseId'] === null || $row['enterpriseId'] === '') {
$result[$row['type']] = $row['config'];
} else {
if ($row['type'] === 'enterprise') {
$result['enterpriseList'][] = ['enterpriseId' => (int) $row['enterpriseId'], 'config' => $row['config']];
}
}
}
if (is_array($result['personal'])) {
$result['personal'] = self::normalizePersonalPricingConfig($result['personal']);
}
if (is_array($result['enterprise'])) {
$result['enterprise'] = self::normalizeEnterprisePricingConfig($result['enterprise']);
}
if (is_array($result['gaokao'])) {
$result['gaokao'] = self::normalizeGaokaoPricingConfig($result['gaokao']);
}
foreach ($result['enterpriseList'] as $i => $entRow) {
if (isset($entRow['config']) && is_array($entRow['config'])) {
$result['enterpriseList'][$i]['config'] = self::normalizeEnterprisePricingConfig($entRow['config']);
}
}
return success($result);
}
}
/**
* 更新定价配置
* @return \think\response\Json
*/
public function update()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
// PUT JSON body 需显式解析,直接用 param() 读深层嵌套数组可能丢失数据
$rawBody = (string) $this->request->getContent();
$jsonData = ($rawBody !== '') ? json_decode($rawBody, true) : null;
if (is_array($jsonData)) {
$type = (string) ($jsonData['type'] ?? '');
$enterpriseId = $jsonData['enterpriseId'] ?? null;
$config = $jsonData['config'] ?? [];
} else {
$type = (string) Request::param('type', '');
$enterpriseId = Request::param('enterpriseId', null);
$config = Request::param('config', []);
}
if (empty($type)) {
return error('定价类型不能为空', 400);
}
if (!in_array($type, ['personal', 'enterprise', 'gaokao', 'deep', 'deep_personal', 'deep_enterprise'], true)) {
return error('定价类型无效', 400);
}
if (empty($config) || !is_array($config)) {
return error('配置数据不能为空', 400);
}
$enterpriseId = ($type === 'enterprise' && $enterpriseId !== null && $enterpriseId !== '') ? (int) $enterpriseId : null;
if ($type !== 'enterprise') {
$enterpriseId = null;
}
$config = self::sanitizeConfigByType($type, $config);
$query = PricingConfigModel::where('type', $type);
if ($type === 'enterprise') {
if ($enterpriseId !== null) {
$query->where('enterpriseId', $enterpriseId);
} else {
$query->whereNull('enterpriseId');
}
} else {
$query->whereNull('enterpriseId');
}
// deep_personal / deep_enterprise 仅全局一条,不按企业分
$pricingConfig = $query->find();
if (!$pricingConfig) {
$pricingConfig = PricingConfigModel::create([
'type' => $type,
'enterpriseId' => $enterpriseId,
'config' => $config
]);
} else {
$pricingConfig->config = $config;
$pricingConfig->save();
}
return success([
'type' => $pricingConfig->type,
'enterpriseId' => $pricingConfig->enterpriseId,
'config' => $pricingConfig->config
], '保存成功');
}
/**
* 批量更新定价配置
* @return \think\response\Json
*/
public function batchUpdate()
{
// 验证是否为超级管理员
$user = $this->request->user ?? null;
if (!$user || $user['role'] !== 'superadmin') {
return error('无权限访问', 403);
}
$rawBody = (string) $this->request->getContent();
$jsonData = ($rawBody !== '') ? json_decode($rawBody, true) : null;
$data = is_array($jsonData) ? ($jsonData['data'] ?? []) : Request::param('data', []);
if (empty($data) || !is_array($data)) {
return error('配置数据不能为空', 400);
}
$successCount = 0;
$errors = [];
foreach ($data as $type => $config) {
if (!in_array($type, ['personal', 'enterprise', 'gaokao', 'deep', 'deep_personal', 'deep_enterprise'], true)) {
$errors[] = "类型 {$type} 无效";
continue;
}
if (empty($config) || !is_array($config)) {
$errors[] = "类型 {$type} 的配置数据无效";
continue;
}
$config = self::sanitizeConfigByType($type, $config);
try {
$pricingConfig = PricingConfigModel::where('type', $type)->whereNull('enterpriseId')->find();
if (!$pricingConfig) {
PricingConfigModel::create([
'type' => $type,
'config' => $config
]);
} else {
$pricingConfig->config = $config;
$pricingConfig->save();
}
$successCount++;
} catch (\Exception $e) {
$errors[] = "保存类型 {$type} 失败:" . $e->getMessage();
}
}
if (!empty($errors)) {
return error('部分配置保存失败:' . implode('', $errors), 400);
}
return success(null, "成功保存 {$successCount} 个配置");
}
/**
* 旧库 JSON 可能缺少高考等字段,合并默认值便于管理端展示与保存
*
* @param array<string,mixed> $cfg
* @return array<string,mixed>
*/
private static function normalizePersonalPricingConfig(array $cfg): array
{
$defaults = [
'face' => 0,
'mbti' => 0,
'disc' => 0,
'pdp' => 0,
'sbti' => 0,
];
return array_merge($defaults, $cfg);
}
/**
* @param array<string,mixed> $cfg
* @return array<string,mixed>
*/
private static function normalizeEnterprisePricingConfig(array $cfg): array
{
$defaults = [
'face' => 0,
'mbti' => 0,
'pdp' => 0,
'disc' => 0,
'sbti' => 0,
'minRecharge' => 0,
];
return array_merge($defaults, $cfg);
}
/**
* @param array<string,mixed> $cfg
* @return array<string,mixed>
*/
private static function normalizeGaokaoPricingConfig(array $cfg): array
{
$defaults = [
'face' => 0,
'mbti' => 0,
'disc' => 0,
'pdp' => 0,
'sbti' => 0,
'gaokao' => 0,
];
return array_merge($defaults, $cfg);
}
/**
* 按档位过滤可写字段,防止跨档字段污染配置。
*
* @param array<string,mixed> $config
* @return array<string,mixed>
*/
private static function sanitizeConfigByType(string $type, array $config): array
{
if (in_array($type, ['deep', 'deep_personal', 'deep_enterprise'], true)) {
return $config;
}
$allowMap = [
'personal' => ['face', 'mbti', 'disc', 'pdp', 'sbti'],
'enterprise' => ['face', 'mbti', 'disc', 'pdp', 'sbti', 'minRecharge'],
'gaokao' => ['face', 'mbti', 'disc', 'pdp', 'sbti', 'gaokao'],
];
$allow = $allowMap[$type] ?? [];
$out = [];
foreach ($allow as $key) {
if (!array_key_exists($key, $config)) {
continue;
}
$val = $config[$key];
if (is_numeric($val)) {
$out[$key] = (float) $val;
} else {
$out[$key] = 0;
}
}
return $out;
}
}

View File

@@ -25,6 +25,8 @@ class Enterprise extends Model
'contactName' => 'string',
'contactPhone' => 'string',
'contactEmail' => 'string',
'idCardNumber' => 'string',
'businessLicenseUrl' => 'string',
'balance' => 'float',
'status' => 'string',
'permissions' => 'json',

View File

@@ -71,7 +71,7 @@ class PricingConfig extends Model
* 1. admin_enterprise + enterpriseId有 eid 时)
* 2. enterprise + null超管全局兜底
*
* @param string $type personal|enterprise|deep
* @param string $type personal|enterprise|gaokao|deep
* @param int|null $enterpriseId 有则优先读该企业专属配置
* @return \app\model\PricingConfig|null
*/
@@ -95,6 +95,13 @@ class PricingConfig extends Model
if ($type === 'deep') {
return self::where('type', 'deep')->whereNull('enterpriseId')->find();
}
if ($type === 'gaokao') {
if (!empty($enterpriseId)) {
$row = self::where('type', 'admin_gaokao')->where('enterpriseId', $enterpriseId)->find();
if ($row) return $row;
}
return self::where('type', 'gaokao')->whereNull('enterpriseId')->find();
}
return self::where('type', $type)->whereNull('enterpriseId')->find();
}
}

View File

@@ -0,0 +1,5 @@
-- 企业身份证号码与营业执照信息
ALTER TABLE `mbti_enterprises`
ADD COLUMN `idCardNumber` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '身份证号码',
ADD COLUMN `businessLicenseUrl` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '营业执照图片/文件URL';

View File

@@ -0,0 +1,153 @@
# 高考版全局定价开发清单
> 基于:`api/docs/高考版开关与全局定价收口说明.md`
> 目标:把需求稿转成可执行的研发任务清单(前端/后端/小程序/测试/发布)。
## 1. 开发目标
- 价格配置入口统一到 `超级管理后台 -> 订单和财务 -> 全局定价`
- 支持三档定价切换:`个人版` / `企业版` / `高考版`
- 权限与能力约束:
- 仅高考版有高考报告能力与价格字段 `gaokao`
- `minRecharge` 仅超管可配置。
- 高考版管理页与企业端不再提供价格编辑入口。
- 小程序支持 2/3 版本逻辑:
- 2 种可用版本:沿用当前默认规则(不弹框)。
- 3 种可用版本:先弹居中版本选择框。
## 2. 数据与接口改造
## 2.1 定价类型支持
- 后端 `pricing` 相关接口增加 `type=gaokao` 的读取与保存。
- 校验白名单更新为:`personal | enterprise | gaokao | deep | deep_personal | deep_enterprise`
## 2.2 字段口径
- `personal` 配置字段:`face, mbti, disc, pdp, sbti`
- `enterprise` 配置字段:`face, mbti, disc, pdp, sbti, minRecharge`
- `gaokao` 配置字段:`face, mbti, disc, pdp, sbti, gaokao`
约束:
- `personal/enterprise` 不得出现 `gaokao` 字段(保存时过滤或拒绝)。
- `enterprise``minRecharge` 仅超管接口可写。
## 2.3 小程序可用版本集合
- 配置接口返回可用版本集合(示例:`availableVersions: ['personal','enterprise','gaokao']`)。
- 小程序据此决定是否弹版本选择框。
## 3. 后端开发任务
## 3.1 控制器改造
- 文件:`api/app/controller/superadmin/Pricing.php`
- 任务:
- 扩展 `type` 校验支持 `gaokao`
-`gaokao` 增加默认值归一化逻辑。
- 保存时按 type 过滤字段,防止跨档字段串入。
- 强化 `minRecharge` 权限控制(仅超管角色可写)。
## 3.2 模型/服务层
- 文件:`api/app/model/PricingConfig.php`(如有类型校验或说明需同步)
- 任务:
- 确保 `type=gaokao` 可正常存取。
- 补充注释/文档,明确三档字段。
## 3.3 小程序配置接口
- 任务:
- 在现有配置返回中加入 `availableVersions`(企业维度)。
- 若企业未配置,按默认规则回落(保持兼容)。
## 4. 超管前端开发任务
## 4.1 全局定价页(三档切换)
- 目标页面:`superadmin/commerce` 下的定价模块(当前对应 `superadmin/Pricing.vue` 所在链路)。
- 任务:
- 定价范围切换增加 `高考版`
- 按档位渲染字段:
- 个人版:无 `gaokao`、无 `minRecharge`
- 企业版:有 `minRecharge`、无 `gaokao`
- 高考版:有 `gaokao`、无 `minRecharge`
- 保存时提交对应 `type`
- 切换时拉取对应 `type` 并回显。
## 4.2 高考版管理页
- 文件:`admin/src/views/superadmin/GaokaoHub.vue`
- 任务:
- 移除价格编辑能力。
- 保留说明文案与“去全局定价配置”跳转按钮(跳转到 `superadmin/commerce?tab=pricing`)。
## 5. 企业端前端开发任务
- 企业端页面去除全局价格编辑能力(如当前存在)。
- 可选:只读展示当前生效价格;若展示,需显式标注“由超管统一配置”。
- 关键约束:企业端不得提交 `minRecharge` 与全局定价写接口。
## 6. 小程序开发任务
## 6.1 版本选择逻辑
- 读取 `availableVersions`
- 数量=2沿用现有默认直达逻辑。
- 数量=3弹居中版本选择框后再进入流程。
## 6.2 版本选择弹框
- UI蒙层 + 上下左右居中 modal。
- 文案:
- 标题:`请选择使用版本`
- 副标题:`你可在个人版、企业版、高考版之间切换`
- 按钮:`个人版` / `企业版` / `高考版`
- 次按钮:`取消`
- 交互:
- 选择后写入当前会话版本(可选持久化最近选择)。
- 取消后按产品策略处理(返回上页或保持当前页)。
## 6.3 权限限制
- 个人版/企业版:不显示高考入口,不允许触发高考报告购买链路。
- 高考版:允许高考报告链路,价格按 `type=gaokao` 命中。
## 7. 测试清单
## 7.1 后端接口
- `GET/PUT /api/v1/superadmin/pricing`
- `type=gaokao` 读写正常。
- `type=enterprise` 不接受 `gaokao` 字段。
- 非超管角色无法写 `minRecharge`
## 7.2 超管后台
- 全局定价三档切换回显正确,保存互不覆盖。
- 高考版页无价格编辑,仅有跳转入口。
## 7.3 企业端
- 无价格编辑入口。
- 不能调用全局定价写接口(前端无入口 + 后端权限兜底)。
## 7.4 小程序
- 2 版本:不弹框,直达正常。
- 3 版本:必弹框,三按钮跳转正常。
- 个人/企业版本无高考能力;高考版能力正常。
## 8. 发布与回滚
- 发布顺序建议:后端接口 -> 超管前端 -> 小程序。
- 回滚策略:
- 前端可独立回滚到旧版本(后端保持兼容字段过滤)。
-`gaokao` 类型异常,临时回退为只读并隐藏高考版切换入口。
## 9. 交付物
- 代码改动(后端 + admin + miniprogram
- 接口联调记录。
- 测试报告(含 2/3 版本场景截图)。
- 更新后的产品文档与运维说明。

View File

@@ -0,0 +1,186 @@
# 高考版开关与全局定价收口说明
## 1. 目标
价格配置统一收口到超级管理后台 `订单和财务 -> 全局定价`,并在该页面支持三档切换:
- 个人版
- 企业版
- 高考版
三档价格独立维护、互不覆盖;高考版管理页与企业端页面不再作为价格主入口。
## 2. 主操作入口
1. 登录超级管理员后台。
2. 进入 `订单和财务` 页面(路由:`/superadmin/commerce`)。
3. 点击 `全局定价` 页签。
4. 在全局定价页进行“个人版/企业版/高考版”切换并保存。
## 3. 页面交互要求(全局定价页)
### 3.1 切换控件
- 定价范围使用单选切换按钮(与现有风格一致)。
- 选项固定为:`个人版``企业版``高考版`
- 切换时立即加载对应档位价格数据,不自动保存当前改动。
### 3.2 价格字段
个人版与企业版展示以下字段(元/次):
- MBTI
- DISC
- PDP
- 人脸
- SBTI
高考版展示以下字段(元/次):
- MBTI
- DISC
- PDP
- 人脸
- SBTI
- 高考报告(仅高考版可配置)
仅企业版额外展示:
- 最低充值金额(元)
- 最低充值金额仅允许超管在全局定价中设置
### 3.3 保存行为
- 保存按钮统一为 `保存定价`
- 仅保存当前档位数据,不影响其他档位。
- 保存成功后给出成功提示,失败时提示错误原因。
### 3.4 其他页面行为约束
- 高考版管理页:仅保留“价格说明”或“去全局定价配置”按钮,不提供价格编辑控件。
- 企业端页面:不提供全局价格编辑能力,仅展示生效价格(可选)或说明文案。
- 任意价格修改都必须在 `订单和财务 -> 全局定价` 完成。
- 权限约束:仅高考版具备“高考报告”能力,个人版与企业版无高考权限。
- 权限约束:`minRecharge` 仅超管可改,企业端及其他角色不可改。
### 3.5 小程序版本切换规则(新增)
- 小程序侧需支持 3 种版本能力:`个人版``企业版``高考版`
- 当某企业仅配置了 2 种版本时,继续沿用当前默认规则(不弹选择框,按现有逻辑直达)。
- 当某企业配置了 3 种版本时,用户首次进入相关入口先弹出“版本选择框”,由用户选择本次使用版本。
- 弹框样式要求:上下左右居中(蒙层 + 居中 modal优先展示三个版本按钮支持取消/关闭。
- 用户选定后进入对应流程;可按产品策略决定是否记忆最近一次选择(默认建议记忆)。
#### 小程序弹框文案建议
- 标题:`请选择使用版本`
- 副标题:`你可在个人版、企业版、高考版之间切换`
- 按钮:`个人版` / `企业版` / `高考版`
- 次按钮:`取消`
## 4. 数据口径与存储约定(最终)
### 4.1 三档映射
- 个人版:`pricing(type=personal)`
- 企业版:`pricing(type=enterprise, enterpriseId=NULL)`(企业默认全局定价)
- 高考版:`pricing(type=gaokao)`
不采用 `type=enterprise + scope=gaokao` 的混合方案,避免统计与维护复杂度上升。
### 4.2 字段默认值
未配置时按 0 回填,避免前端出现 `null`
- 个人版/企业版:`face`, `mbti`, `disc`, `pdp`, `sbti`
- 高考版:`face`, `mbti`, `disc`, `pdp`, `sbti`, `gaokao`
- `minRecharge`(仅企业版,且仅超管可配置)
## 5. API 需求(待开发)
### 5.1 已有接口(继续使用)
- 开关配置:
- `GET /api/v1/superadmin/gaokao/config`
- `POST /api/v1/superadmin/gaokao/config`
- 定价配置:
- `GET /api/v1/superadmin/pricing?type=...`
- `PUT /api/v1/superadmin/pricing`
### 5.2 新增能力(必须)
`pricing` 接口需要支持 `type=gaokao` 的读取与保存。
小程序配置接口需返回“当前企业可用版本集合”(用于判断是否弹版本选择框)。
### 5.3 页面与接口职责边界
- `superadmin/commerce`(全局定价页)负责调用 `pricing` 接口进行读写。
- `superadmin/gaokao`(高考版管理页)不直接写价格,可仅跳转到全局定价页。
- 企业端接口与页面不写全局定价,避免口径分叉。
### 5.4 请求体示例
企业版示例:
```json
{
"type": "enterprise",
"config": {
"face": 9.9,
"mbti": 19.9,
"disc": 19.9,
"pdp": 19.9,
"sbti": 19.9,
"minRecharge": 100
}
}
```
高考版示例:
```json
{
"type": "gaokao",
"config": {
"face": 9.9,
"mbti": 19.9,
"disc": 19.9,
"pdp": 19.9,
"sbti": 19.9,
"gaokao": 39.9
}
}
```
## 6. 验收标准
1. `订单和财务 -> 全局定价` 页面可切换 `个人版/企业版/高考版` 三个档位。
2. 每个档位都能独立读取并回显对应价格。
3. 修改并保存任一档位,不会影响其他两个档位。
4. 企业版可单独维护 `minRecharge`,高考版与个人版不显示该字段。
5. 刷新页面后,三档价格与保存前一致。
6. 高考版定价的存储与读取均走 `pricing(type=gaokao)`
7. 高考版管理页与企业端页面无价格编辑入口(仅说明/跳转)。
8. 小程序在“三种版本都可用”时,先显示居中版本选择弹框;仅两种时沿用当前默认直达规则。
9. 仅高考版可配置与使用“高考报告”;个人版与企业版无高考权限且不展示高考字段。
10. `minRecharge` 仅超管可配置;企业端与其他角色不可编辑该字段。
## 7. 测试建议
1.`订单和财务 -> 全局定价` 依次切到三档,各改一个不同价格后分别保存。
2. 回切确认数据未串档。
3. 重新登录后台再次核对三档回显。
4. 在小程序下单流程抽测:
- 个人版/企业版:无高考入口与高考报告购买能力。
- 高考版:高考报告价格命中正确。
5. 检查高考版管理页与企业端页面,确认无价格编辑入口。
6. 验证 `minRecharge` 权限:
- 超管可在全局定价页修改并保存。
- 企业端及其他角色看不到或不可编辑该字段。
7. 分别验证小程序企业场景:
- 可用版本=2不弹框按当前逻辑进入。
- 可用版本=3弹出居中版本选择框三按钮可正常跳转。
## 8. 备注
本文档是需求规格稿,先用于评审与排期;你确认后再按本文档开始开发。

View File

@@ -162,6 +162,9 @@ Route::group('api/v1/admin', function () {
// 仪表盘统计
Route::get('dashboard', 'admin.Dashboard/index');
// 埋点:单用户旅程(限当前企业 user_profile非超管全局
Route::get('analytics/user-journey', 'admin.Analytics/userJourney');
// 邀请二维码
Route::get('invite/qrcode', 'admin.Invite/qrcode');
@@ -171,6 +174,8 @@ Route::group('api/v1/admin', function () {
// 测试用户(小程序用户,只读列表与详情)
Route::get('test-records/:id', 'admin.AppUser/testRecord');
// 须在 :id 之前注册否则「stats」会被当成用户 id
Route::get('app-users/stats', 'admin.AppUser/stats');
Route::get('app-users/:id', 'admin.AppUser/detail');
Route::get('app-users', 'admin.AppUser/index');
Route::get('gaokao-users/:id', 'admin.GaokaoUser/detail');
@@ -335,6 +340,11 @@ Route::group('api/v1/superadmin', function () {
Route::get('test-records/:id', 'superadmin.AppUser/testRecord');
Route::get('app-users/:id', 'superadmin.AppUser/detail');
Route::get('app-users', 'superadmin.AppUser/index');
// 高考版管理 Hub与前端 GaokaoHub.vue 路径一致;须在 gaokao-users 之前注册)
Route::get('gaokao/overview', 'superadmin.Gaokao/overview');
Route::get('gaokao/users', 'superadmin.Gaokao/users');
Route::get('gaokao/config', 'superadmin.Gaokao/getConfig');
Route::post('gaokao/config', 'superadmin.Gaokao/saveConfig');
Route::get('gaokao-users/:id', 'superadmin.GaokaoUser/detail');
Route::get('gaokao-users', 'superadmin.GaokaoUser/index');

2
h5-vue/.env.development Normal file
View File

@@ -0,0 +1,2 @@
# 后端 API 根地址(与小程序合法域名一致)。开发时可用 Vite 代理 /api
VITE_API_BASE=

4
h5-vue/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
*.local
.DS_Store

40
h5-vue/README.md Normal file
View File

@@ -0,0 +1,40 @@
# MBTI 小程序 → Vue 3 H5迁移工程
## 启动
```bash
cd h5-vue
npm install
npm run dev
```
浏览器打开终端提示的地址(默认 `http://localhost:5174`)。
配置接口地址:复制 `.env.development``.env.local`,设置:
```env
VITE_API_BASE=https://你的API域名
```
若本地后端与 Vite 同源代理,可在 `vite.config.ts` 里配置 `server.proxy`,并令 `VITE_API_BASE` 为空,请求走相对路径 `/api`
## 迁移进度说明
| 维度 | 说明 |
|------|------|
| **路由** | `app.json`**37** 条页面路径已全部注册 |
| **Tab 三页** | 首页 / 拍摄 / 我的:布局与小程序主区块对齐(含底部自定义 Tab |
| **占位页** | 其余 **34** 条为占位页(标题 + 路由名 + 回首页),便于按页迁移 |
| **基建** | Vue Router、Pinia、`/api/config/runtime` 拉取、axios 封装 |
### 完成度百分比(客观口径)
- **路由覆盖率**37 / 37 = **100%**(每条路径均可访问)
- **界面 + 主要交互按小程序复刻**:约 **3 / 37 ≈ 8%**(仅 Tab 内三页;其中拍摄页为能力说明型占位)
- **若计入基建(工程可运行 + Tab 壳 + Runtime**:约 **15%18%**(主观加权,供排期参考)
> 要做到「每个页面 1:1 功能与界面」,需按模块继续把 `.wxml/.wxss/.js` 迁到 `.vue`,并替换 `wx.*`(支付、登录、相机、分享等)。
## 静态资源
首页头图已尝试从 `../miniprogram/images/mbti-team-image.png` 复制到 `public/images/`;若缺失可手动拷贝同名文件。

15
h5-vue/env.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}
interface ImportMetaEnv {
readonly VITE_API_BASE: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

12
h5-vue/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
<title>神仙团队性格测试</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2267
h5-vue/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
h5-vue/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "mbti-h5-vue",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.9",
"pinia": "^2.3.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"sass": "^1.83.4",
"typescript": "~5.7.2",
"vite": "^6.0.7",
"vue-tsc": "^2.2.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

13
h5-vue/src/App.vue Normal file
View File

@@ -0,0 +1,13 @@
<template>
<router-view />
</template>
<script setup lang="ts">
</script>
<style>
html, body, #app {
height: 100%;
margin: 0;
}
</style>

View File

@@ -0,0 +1,184 @@
<template>
<nav class="tab-bar" aria-label="主导航">
<div class="tab-bar-line" />
<div class="tab-bar-inner" :class="'tab-bar-count-' + items.length">
<template v-for="(it, index) in items" :key="it.path">
<button
v-if="it.highlight"
type="button"
class="tab-slot-middle"
:aria-current="selected === index ? 'page' : undefined"
@click="switchTab(it.path)"
>
<div class="middle-fab" :class="{ active: selected === index }">
<div class="center-circle">
<span class="center-svg center-svg--camera" />
</div>
<span class="tab-text tab-text-fab">{{ it.text }}</span>
</div>
</button>
<button
v-else
type="button"
class="tab-item"
:class="[
selected === index ? 'active' : '',
'tab-item--' + it.iconKey,
]"
:aria-current="selected === index ? 'page' : undefined"
@click="switchTab(it.path)"
>
<span class="tab-icon" :class="'tab-icon--' + it.iconKey + (selected === index ? ' tab-icon--active' : '')" />
<span class="tab-text">{{ it.text }}</span>
</button>
</template>
</div>
</nav>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
/** 与当前 app.json tabBar.list 一致3 项);神仙 AI 已移除 */
const TAB_ITEMS = [
{ path: '/pages/index/index', text: '首页', iconKey: 'home', highlight: false },
{ path: '/pages/index/camera', text: '拍摄', iconKey: 'camera', highlight: true },
{ path: '/pages/profile/index', text: '我的', iconKey: 'profile', highlight: false },
]
const route = useRoute()
const router = useRouter()
const items = computed(() => TAB_ITEMS)
const selected = computed(() => {
const path = route.path.replace(/\/$/, '') || '/pages/index/index'
const i = TAB_ITEMS.findIndex((t) => t.path.replace(/\/$/, '') === path)
return i >= 0 ? i : 0
})
watch(
() => route.fullPath,
() => {},
{ immediate: true }
)
function switchTab(path: string) {
if (route.path.replace(/\/$/, '') === path.replace(/\/$/, '')) return
router.push(path)
}
defineOptions({ name: 'CustomTabBar' })
</script>
<style scoped lang="scss">
@function rpx($n) {
@return calc(#{$n} * 100vw / 750);
}
.tab-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 10000;
padding-bottom: env(safe-area-inset-bottom, 0px);
background: #fff;
box-shadow: 0 -2rpx 16rpx rgba(0, 0, 0, 0.06);
}
.tab-bar-line {
height: 1px;
background: rgba(0, 0, 0, 0.06);
}
.tab-bar-inner {
display: flex;
align-items: flex-end;
justify-content: space-around;
min-height: rpx(112);
padding: 0 rpx(16) rpx(8);
max-width: 100%;
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: rpx(4);
padding: rpx(8) 0;
border: none;
background: transparent;
color: #999;
font-size: rpx(22);
}
.tab-item.active {
color: #7c3aed;
}
.tab-icon {
width: rpx(48);
height: rpx(48);
border-radius: rpx(12);
background: #e5e7eb;
}
.tab-item.active .tab-icon--home {
background: linear-gradient(135deg, #a78bfa, #7c3aed);
}
.tab-item.active .tab-icon--profile {
background: linear-gradient(135deg, #a78bfa, #7c3aed);
}
.tab-slot-middle {
flex: 1;
display: flex;
justify-content: center;
align-items: flex-end;
padding-bottom: rpx(4);
border: none;
background: transparent;
}
.middle-fab {
display: flex;
flex-direction: column;
align-items: center;
gap: rpx(4);
}
.center-circle {
width: rpx(112);
height: rpx(112);
margin-top: rpx(-36);
border-radius: 50%;
background: linear-gradient(145deg, #e9d5ff, #7c3aed);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 rpx(8) rpx(24) rgba(124, 58, 237, 0.35);
}
.center-svg--camera {
width: rpx(52);
height: rpx(52);
background: #fff;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='white' d='M9 4L7 6H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-3l-2-2H9zm3 13c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-2c1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3 1.34 3 3 3z'/%3E%3C/svg%3E")
center / contain no-repeat;
}
.tab-text-fab {
font-size: rpx(22);
color: #7c3aed;
font-weight: 600;
}
.tab-text {
font-size: rpx(22);
}
</style>

View File

@@ -0,0 +1,19 @@
<template>
<div class="tab-layout">
<router-view />
<CustomTabBar />
</div>
</template>
<script setup lang="ts">
import CustomTabBar from '@/components/CustomTabBar.vue'
defineOptions({ name: 'TabLayout' })
</script>
<style scoped lang="scss">
.tab-layout {
min-height: 100vh;
position: relative;
}
</style>

17
h5-vue/src/main.ts Normal file
View File

@@ -0,0 +1,17 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { useAppStore } from './stores/app'
import './styles/global.scss'
const app = createApp(App)
app.use(createPinia())
app.use(router)
const appStore = useAppStore()
appStore.initFromStorage()
// 与小程序一致:启动时拉 runtime不阻塞首屏
appStore.fetchRuntime().catch(() => {})
app.mount('#app')

112
h5-vue/src/router/index.ts Normal file
View File

@@ -0,0 +1,112 @@
import { createRouter, createWebHistory } from 'vue-router'
import TabLayout from '@/layouts/TabLayout.vue'
/** 与 miniprogram/app.json pages 数组顺序一致(用于统计迁移进度) */
export const MINI_PAGE_PATHS = [
'pages/index/index',
'pages/index/camera',
'pages/index/upload',
'pages/index/result',
'pages/test-select/index',
'pages/ai-test/index',
'pages/test/mbti',
'pages/test/sbti',
'pages/test/disc',
'pages/test/pdp',
'pages/result/mbti',
'pages/result/sbti',
'pages/result/disc',
'pages/result/pdp',
'pages/result/resume',
'pages/purchase/index',
'pages/recharge/index',
'pages/enterprise/index',
'pages/enterprise/resume-history',
'pages/profile/index',
'pages/user-profile/index',
'pages/history/index',
'pages/order/index',
'pages/phone-auth/index',
'pages/promo/index',
'pages/promo/poster',
'pages/promo/withdrawals',
'pages/match-job/index',
'pages/gaokao/index',
'pages/gaokao/form',
'pages/gaokao/report',
'pages/ai-chat/index',
'pages/ai-chat/report',
'pages/ai-chat/history',
'pages/webview/index',
] as const
export const TAB_PATHS = new Set([
'pages/index/index',
'pages/index/camera',
'pages/profile/index',
])
const IndexHome = () => import('@/views/tab/IndexHome.vue')
const CameraPage = () => import('@/views/tab/CameraPage.vue')
const ProfilePage = () => import('@/views/tab/ProfilePage.vue')
const PageStub = () => import('@/views/stub/PageStub.vue')
const TestSelectPage = () => import('@/views/pages/TestSelectPage.vue')
const MbtiTestPage = () => import('@/views/test/MbtiTestPage.vue')
const STUB_EXCLUDE = new Set<string>(['pages/test-select/index', 'pages/test/mbti'])
const stubRoutes = MINI_PAGE_PATHS.filter(
(p) => !TAB_PATHS.has(p) && !STUB_EXCLUDE.has(p)
).map((path) => ({
path: '/' + path,
name: 'stub-' + path.replace(/\//g, '-'),
component: PageStub,
meta: { miniPath: path, title: path.split('/').pop() || path },
}))
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
component: TabLayout,
redirect: '/pages/index/index',
children: [
{
path: 'pages/index/index',
name: 'home',
component: IndexHome,
meta: { miniPath: 'pages/index/index', tab: true },
},
{
path: 'pages/index/camera',
name: 'camera',
component: CameraPage,
meta: { miniPath: 'pages/index/camera', tab: true },
},
{
path: 'pages/profile/index',
name: 'profile',
component: ProfilePage,
meta: { miniPath: 'pages/profile/index', tab: true },
},
],
},
{
path: '/pages/test-select/index',
name: 'test-select',
component: TestSelectPage,
meta: { miniPath: 'pages/test-select/index', title: '选择测试' },
},
{
path: '/pages/test/mbti',
name: 'test-mbti',
component: MbtiTestPage,
meta: { miniPath: 'pages/test/mbti', title: 'MBTI 测试' },
},
...stubRoutes,
{ path: '/:pathMatch(.*)*', redirect: '/pages/index/index' },
],
})
export default router

98
h5-vue/src/stores/app.ts Normal file
View File

@@ -0,0 +1,98 @@
import axios from 'axios'
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { get, set as idbSet } from '../utils/storage'
const TOKEN_KEY = 'token'
const API_BASE_KEY = 'apiBase'
export const useAppStore = defineStore('app', () => {
const token = ref<string | null>(get(TOKEN_KEY))
const apiBase = ref(
(import.meta.env.VITE_API_BASE as string) || get(API_BASE_KEY) || ''
)
const siteTitle = ref('神仙团队性格测试')
const reviewMode = ref(false)
const maintenanceMode = ref(false)
const miniprogramAuditMode = ref(false)
const textConfig = ref<Record<string, string> | null>(null)
const enterprisePermissions = ref<Record<string, boolean> | null>(null)
const appScope = ref<'personal' | 'enterprise'>('personal')
/** 与小程序 globalData.enterpriseIdFromScene 等对齐,供题库/提交 enterpriseId */
const enterpriseIdFromScene = ref<number | null>(null)
const userEnterpriseId = ref<number | null>(null)
const defaultEnterpriseId = ref<number | null>(null)
/** 与小程序 testQuestionDrawCount抽题上限0 表示不限制) */
const testQuestionDrawCount = ref(0)
const reviewEff = computed(
() => reviewMode.value || maintenanceMode.value || miniprogramAuditMode.value
)
function initFromStorage() {
const t = get(TOKEN_KEY)
if (t) token.value = t
const base = import.meta.env.VITE_API_BASE || get(API_BASE_KEY)
if (base) apiBase.value = String(base).replace(/\/$/, '')
}
function setApiBase(base: string) {
apiBase.value = base.replace(/\/$/, '')
try {
idbSet(API_BASE_KEY, apiBase.value)
} catch {
/* ignore */
}
}
async function fetchRuntime(scope: 'personal' | 'enterprise' = 'personal') {
const base = apiBase.value
if (!base) return null
appScope.value = scope
const url = `${base}/api/config/runtime?scope=${encodeURIComponent(scope)}`
try {
const { data: body } = await axios.get<{ code: number; data?: Record<string, unknown> }>(
url,
{ timeout: 120000 }
)
if (!body || body.code !== 200 || !body.data) return null
const d = body.data as Record<string, unknown>
if (typeof d.siteTitle === 'string') siteTitle.value = d.siteTitle
if (typeof d.reviewMode === 'boolean') reviewMode.value = d.reviewMode
if (typeof d.maintenanceMode === 'boolean') maintenanceMode.value = d.maintenanceMode
if (typeof d.miniprogramAuditMode === 'boolean')
miniprogramAuditMode.value = d.miniprogramAuditMode
if (d.textConfig && typeof d.textConfig === 'object')
textConfig.value = d.textConfig as Record<string, string>
if (d.enterprisePermissions && typeof d.enterprisePermissions === 'object')
enterprisePermissions.value = d.enterprisePermissions as Record<string, boolean>
if (d.testQuestionDrawCount != null) {
const n = parseInt(String(d.testQuestionDrawCount), 10)
testQuestionDrawCount.value = Number.isFinite(n) && n > 0 ? Math.min(500, n) : 0
}
return d
} catch {
return null
}
}
return {
token,
apiBase,
siteTitle,
reviewMode,
maintenanceMode,
miniprogramAuditMode,
textConfig,
enterprisePermissions,
appScope,
enterpriseIdFromScene,
userEnterpriseId,
defaultEnterpriseId,
testQuestionDrawCount,
reviewEff,
initFromStorage,
setApiBase,
fetchRuntime,
}
})

View File

@@ -0,0 +1,28 @@
// 与小程序 750rpx 设计稿对齐100vw = 750rpx
@function rpx($n) {
@return calc(#{$n} * 100vw / 750);
}
:root {
--primary-color: #ff6b8a;
--secondary-color: #8b5cf6;
--text-primary: #1f2937;
--text-secondary: #6b7280;
--bg-secondary: #f3f4f6;
--tabbar-pad: calc(168 * 100vw / 750 + env(safe-area-inset-bottom, 0px) + 36 * 100vw / 750);
--tabbar-height: calc(168 * 100vw / 750);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
sans-serif;
font-size: rpx(28);
color: var(--text-primary);
background: var(--bg-secondary);
-webkit-tap-highlight-color: transparent;
}

10
h5-vue/src/utils/audit.ts Normal file
View File

@@ -0,0 +1,10 @@
/** 与小程序 miniprogramAuditGate.isAuditHideAiMode 一致:审核/维护/提审下隐藏高考与神仙 AI 等 */
export function isAuditHideAiMode(gd: {
reviewMode?: boolean
maintenanceMode?: boolean
miniprogramAuditMode?: boolean
} | null): boolean {
if (!gd) return false
return !!(gd.miniprogramAuditMode || gd.maintenanceMode || gd.reviewMode)
}

View File

@@ -0,0 +1,17 @@
import { useAppStore } from '@/stores/app'
/** 与小程序 enterpriseContext.getEnterpriseIdForApiPayload 对齐H5 暂无 scene eid默认 null */
export function getEnterpriseIdForApiPayload(): number | null {
const store = useAppStore()
const scope = store.appScope || 'personal'
if (scope === 'personal') {
const fromScene = store.enterpriseIdFromScene
if (fromScene != null && Number(fromScene) > 0) return Number(fromScene)
return null
}
const bound = store.userEnterpriseId
if (bound != null && Number(bound) > 0) return Number(bound)
const def = store.defaultEnterpriseId
if (def != null && Number(def) > 0) return Number(def)
return null
}

View File

@@ -0,0 +1,82 @@
import { mbtiDescriptions } from './mbtiDescriptions'
export interface MbtiCalcResult {
mbtiType: string
scores: Record<'E' | 'I' | 'S' | 'N' | 'T' | 'F' | 'J' | 'P', number>
dimensionScores: {
EI: { E: number; I: number; dominant: string; percentage: number }
SN: { S: number; N: number; dominant: string; percentage: number }
TF: { T: number; F: number; dominant: string; percentage: number }
JP: { J: number; P: number; dominant: string; percentage: number }
}
confidence: number
description: (typeof mbtiDescriptions)[string] | Record<string, never>
}
/** 与 miniprogram/pages/test/mbti.js calculateResult 一致 */
export function calculateMbtiResult(answers: Record<string, string>): MbtiCalcResult {
const scores = { E: 0, I: 0, S: 0, N: 0, T: 0, F: 0, J: 0, P: 0 }
Object.values(answers).forEach((value) => {
if (value in scores) {
scores[value as keyof typeof scores]++
}
})
const mbtiType = [
scores.E >= scores.I ? 'E' : 'I',
scores.S >= scores.N ? 'S' : 'N',
scores.T >= scores.F ? 'T' : 'F',
scores.J >= scores.P ? 'J' : 'P',
].join('')
const pct = (a: number, b: number) => {
const s = a + b
if (!s) return 50
return Math.round((Math.max(a, b) / s) * 100)
}
const dimensionScores = {
EI: {
E: scores.E,
I: scores.I,
dominant: scores.E >= scores.I ? 'E' : 'I',
percentage: pct(scores.E, scores.I),
},
SN: {
S: scores.S,
N: scores.N,
dominant: scores.S >= scores.N ? 'S' : 'N',
percentage: pct(scores.S, scores.N),
},
TF: {
T: scores.T,
F: scores.F,
dominant: scores.T >= scores.F ? 'T' : 'F',
percentage: pct(scores.T, scores.F),
},
JP: {
J: scores.J,
P: scores.P,
dominant: scores.J >= scores.P ? 'J' : 'P',
percentage: pct(scores.J, scores.P),
},
}
let confidence = Math.round(
(dimensionScores.EI.percentage +
dimensionScores.SN.percentage +
dimensionScores.TF.percentage +
dimensionScores.JP.percentage) /
4
)
if (!Number.isFinite(confidence)) confidence = 0
return {
mbtiType,
scores,
dimensionScores,
confidence,
description: mbtiDescriptions[mbtiType] || {},
}
}

View File

@@ -0,0 +1,159 @@
/** 与 miniprogram/utils/descriptions.js mbtiDescriptions 一致 */
export const mbtiDescriptions: Record<
string,
{
type: string
name: string
category: string
description: string
strengths: string[]
weaknesses: string[]
careers: string[]
}
> = {
ISTJ: {
type: 'ISTJ',
name: '物流师',
category: '守护者',
description: '务实、负责、可靠的传统主义者',
strengths: ['可靠负责', '注重细节', '有条理'],
weaknesses: ['可能过于固执', '不喜变化'],
careers: ['会计师', '审计师', '项目经理'],
},
ISFJ: {
type: 'ISFJ',
name: '守卫者',
category: '守护者',
description: '安静、友好、负责任的守护者',
strengths: ['忠诚体贴', '观察力强', '耐心'],
weaknesses: ['不善拒绝', '过于谦虚'],
careers: ['护士', '教师', '行政'],
},
INFJ: {
type: 'INFJ',
name: '提倡者',
category: '理想主义者',
description: '寻求意义和联系的理想主义者',
strengths: ['洞察力强', '有远见', '坚定'],
weaknesses: ['过于理想化', '容易疲惫'],
careers: ['心理咨询', '作家', '人力资源'],
},
INTJ: {
type: 'INTJ',
name: '建筑师',
category: '理想主义者',
description: '独立、有战略眼光的思考者',
strengths: ['战略思维', '独立自信', '意志坚定'],
weaknesses: ['可能傲慢', '过于苛刻'],
careers: ['战略顾问', '科学家', '架构师'],
},
ISTP: {
type: 'ISTP',
name: '鉴赏家',
category: '探险家',
description: '灵活、务实的问题解决者',
strengths: ['适应力强', '动手能力', '冷静'],
weaknesses: ['可能冷漠', '不善表达'],
careers: ['工程师', '技术员', '飞行员'],
},
ISFP: {
type: 'ISFP',
name: '艺术家',
category: '探险家',
description: '温和、敏感的艺术家',
strengths: ['创造力', '同理心', '灵活'],
weaknesses: ['过于敏感', '避免冲突'],
careers: ['设计师', '艺术家', '摄影师'],
},
INFP: {
type: 'INFP',
name: '调停者',
category: '理想主义者',
description: '理想主义、忠诚的调解者',
strengths: ['创造力', '同理心', '真诚'],
weaknesses: ['过于理想化', '情绪化'],
careers: ['作家', '心理咨询', '社工'],
},
INTP: {
type: 'INTP',
name: '逻辑学家',
category: '理想主义者',
description: '创新、逻辑的思考者',
strengths: ['逻辑思维', '创新能力', '客观'],
weaknesses: ['可能孤僻', '忽视情感'],
careers: ['程序员', '研究员', '分析师'],
},
ESTP: {
type: 'ESTP',
name: '动力者',
category: '探险家',
description: '精力充沛、务实的行动者',
strengths: ['果断', '务实', '善于应变'],
weaknesses: ['可能冲动', '缺乏耐心'],
careers: ['销售', '企业家', '运动员'],
},
ESFP: {
type: 'ESFP',
name: '表演者',
category: '探险家',
description: '热情、友好的社交达人',
strengths: ['热情友好', '乐观', '灵活'],
weaknesses: ['可能肤浅', '容易分心'],
careers: ['演员', '销售', '主持人'],
},
ENFP: {
type: 'ENFP',
name: '竞选者',
category: '理想主义者',
description: '热情、有创造力的社交者',
strengths: ['创造力', '热情', '善于沟通'],
weaknesses: ['可能不切实际', '缺乏专注'],
careers: ['市场营销', '记者', '顾问'],
},
ENTP: {
type: 'ENTP',
name: '辩论家',
category: '理想主义者',
description: '聪明、好奇的思想家',
strengths: ['创新能力', '辩论能力', '适应力'],
weaknesses: ['可能争辩', '不善执行'],
careers: ['律师', '企业家', '咨询'],
},
ESTJ: {
type: 'ESTJ',
name: '管理者',
category: '守护者',
description: '务实、果断的组织者',
strengths: ['领导力', '组织能力', '务实'],
weaknesses: ['可能专制', '不够灵活'],
careers: ['管理者', '项目经理', '律师'],
},
ESFJ: {
type: 'ESFJ',
name: '执政官',
category: '守护者',
description: '热心、合作的支持者',
strengths: ['关心他人', '负责任', '善于合作'],
weaknesses: ['过于在意评价', '不善拒绝'],
careers: ['教师', '护士', '人力资源'],
},
ENFJ: {
type: 'ENFJ',
name: '主人公',
category: '理想主义者',
description: '有魅力、鼓舞人心的领导者',
strengths: ['领导力', '同理心', '说服力'],
weaknesses: ['过于理想化', '过度付出'],
careers: ['培训师', '顾问', '教师'],
},
ENTJ: {
type: 'ENTJ',
name: '指挥官',
category: '理想主义者',
description: '大胆、有远见的领导者',
strengths: ['领导力', '战略思维', '果断'],
weaknesses: ['可能专制', '不耐烦'],
careers: ['CEO', '企业家', '律师'],
},
}

View File

@@ -0,0 +1,269 @@
// utils/mbtiLocalQuestions.js
// MBTI 本地 fallback 题库 —— 仅当 /api/test/questions 不可达时使用
// 覆盖四维度E/I · S/N · T/F · J/P各 15 题,共 60 题
// 注意:不替代线上题库,仅保底;结果计算走 pages/test/mbti.js 的 calculateResult
//
// 题目结构:{ id, text, dimension: 'EI'|'SN'|'TF'|'JP', options: [{ text, value }] }
// value 必须是 'E' / 'I' / 'S' / 'N' / 'T' / 'F' / 'J' / 'P' 八个字母之一
const QUESTIONS = [
// ==================== E / I 外向 vs 内向15 题)====================
{ id: 'L-EI-01', text: '参加聚会时,你更享受:', dimension: 'EI', options: [
{ text: '与很多人交谈互动', value: 'E' },
{ text: '和少数几个人深聊', value: 'I' }
]},
{ id: 'L-EI-02', text: '经过忙碌的一天,你更倾向于:', dimension: 'EI', options: [
{ text: '出门找朋友放松', value: 'E' },
{ text: '一个人独处充电', value: 'I' }
]},
{ id: 'L-EI-03', text: '遇到问题时,你先:', dimension: 'EI', options: [
{ text: '找人一起讨论', value: 'E' },
{ text: '一个人默默思考', value: 'I' }
]},
{ id: 'L-EI-04', text: '在新环境里,你通常:', dimension: 'EI', options: [
{ text: '主动跟大家打招呼', value: 'E' },
{ text: '先观察再慢慢融入', value: 'I' }
]},
{ id: 'L-EI-05', text: '你更喜欢的工作方式:', dimension: 'EI', options: [
{ text: '多人协作沟通推进', value: 'E' },
{ text: '独立专注深度工作', value: 'I' }
]},
{ id: 'L-EI-06', text: '面对一群陌生人,你会:', dimension: 'EI', options: [
{ text: '感到兴奋,期待结识', value: 'E' },
{ text: '感到疲惫,想早离场', value: 'I' }
]},
{ id: 'L-EI-07', text: '你表达想法时更习惯:', dimension: 'EI', options: [
{ text: '边说边想,口头表达', value: 'E' },
{ text: '先想好再开口或写下', value: 'I' }
]},
{ id: 'L-EI-08', text: '周末你更想:', dimension: 'EI', options: [
{ text: '约上朋友热闹一场', value: 'E' },
{ text: '一个人看书、发呆', value: 'I' }
]},
{ id: 'L-EI-09', text: '在会议中你更容易:', dimension: 'EI', options: [
{ text: '积极发言分享观点', value: 'E' },
{ text: '认真倾听再发言', value: 'I' }
]},
{ id: 'L-EI-10', text: '你的能量来源主要是:', dimension: 'EI', options: [
{ text: '和他人的互动交流', value: 'E' },
{ text: '独处的安静时光', value: 'I' }
]},
{ id: 'L-EI-11', text: '认识新朋友时你更看重:', dimension: 'EI', options: [
{ text: '广泛的社交圈', value: 'E' },
{ text: '少数深度的友谊', value: 'I' }
]},
{ id: 'L-EI-12', text: '演讲或发言机会出现时你:', dimension: 'EI', options: [
{ text: '愿意尝试,享受舞台', value: 'E' },
{ text: '倾向避免,偏好幕后', value: 'I' }
]},
{ id: 'L-EI-13', text: '你更容易:', dimension: 'EI', options: [
{ text: '主动开启对话', value: 'E' },
{ text: '等别人先开口', value: 'I' }
]},
{ id: 'L-EI-14', text: '一天下来话多了你会:', dimension: 'EI', options: [
{ text: '越讲越来劲', value: 'E' },
{ text: '想找个安静角落', value: 'I' }
]},
{ id: 'L-EI-15', text: '沉默的氛围你觉得:', dimension: 'EI', options: [
{ text: '尴尬,想打破', value: 'E' },
{ text: '舒服,享受宁静', value: 'I' }
]},
// ==================== S / N 感觉 vs 直觉15 题)====================
{ id: 'L-SN-01', text: '你更关注:', dimension: 'SN', options: [
{ text: '眼下具体的事实', value: 'S' },
{ text: '未来的可能性', value: 'N' }
]},
{ id: 'L-SN-02', text: '学习新知识时你偏好:', dimension: 'SN', options: [
{ text: '从实际案例入手', value: 'S' },
{ text: '先理解整体框架', value: 'N' }
]},
{ id: 'L-SN-03', text: '做决定时你更依赖:', dimension: 'SN', options: [
{ text: '过往经验与数据', value: 'S' },
{ text: '直觉与潜在趋势', value: 'N' }
]},
{ id: 'L-SN-04', text: '描述事物时你更倾向:', dimension: 'SN', options: [
{ text: '具体细节', value: 'S' },
{ text: '抽象概念', value: 'N' }
]},
{ id: 'L-SN-05', text: '面对项目,你会先:', dimension: 'SN', options: [
{ text: '列清具体步骤', value: 'S' },
{ text: '构思大方向与创意', value: 'N' }
]},
{ id: 'L-SN-06', text: '你更欣赏的书籍:', dimension: 'SN', options: [
{ text: '写实传记、工具书', value: 'S' },
{ text: '哲学、科幻、象征文学', value: 'N' }
]},
{ id: 'L-SN-07', text: '谈论"未来五年",你会:', dimension: 'SN', options: [
{ text: '列出具体目标与路径', value: 'S' },
{ text: '畅想各种可能性', value: 'N' }
]},
{ id: 'L-SN-08', text: '接到任务你先问:', dimension: 'SN', options: [
{ text: '怎么做、截止时间?', value: 'S' },
{ text: '为什么做、意义是?', value: 'N' }
]},
{ id: 'L-SN-09', text: '你喜欢的工作类型:', dimension: 'SN', options: [
{ text: '流程清晰、步骤明确', value: 'S' },
{ text: '需要创新与想象', value: 'N' }
]},
{ id: 'L-SN-10', text: '看风景时你会:', dimension: 'SN', options: [
{ text: '留意植物、天气、光线', value: 'S' },
{ text: '联想到某种心情或意境', value: 'N' }
]},
{ id: 'L-SN-11', text: '聊天中你更容易:', dimension: 'SN', options: [
{ text: '讲具体发生的事', value: 'S' },
{ text: '延伸到理论或类比', value: 'N' }
]},
{ id: 'L-SN-12', text: '解决问题时你更看重:', dimension: 'SN', options: [
{ text: '验证过的成熟方法', value: 'S' },
{ text: '新颖独特的切入点', value: 'N' }
]},
{ id: 'L-SN-13', text: '你的注意力更多放在:', dimension: 'SN', options: [
{ text: '此刻正在发生的事', value: 'S' },
{ text: '事情背后的模式', value: 'N' }
]},
{ id: 'L-SN-14', text: '你相信:', dimension: 'SN', options: [
{ text: '看得见的才是真的', value: 'S' },
{ text: '灵感与预感常常正确', value: 'N' }
]},
{ id: 'L-SN-15', text: '别人说你的特点:', dimension: 'SN', options: [
{ text: '踏实、可靠、注重细节', value: 'S' },
{ text: '想象力丰富、爱畅想', value: 'N' }
]},
// ==================== T / F 思考 vs 情感15 题)====================
{ id: 'L-TF-01', text: '做决定时你更看重:', dimension: 'TF', options: [
{ text: '逻辑与客观事实', value: 'T' },
{ text: '感受与人际影响', value: 'F' }
]},
{ id: 'L-TF-02', text: '朋友向你倾诉时你会:', dimension: 'TF', options: [
{ text: '分析原因、给出建议', value: 'T' },
{ text: '共情、陪伴感受', value: 'F' }
]},
{ id: 'L-TF-03', text: '批评同事工作时你更倾向:', dimension: 'TF', options: [
{ text: '直接指出问题', value: 'T' },
{ text: '委婉给建议', value: 'F' }
]},
{ id: 'L-TF-04', text: '你认为好决策要:', dimension: 'TF', options: [
{ text: '公正合理', value: 'T' },
{ text: '让人感到被尊重', value: 'F' }
]},
{ id: 'L-TF-05', text: '冲突中你更关注:', dimension: 'TF', options: [
{ text: '谁对谁错', value: 'T' },
{ text: '大家的感受', value: 'F' }
]},
{ id: 'L-TF-06', text: '工作评估你更相信:', dimension: 'TF', options: [
{ text: '数据与绩效', value: 'T' },
{ text: '态度与团队氛围', value: 'F' }
]},
{ id: 'L-TF-07', text: '你评价自己是:', dimension: 'TF', options: [
{ text: '理性、客观', value: 'T' },
{ text: '温暖、善解人意', value: 'F' }
]},
{ id: 'L-TF-08', text: '看电影时你更容易:', dimension: 'TF', options: [
{ text: '分析剧情逻辑', value: 'T' },
{ text: '被角色情绪打动', value: 'F' }
]},
{ id: 'L-TF-09', text: '他人失败时你会说:', dimension: 'TF', options: [
{ text: '看看哪里可以改进', value: 'T' },
{ text: '你辛苦了,别太难受', value: 'F' }
]},
{ id: 'L-TF-10', text: '买东西时你最在意:', dimension: 'TF', options: [
{ text: '性价比', value: 'T' },
{ text: '是否让自己或亲人开心', value: 'F' }
]},
{ id: 'L-TF-11', text: '谈判中你更可能:', dimension: 'TF', options: [
{ text: '坚持立场,据理力争', value: 'T' },
{ text: '寻求双方都舒服的方案', value: 'F' }
]},
{ id: 'L-TF-12', text: '接受反馈时你更在意:', dimension: 'TF', options: [
{ text: '反馈是否准确', value: 'T' },
{ text: '对方是否尊重你', value: 'F' }
]},
{ id: 'L-TF-13', text: '面对选择你先问自己:', dimension: 'TF', options: [
{ text: '哪个更合理?', value: 'T' },
{ text: '哪个让我更安心?', value: 'F' }
]},
{ id: 'L-TF-14', text: '你欣赏的领导:', dimension: 'TF', options: [
{ text: '目标清晰、决策果断', value: 'T' },
{ text: '关心员工、能激励人', value: 'F' }
]},
{ id: 'L-TF-15', text: '朋友犯错时你更倾向:', dimension: 'TF', options: [
{ text: '坦诚指出', value: 'T' },
{ text: '先理解再委婉提醒', value: 'F' }
]},
// ==================== J / P 判断 vs 知觉15 题)====================
{ id: 'L-JP-01', text: '你更喜欢:', dimension: 'JP', options: [
{ text: '计划好再行动', value: 'J' },
{ text: '边走边看,保持灵活', value: 'P' }
]},
{ id: 'L-JP-02', text: '你的桌面/房间通常:', dimension: 'JP', options: [
{ text: '整洁有序', value: 'J' },
{ text: '凌乱但自有章法', value: 'P' }
]},
{ id: 'L-JP-03', text: '旅行你更愿意:', dimension: 'JP', options: [
{ text: '提前订好行程', value: 'J' },
{ text: '随性出发,临时决定', value: 'P' }
]},
{ id: 'L-JP-04', text: '截止日期临近你会:', dimension: 'JP', options: [
{ text: '很早开始,提前完成', value: 'J' },
{ text: '最后冲刺,效率最高', value: 'P' }
]},
{ id: 'L-JP-05', text: '你喜欢的待办清单:', dimension: 'JP', options: [
{ text: '打勾一条一条完成', value: 'J' },
{ text: '灵活选择,心情驱动', value: 'P' }
]},
{ id: 'L-JP-06', text: '改变计划时你:', dimension: 'JP', options: [
{ text: '感到不安,希望稳定', value: 'J' },
{ text: '欢迎变化,觉得有趣', value: 'P' }
]},
{ id: 'L-JP-07', text: '你做决定的速度:', dimension: 'JP', options: [
{ text: '果断,能尽快确定', value: 'J' },
{ text: '偏慢,倾向保留选择', value: 'P' }
]},
{ id: 'L-JP-08', text: '你工作中更倾向:', dimension: 'JP', options: [
{ text: '设定目标并推进完成', value: 'J' },
{ text: '探索各种可能再定', value: 'P' }
]},
{ id: 'L-JP-09', text: '收到邀请你会:', dimension: 'JP', options: [
{ text: '马上确认或婉拒', value: 'J' },
{ text: '拖一阵看情况再定', value: 'P' }
]},
{ id: 'L-JP-10', text: '你更喜欢的节奏:', dimension: 'JP', options: [
{ text: '稳定可控的日常', value: 'J' },
{ text: '充满惊喜的变化', value: 'P' }
]},
{ id: 'L-JP-11', text: '面对突发任务你:', dimension: 'JP', options: [
{ text: '感到打乱、需要调整', value: 'J' },
{ text: '随机应变、不太介意', value: 'P' }
]},
{ id: 'L-JP-12', text: '你喜欢的工作方式:', dimension: 'JP', options: [
{ text: '按流程一步一步', value: 'J' },
{ text: '灵感来了就一口气干', value: 'P' }
]},
{ id: 'L-JP-13', text: '别人评价你:', dimension: 'JP', options: [
{ text: '有条理、守时', value: 'J' },
{ text: '随性、有弹性', value: 'P' }
]},
{ id: 'L-JP-14', text: '面对未完成的事你:', dimension: 'JP', options: [
{ text: '强迫自己收尾', value: 'J' },
{ text: '先放一放,换个状态', value: 'P' }
]},
{ id: 'L-JP-15', text: '你的生活哲学:', dimension: 'JP', options: [
{ text: '计划是成功的一半', value: 'J' },
{ text: '变化是唯一的不变', value: 'P' }
]}
]
/** 返回本地 MBTI 题库60 题) */
function getMbtiLocalQuestions() {
return QUESTIONS.map((q) => ({
id: q.id,
text: q.text,
dimension: q.dimension,
options: (q.options || []).map((o) => ({ ...o }))
}))
}
export { getMbtiLocalQuestions }

View File

@@ -0,0 +1,7 @@
import router from '@/router'
/** 与 miniprogram utils/phoneAuth.afterTestSubmitNavigate 对齐:提交后进结果页 */
export function afterTestSubmitNavigate(targetUrl: string): void {
const url = (targetUrl && String(targetUrl).trim()) || '/pages/index/index'
router.replace(url).catch(() => router.replace('/pages/index/index'))
}

View File

@@ -0,0 +1,174 @@
/**
* 与 miniprogram/utils/questionBank.js 对齐:远程拉题 + Fisher-Yates + MBTI 本地降级
*/
import { request } from '@/utils/request'
import { useAppStore } from '@/stores/app'
import { getEnterpriseIdForApiPayload } from '@/utils/enterpriseContext'
import { getMbtiLocalQuestions } from '@/utils/mbtiLocalQuestions'
export interface TestQuestionOption {
text: string
value: string
}
export interface TestQuestion {
id: string | number
question: string
options: TestQuestionOption[]
dimension?: string
}
interface ApiListBody {
code: number
message?: string
data?: { list?: unknown[] }
}
function parseJsonBody(raw: unknown): ApiListBody {
if (raw == null) return {}
if (typeof raw === 'object' && raw !== null && !Array.isArray(raw)) return raw as ApiListBody
if (typeof raw === 'string') {
const s = raw.trim()
if (!s) return {}
try {
return JSON.parse(s) as ApiListBody
} catch {
return { code: -1, message: 'parse error' }
}
}
return {}
}
export function shuffleQuestions(questions: TestQuestion[]): TestQuestion[] {
const arr = (questions || []).map((q) => ({
...q,
options: (q.options || []).slice().sort(() => Math.random() - 0.5),
}))
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[arr[i], arr[j]] = [arr[j], arr[i]]
}
return arr
}
function normalizeQuestion(raw: Record<string, unknown>): TestQuestion | null {
const id = raw.id
if (id == null) return null
const qtext =
(typeof raw.question === 'string' && raw.question) ||
(typeof raw.text === 'string' && raw.text) ||
''
const options = Array.isArray(raw.options) ? raw.options : []
const mapped: TestQuestionOption[] = options
.map((o: unknown) => {
if (!o || typeof o !== 'object') return null
const x = o as Record<string, unknown>
const text = typeof x.text === 'string' ? x.text : ''
const value = typeof x.value === 'string' ? x.value : ''
if (!text || !value) return null
return { text, value }
})
.filter((x): x is TestQuestionOption => x != null)
return {
id: id as string | number,
question: qtext,
options: mapped,
dimension: typeof raw.dimension === 'string' ? raw.dimension : undefined,
}
}
function applyDrawCountAfterShuffle(questions: TestQuestion[]): TestQuestion[] {
const store = useAppStore()
const n = parseInt(String(store.testQuestionDrawCount ?? '0'), 10)
const draw = Number.isFinite(n) && n > 0 ? Math.min(500, n) : 0
if (!draw || questions.length <= draw) return questions
return questions.slice(0, draw)
}
function resolveEnterpriseIdForQuestionBank(opts: { enterpriseId?: number | null }): number | null {
if (Object.prototype.hasOwnProperty.call(opts, 'enterpriseId')) {
const v = opts.enterpriseId
if (v == null || v === '') return null
const num = Number(v)
return Number.isFinite(num) && num > 0 ? num : null
}
return getEnterpriseIdForApiPayload()
}
function loadLocalFallback(type: string): TestQuestion[] | null {
if (type !== 'mbti') return null
try {
const list = getMbtiLocalQuestions() as Record<string, unknown>[]
if (!Array.isArray(list) || !list.length) return null
return list
.map((q) => normalizeQuestion(q))
.filter((x): x is TestQuestion => x != null && x.options.length > 0)
} catch {
return null
}
}
type FetchErr = Error & { statusCode?: number; isNetworkError?: boolean }
async function fetchQuestionBank(type: string, enterpriseId: number | null): Promise<TestQuestion[]> {
const q = [`type=${encodeURIComponent(type)}`]
if (enterpriseId != null && Number(enterpriseId) > 0) {
q.push(`enterpriseId=${Number(enterpriseId)}`)
}
try {
const res = await request<string | ApiListBody>({
url: `/api/test/questions?${q.join('&')}`,
method: 'GET',
needAuth: true,
})
const body = parseJsonBody(res.data)
if (body.code !== 200 || body.data == null) {
const msg = body.message || '拉取题库失败'
const hint =
body.code === 401 || body.code === 403 ? `${msg}(请重新登录或刷新)` : msg
const e = new Error(hint) as FetchErr
e.statusCode = body.code === 401 ? 401 : body.code === 403 ? 403 : undefined
throw e
}
const list = body.data?.list
if (!Array.isArray(list)) throw new Error('题库格式错误')
const mapped = list
.map((item) => normalizeQuestion(item as Record<string, unknown>))
.filter((x): x is TestQuestion => x != null && x.question && x.options.length > 0)
return mapped
} catch (err: unknown) {
const ax = err as { response?: { status?: number }; message?: string }
const status = ax.response?.status
const e = new Error(ax.message || '网络请求失败') as FetchErr
e.statusCode = status
e.isNetworkError = ax.response == null
throw e
}
}
export function loadQuestions(
type: 'mbti' | 'sbti' | 'disc' | 'pdp',
opts: { enterpriseId?: number | null; allowLocalFallback?: boolean } = {}
): Promise<TestQuestion[]> {
const enterpriseId = resolveEnterpriseIdForQuestionBank(opts)
const allowLocal = opts.allowLocalFallback !== false
return fetchQuestionBank(type, enterpriseId)
.then((list) => {
if (!list.length) throw new Error('暂无启用题目')
return applyDrawCountAfterShuffle(shuffleQuestions(list))
})
.catch((err: FetchErr) => {
const status = err.statusCode
const canFallback =
err.isNetworkError ||
(status != null && status >= 500 && status < 600) ||
status === 401
if (!allowLocal || !canFallback) throw err
const local = loadLocalFallback(type)
if (!local || !local.length) throw err
console.warn('[questionBank] 后端不可达,降级本地 fallback 题库:', type, err.message)
return applyDrawCountAfterShuffle(shuffleQuestions(local))
})
}

View File

@@ -0,0 +1,32 @@
import axios, { type AxiosRequestConfig } from 'axios'
import { useAppStore } from '@/stores/app'
export interface RequestOptions extends AxiosRequestConfig {
needAuth?: boolean
}
export async function request<T = unknown>(options: RequestOptions): Promise<{ data: T }> {
const store = useAppStore()
const apiBase = store.apiBase || ''
const path = options.url || ''
const fullUrl = path.startsWith('http')
? path
: `${apiBase.replace(/\/$/, '')}${path.startsWith('/') ? '' : '/'}${path}`
const needAuth = options.needAuth !== false
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
}
if (needAuth && store.token) {
headers.Authorization = `Bearer ${store.token}`
}
const res = await axios.request<T>({
...options,
url: fullUrl,
headers,
timeout: options.timeout ?? 120000,
})
return { data: res.data }
}

View File

@@ -0,0 +1,24 @@
/** H5 替代 wx.setStorageSync */
export function get(key: string): string | null {
try {
return localStorage.getItem(key)
} catch {
return null
}
}
export function set(key: string, val: string): void {
try {
localStorage.setItem(key, val)
} catch {
/* ignore */
}
}
export function remove(key: string): void {
try {
localStorage.removeItem(key)
} catch {
/* ignore */
}
}

View File

@@ -0,0 +1,52 @@
import { request } from '@/utils/request'
import { useAppStore } from '@/stores/app'
import { getEnterpriseIdForApiPayload } from '@/utils/enterpriseContext'
import { set } from '@/utils/storage'
/** 与 miniprogram app.saveTestResult 对齐:本地存结果 + POST /api/test/submit */
export async function saveTestResult(
type: string,
result: Record<string, unknown>
): Promise<{ id?: string | number }> {
const store = useAppStore()
const key = `${type}Result`
try {
set(key, JSON.stringify(result))
} catch {
/* ignore */
}
if (!store.token) {
return {}
}
const enterpriseId = getEnterpriseIdForApiPayload()
try {
const { data: body } = await request<{ code: number; data?: { id?: string | number } }>({
url: '/api/test/submit',
method: 'POST',
needAuth: true,
data: {
testType: type,
answers: result.answers ?? [],
result,
enterpriseId: enterpriseId != null ? enterpriseId : undefined,
testDuration: result.testDuration ?? 0,
timestamp: new Date().toISOString(),
},
})
if (
body &&
body.code === 200 &&
body.data &&
typeof body.data === 'object' &&
body.data.id != null
) {
return { id: body.data.id }
}
return {}
} catch {
return {}
}
}

23
h5-vue/src/utils/toast.ts Normal file
View File

@@ -0,0 +1,23 @@
/** 轻量 Toast替代 wx.showToast */
export function showToast(message: string, durationMs = 2200): void {
const el = document.createElement('div')
el.textContent = message
el.style.cssText = [
'position:fixed',
'left:50%',
'bottom:max(80px,env(safe-area-inset-bottom))',
'transform:translateX(-50%)',
'background:rgba(0,0,0,.78)',
'color:#fff',
'padding:10px 16px',
'border-radius:8px',
'font-size:14px',
'z-index:99999',
'max-width:90vw',
'text-align:center',
'pointer-events:none',
].join(';')
document.body.appendChild(el)
setTimeout(() => el.remove(), durationMs)
}

View File

@@ -0,0 +1,343 @@
<template>
<div class="container">
<div class="card intro-card">
<span class="intro-title">选择一项详细性格测试</span>
<span v-if="auditHideAiFeatures" class="intro-desc">
问卷测评与拍照面相分项进入完成后可获得对应报告
</span>
<span v-else class="intro-desc">
问卷测评高考志愿规划与 AI 对话 / 拍照面相分项进入完成后可获得对应报告
</span>
</div>
<div v-if="permMbti" class="card entry-card" @click="goMBTI">
<div class="entry-icon-wrap mbti"><span class="entry-emoji">🧠</span></div>
<div class="entry-content">
<span class="entry-name">MBTI 性格测试</span>
<span class="entry-brief">16型人格 · 发现你的认知与决策风格</span>
</div>
<span class="entry-arrow"></span>
</div>
<div v-if="permSbti" class="card entry-card" @click="goSBTI">
<div class="entry-icon-wrap sbti"><span class="entry-emoji">🎭</span></div>
<div class="entry-content">
<span class="entry-name">SBTI 性格测试</span>
<span class="entry-brief">15 维等级匹配 · 闸口题与标准计分一致</span>
</div>
<span class="entry-arrow"></span>
</div>
<div v-if="permPdp" class="card entry-card" @click="goPDP">
<div class="entry-icon-wrap pdp"><span class="entry-emoji">🦁</span></div>
<div class="entry-content">
<span class="entry-name">PDP 行为偏好测试</span>
<span class="entry-brief">老虎 / 孔雀 / 无尾熊 / 猫头鹰 / 变色龙 · 行为风格</span>
</div>
<span class="entry-arrow"></span>
</div>
<div v-if="permDisc" class="card entry-card" @click="goDISC">
<div class="entry-icon-wrap disc"><span class="entry-emoji">📊</span></div>
<div class="entry-content">
<span class="entry-name">DISC 性格测试</span>
<span class="entry-brief">D/I/S/C 四维 · 沟通与行为倾向</span>
</div>
<span class="entry-arrow"></span>
</div>
<div v-if="permGaokao" class="card entry-card" @click="goGaokaoHub">
<div class="entry-icon-wrap gaokao"><span class="entry-emoji">🎓</span></div>
<div class="entry-content">
<span class="entry-name">高考志愿任务中心</span>
<span class="entry-brief">
完成 MBTI / PDP / DISC / 拍照面相与信息表单 · 购买报告后生成志愿分析
</span>
</div>
<span class="entry-arrow"></span>
</div>
<div v-if="permAiHub" class="card entry-card" @click="goAIChatInterpretation">
<div class="entry-icon-wrap ai-chat"><span class="entry-emoji">💬</span></div>
<div class="entry-content">
<span class="entry-name">AI 对话解读</span>
<span class="entry-brief">与神仙 AI 对话 · 结合测评画像的深度解读与建议</span>
</div>
<span class="entry-arrow"></span>
</div>
<div v-if="permFace" class="card entry-card" @click="goAIFaceAnalysis">
<div class="entry-icon-wrap ai-face"><span class="entry-emoji">📷</span></div>
<div class="entry-content">
<span class="entry-name">拍照面相分析</span>
<span class="entry-brief">上传正面与侧面照 · 面相 / 骨相与性格报告</span>
</div>
<span class="entry-arrow"></span>
</div>
<div v-if="allTestsDisabled" class="card perm-disabled-hint">
<span class="perm-disabled-text">
当前环境下暂未开放问卷测试请联系管理员或从首页进入
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useAppStore } from '@/stores/app'
import { isAuditHideAiMode } from '@/utils/audit'
import { showToast } from '@/utils/toast'
const router = useRouter()
const store = useAppStore()
const { enterprisePermissions } = storeToRefs(store)
const gd = computed(() => ({
reviewMode: store.reviewMode,
maintenanceMode: store.maintenanceMode,
miniprogramAuditMode: store.miniprogramAuditMode,
}))
const auditHideAiFeatures = computed(() => isAuditHideAiMode(gd.value))
const permFace = computed(() => !enterprisePermissions.value || enterprisePermissions.value.face !== false)
const permMbti = computed(() => !enterprisePermissions.value || enterprisePermissions.value.mbti !== false)
const permSbti = computed(() => !enterprisePermissions.value || enterprisePermissions.value.sbti !== false)
const permPdp = computed(() => !enterprisePermissions.value || enterprisePermissions.value.pdp !== false)
const permDisc = computed(() => !enterprisePermissions.value || enterprisePermissions.value.disc !== false)
const permGaokao = computed(() => {
const p = enterprisePermissions.value
const base = !p || p.gaokao !== false
return base && !auditHideAiFeatures.value
})
const permAiHub = computed(() => {
const p = enterprisePermissions.value
const base = !p || p.aiHub !== false
return base && !auditHideAiFeatures.value
})
const allTestsDisabled = computed(() => {
const p = enterprisePermissions.value
return !!(
p &&
!permMbti.value &&
!permSbti.value &&
!permPdp.value &&
!permDisc.value &&
!permGaokao.value
)
})
function syncPerms() {
store.fetchRuntime().catch(() => {})
}
onMounted(() => {
syncPerms()
})
function goMBTI() {
router.push('/pages/test/mbti')
}
function goSBTI() {
router.push('/pages/test/sbti')
}
function goPDP() {
router.push('/pages/test/pdp')
}
function goDISC() {
router.push('/pages/test/disc')
}
function goGaokaoHub() {
if (isAuditHideAiMode(gd.value)) {
showToast('版本审核中暂不可用')
return
}
if (!permGaokao.value) {
showToast('当前企业未开放高考志愿功能')
return
}
router.push('/pages/gaokao/index')
}
function goAIChatInterpretation() {
if (isAuditHideAiMode(gd.value)) {
showToast('功能升级中')
return
}
router.push('/pages/ai-chat/index?src=test_select')
}
function goAIFaceAnalysis() {
router.push('/pages/index/camera')
}
</script>
<style scoped lang="scss">
@use '@/styles/global.scss' as *;
.container {
min-height: 100vh;
background: linear-gradient(180deg, #fff5f5 0%, #f5f5f5 50%, #ffffff 100%);
padding: rpx(24) rpx(24) rpx(60);
}
.card {
background-color: #fff;
border-radius: rpx(16);
padding: rpx(32);
margin-bottom: rpx(24);
box-shadow: 0 rpx(2) rpx(8) rgba(0, 0, 0, 0.04);
}
.intro-card {
text-align: center;
padding: rpx(40) rpx(32);
}
.intro-title {
display: block;
font-size: rpx(34);
font-weight: 700;
color: #e63946;
margin-bottom: rpx(16);
}
.intro-desc {
display: block;
font-size: rpx(26);
color: #666;
line-height: 1.6;
}
.perm-disabled-hint {
text-align: center;
padding: rpx(32);
}
.perm-disabled-text {
font-size: rpx(28);
color: #888;
line-height: 1.6;
}
.entry-card {
display: flex;
align-items: center;
padding: rpx(28) rpx(32);
cursor: pointer;
transition: opacity 0.2s;
border-radius: rpx(16);
border: rpx(2) solid transparent;
&:active {
opacity: 0.9;
background: linear-gradient(
135deg,
rgba(230, 57, 70, 0.04) 0%,
rgba(255, 107, 157, 0.04) 100%
);
border-color: rgba(230, 57, 70, 0.15);
}
}
.entry-icon-wrap {
width: rpx(88);
height: rpx(88);
border-radius: rpx(20);
display: flex;
align-items: center;
justify-content: center;
margin-right: rpx(24);
flex-shrink: 0;
&.mbti {
background: linear-gradient(
135deg,
rgba(124, 58, 237, 0.15) 0%,
rgba(139, 92, 246, 0.2) 100%
);
}
&.sbti {
background: linear-gradient(135deg, #f2f7f3 0%, #dce8e0 100%);
border: rpx(1) solid #c5d4cc;
}
&.pdp {
background: linear-gradient(
135deg,
rgba(230, 57, 70, 0.12) 0%,
rgba(255, 107, 157, 0.15) 100%
);
}
&.disc {
background: linear-gradient(
135deg,
rgba(59, 130, 246, 0.12) 0%,
rgba(96, 165, 250, 0.18) 100%
);
}
&.gaokao {
background: linear-gradient(
135deg,
rgba(79, 70, 229, 0.18) 0%,
rgba(129, 140, 248, 0.22) 100%
);
border: rpx(1) solid rgba(99, 102, 241, 0.25);
}
&.ai-chat {
background: linear-gradient(
135deg,
rgba(124, 58, 237, 0.16) 0%,
rgba(99, 102, 241, 0.2) 100%
);
}
&.ai-face {
background: linear-gradient(
135deg,
rgba(236, 72, 153, 0.14) 0%,
rgba(244, 114, 182, 0.18) 100%
);
}
}
.entry-emoji {
font-size: rpx(44);
}
.entry-content {
flex: 1;
min-width: 0;
}
.entry-name {
display: block;
font-size: rpx(30);
font-weight: 600;
color: #333;
margin-bottom: rpx(8);
}
.entry-brief {
display: block;
font-size: rpx(24);
color: #888;
line-height: 1.4;
}
.entry-arrow {
font-size: rpx(32);
color: #e63946;
font-weight: 600;
margin-left: rpx(16);
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,103 @@
<template>
<div class="stub-page">
<header class="stub-nav">
<button type="button" class="back" aria-label="返回" @click="goBack"></button>
<h1 class="stub-title">{{ title }}</h1>
</header>
<div class="stub-body">
<p class="path"><code>{{ miniPath }}</code></p>
<p class="tip">
此页对应小程序路由已注册完整 UI / 交互 / 微信支付 / 相机等需在 H5 逐项迁移
</p>
<button type="button" class="primary" @click="$router.push('/pages/index/index')">回首页</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const router = useRouter()
const route = useRoute()
const miniPath = computed(() => (route.meta.miniPath as string) || route.path.replace(/^\//, ''))
const title = computed(() => (route.meta.title as string) || miniPath.value)
function goBack() {
if (typeof window !== 'undefined' && window.history.length > 1) router.back()
else router.push('/pages/index/index')
}
defineOptions({ name: 'PageStub' })
</script>
<style scoped lang="scss">
@function rpx($n) {
@return calc(#{$n} * 100vw / 750);
}
.stub-page {
min-height: 100vh;
background: #f8fafc;
}
.stub-nav {
display: flex;
align-items: center;
gap: rpx(16);
padding: rpx(24) rpx(24) rpx(16);
padding-top: calc(rpx(24) + env(safe-area-inset-top, 0px));
background: #fff;
border-bottom: 1px solid #e2e8f0;
}
.back {
width: rpx(72);
height: rpx(72);
border: none;
border-radius: rpx(36);
background: #f1f5f9;
font-size: rpx(44);
line-height: 1;
cursor: pointer;
}
.stub-title {
margin: 0;
flex: 1;
font-size: rpx(32);
font-weight: 700;
color: #0f172a;
}
.stub-body {
padding: rpx(40);
}
.path code {
font-size: rpx(24);
word-break: break-all;
color: #475569;
}
.tip {
margin-top: rpx(24);
font-size: rpx(28);
color: #64748b;
line-height: 1.6;
}
.primary {
margin-top: rpx(48);
width: 100%;
padding: rpx(28);
border: none;
border-radius: rpx(44);
background: linear-gradient(135deg, #7c3aed, #a78bfa);
color: #fff;
font-size: rpx(30);
font-weight: 600;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,84 @@
<template>
<div class="camera-page tabbar-pad">
<header class="nav">
<h1 class="nav-title">拍摄</h1>
<p class="nav-desc">H5 端相机能力需浏览器权限完整流程请使用微信小程序</p>
</header>
<div class="panel">
<p class="hint">与小程序 <code>pages/index/camera</code> 对齐此处为界面骨架可接入 WebRTC / 上传照片</p>
<button type="button" class="btn" @click="$router.push('/pages/test-select/index')">去选择性格测试</button>
<button type="button" class="btn ghost" @click="$router.push('/pages/index/index')">回首页</button>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'CameraPage' })
</script>
<style scoped lang="scss">
@function rpx($n) {
@return calc(#{$n} * 100vw / 750);
}
.camera-page {
min-height: 100vh;
background: linear-gradient(180deg, #faf9ff, #fff);
padding: rpx(48) rpx(40);
padding-bottom: calc(var(--tabbar-pad) + #{rpx(32)});
}
.nav-title {
margin: 0 0 rpx(16);
font-size: rpx(40);
color: #312e81;
}
.nav-desc {
margin: 0;
font-size: rpx(26);
color: #64748b;
line-height: 1.5;
}
.panel {
margin-top: rpx(48);
padding: rpx(32);
background: rgba(255, 255, 255, 0.9);
border-radius: rpx(24);
border: 1px solid rgba(167, 139, 250, 0.25);
}
.hint {
font-size: rpx(26);
color: #475569;
line-height: 1.6;
}
.btn {
display: block;
width: 100%;
margin-top: rpx(24);
padding: rpx(24);
border: none;
border-radius: rpx(44);
background: linear-gradient(135deg, #7c3aed, #a78bfa);
color: #fff;
font-size: rpx(28);
font-weight: 600;
cursor: pointer;
}
.btn.ghost {
background: #fff;
color: #7c3aed;
border: 2px solid #ddd6fe;
}
code {
font-size: 90%;
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,392 @@
<template>
<div class="container tabbar-pad" :style="{ paddingTop: navbarHeightPx + 'px' }">
<header class="custom-navbar" :style="{ paddingTop: statusBarPx + 'px' }">
<div class="navbar-content">
<button
v-if="showEnterpriseEntry && !reviewEff"
type="button"
class="switch-enterprise-btn"
@click="goEnterprise"
>
<span class="enterprise-icon">🏢</span>
<span class="enterprise-text">企业版</span>
</button>
<div v-else class="navbar-placeholder" />
<h1 class="navbar-title">{{ displayTitle }}</h1>
<div class="navbar-placeholder" />
</div>
</header>
<div class="bg-decoration bg-top-right" aria-hidden="true" />
<div class="bg-decoration bg-bottom-left" aria-hidden="true" />
<section class="top-image-section">
<div class="image-container">
<div class="image-wrapper">
<img class="main-image" :src="heroImg" alt="" @error="onImgErr" />
</div>
<template v-if="!reviewEff && permFace">
<span class="float-tag tag-1">面相分析</span>
<span class="float-tag tag-2">骨相分析</span>
<span class="float-tag tag-3">性格测评</span>
</template>
<template v-else-if="reviewEff">
<span class="float-tag tag-1">MBTI</span>
<span class="float-tag tag-2">DISC</span>
<span class="float-tag tag-3">性格测评</span>
</template>
<template v-else>
<span class="float-tag tag-3">性格测评</span>
</template>
</div>
</section>
<section class="process-section">
<h2 class="section-title">测试流程</h2>
<div v-if="!reviewEff && permFace" class="process-steps">
<div class="step-item">
<div class="step-circle active">1</div>
<span class="step-label">STEP1</span>
<span class="step-text">拍摄照片</span>
</div>
<div class="step-line" />
<div class="step-item">
<div class="step-circle">2</div>
<span class="step-label">STEP2</span>
<span class="step-text">{{ aiAnalysisText }}</span>
</div>
<div class="step-line" />
<div class="step-item">
<div class="step-circle">3</div>
<span class="step-label">STEP3</span>
<span class="step-text">生成报告</span>
</div>
</div>
<div v-else class="process-steps">
<div class="step-item">
<div class="step-circle active">1</div>
<span class="step-label">STEP1</span>
<span class="step-text">选择测试</span>
</div>
<div class="step-line" />
<div class="step-item">
<div class="step-circle">2</div>
<span class="step-label">STEP2</span>
<span class="step-text">回答问题</span>
</div>
<div class="step-line" />
<div class="step-item">
<div class="step-circle">3</div>
<span class="step-label">STEP3</span>
<span class="step-text">查看结果</span>
</div>
</div>
</section>
<button type="button" class="start-button" @click="startCamera">
<span class="button-text">{{ startButtonText }}</span>
</button>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
const router = useRouter()
const store = useAppStore()
const statusBarPx = ref(0)
const navbarHeightPx = ref(88)
const showEnterpriseEntry = ref(false)
const heroBroken = ref(false)
const heroImg = computed(() =>
heroBroken.value
? 'data:image/svg+xml,' +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400"><rect fill="#f0f4ff" width="400" height="400"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="#94a3b8" font-size="18">MBTI</text></svg>`
)
: '/images/mbti-team-image.png'
)
const reviewEff = computed(() => store.reviewEff)
const permFace = computed(() => {
const ep = store.enterprisePermissions
return !ep || ep.face !== false
})
const displayTitle = computed(() => {
const t = store.siteTitle || '神仙团队性格测试'
return reviewEff.value ? t.replace(/AI/gi, '') : t
})
const startButtonText = computed(() => {
if (reviewEff.value) return '开始性格测试'
return store.textConfig?.startButtonText || '30秒测出你的性格'
})
const aiAnalysisText = computed(() => {
if (reviewEff.value) return '分析'
return store.textConfig?.aiAnalysisText || '分析'
})
function onImgErr() {
heroBroken.value = true
}
function startCamera() {
router.push('/pages/index/camera')
}
function goEnterprise() {
router.push('/pages/enterprise/index')
}
onMounted(() => {
if (typeof window !== 'undefined') {
statusBarPx.value = 0
navbarHeightPx.value = 44 + statusBarPx.value
}
store.fetchRuntime().catch(() => {})
})
defineOptions({ name: 'IndexHome' })
</script>
<style scoped lang="scss">
@function rpx($n) {
@return calc(#{$n} * 100vw / 750);
}
.container {
min-height: 100vh;
width: 100vw;
overflow-x: hidden;
background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 50%, #ffffff 100%);
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.tabbar-pad {
padding-bottom: calc(var(--tabbar-pad) + #{rpx(32)}) !important;
}
.bg-decoration {
position: absolute;
width: rpx(600);
height: rpx(600);
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
.bg-top-right {
top: rpx(-200);
right: rpx(-200);
background: radial-gradient(circle, rgba(139, 92, 246, 0.08) 0%, transparent 70%);
}
.bg-bottom-left {
bottom: 0;
left: 0;
background: radial-gradient(circle, rgba(99, 102, 241, 0.06) 0%, transparent 70%);
}
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
background: #fff;
z-index: 10000;
}
.navbar-content {
height: rpx(88);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 rpx(40);
position: relative;
}
.navbar-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
margin: 0;
font-size: rpx(36);
font-weight: 700;
color: #e63946;
}
.navbar-placeholder {
width: rpx(140);
flex-shrink: 0;
}
.switch-enterprise-btn {
display: flex;
align-items: center;
gap: rpx(8);
padding: rpx(12) rpx(20);
background: rgba(230, 57, 70, 0.1);
border-radius: rpx(30);
border: 1px solid rgba(230, 57, 70, 0.2);
cursor: pointer;
z-index: 10;
}
.enterprise-text {
font-size: rpx(24);
color: #e63946;
font-weight: 600;
}
.top-image-section {
width: 100%;
padding: rpx(8) rpx(40) rpx(10);
position: relative;
z-index: 1;
margin-top: rpx(-20);
}
.image-container {
position: relative;
width: 100%;
transform: translateY(rpx(-26));
margin-bottom: rpx(-26);
}
.image-wrapper {
position: relative;
width: 100%;
padding-top: 100%;
border-radius: 50%;
overflow: hidden;
background: #fff;
box-shadow: 0 rpx(8) rpx(32) rgba(0, 0, 0, 0.12);
}
.main-image {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 85%;
height: 85%;
object-fit: contain;
}
.float-tag {
position: absolute;
padding: rpx(12) rpx(24);
border-radius: rpx(30);
font-size: rpx(24);
font-weight: 600;
color: #e63946;
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 rpx(4) rpx(16) rgba(230, 57, 70, 0.2);
white-space: nowrap;
z-index: 10;
}
.tag-1 {
top: 15%;
right: rpx(10);
}
.tag-2 {
bottom: 25%;
left: 0;
}
.tag-3 {
bottom: 25%;
right: 0;
}
.process-section {
padding: rpx(8) rpx(40) rpx(20);
position: relative;
z-index: 1;
}
.section-title {
font-size: rpx(32);
font-weight: 700;
color: #e63946;
text-align: center;
margin: 0 0 rpx(25);
}
.process-steps {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 rpx(10);
}
.step-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.step-circle {
width: rpx(56);
height: rpx(56);
border-radius: 50%;
background: #e5e7eb;
color: #9ca3af;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: rpx(26);
}
.step-circle.active {
background: linear-gradient(135deg, #e63946, #ff8e53);
color: #fff;
}
.step-label {
font-size: rpx(20);
color: #9ca3af;
margin-top: rpx(8);
}
.step-text {
font-size: rpx(22);
color: #374151;
margin-top: rpx(4);
}
.step-line {
flex: 0 0 rpx(40);
height: rpx(4);
background: #e5e7eb;
align-self: flex-start;
margin-top: rpx(26);
}
.start-button {
margin: rpx(16) rpx(48) rpx(48);
height: rpx(96);
border: none;
border-radius: rpx(48);
background: linear-gradient(135deg, #e63946 0%, #ff8e53 100%);
color: #fff;
font-size: rpx(32);
font-weight: 700;
cursor: pointer;
box-shadow: 0 rpx(12) rpx(32) rgba(230, 57, 70, 0.35);
}
.button-text {
display: block;
}
</style>

View File

@@ -0,0 +1,289 @@
<template>
<div class="profile-page tabbar-pad">
<header class="topbar">
<span class="topbar-title">我的</span>
</header>
<button type="button" class="user-card" @click="onCard">
<div class="avatar-wrap">
<div class="avatar-ring">
<img
v-if="hasLogin && avatarUrl"
class="avatar-img"
:src="avatarUrl"
alt=""
/>
<div v-else class="avatar-letter-wrap" :style="{ background: avatarBg }">
<span class="avatar-letter">{{ avatarLetter }}</span>
</div>
</div>
<span v-if="hasLogin" class="online-dot" />
</div>
<div class="user-meta">
<template v-if="hasLogin">
<span class="nickname-text">{{ nickname || '点击设置昵称' }}</span>
<div class="tags-row">
<span v-if="mbtiType" class="tag tag-purple">{{ mbtiType }}</span>
<span v-else class="tag tag-gray">暂无测试记录</span>
</div>
</template>
<template v-else>
<span class="user-name">点击登录</span>
<span class="user-sub">登录后查看你的测试结果</span>
</template>
</div>
<span class="chevron"></span>
</button>
<div v-if="hasLogin" class="user-stats">
<button type="button" class="user-stat" @click="goHistory">
<span class="user-stat__v">{{ testCount }}</span>
<span class="user-stat__l">测评记录</span>
</button>
</div>
<section class="menu-section">
<button type="button" class="menu-row" @click="$router.push('/pages/test-select/index')">
<span>详细性格测试</span>
<span class="chevron"></span>
</button>
<button type="button" class="menu-row" @click="$router.push('/pages/order/index')">
<span>我的订单</span>
<span class="chevron"></span>
</button>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
const router = useRouter()
const store = useAppStore()
const hasLogin = computed(() => !!store.token)
const avatarUrl = ref('')
const nickname = ref('')
const mbtiType = ref('')
const testCount = ref(0)
const avatarLetter = computed(() => {
const n = nickname.value || '访'
return n.slice(0, 1).toUpperCase()
})
const avatarBg = '#7c3aed'
function onCard() {
if (!hasLogin.value) {
router.push('/pages/phone-auth/index')
return
}
router.push('/pages/user-profile/index')
}
function goHistory() {
router.push('/pages/history/index')
}
defineOptions({ name: 'ProfilePage' })
</script>
<style scoped lang="scss">
@function rpx($n) {
@return calc(#{$n} * 100vw / 750);
}
.profile-page {
min-height: 100vh;
background: linear-gradient(180deg, #faf9ff 0%, #f4f3f9 40%, #ffffff 100%);
padding-bottom: calc(var(--tabbar-pad) + #{rpx(24)});
}
.topbar {
padding: rpx(24) rpx(40) rpx(16);
display: flex;
align-items: center;
justify-content: center;
}
.topbar-title {
font-size: rpx(36);
font-weight: 700;
color: #1e1b4b;
}
.user-card {
margin: 0 rpx(32) rpx(24);
padding: rpx(36) rpx(28);
display: flex;
flex-direction: row;
align-items: center;
gap: rpx(24);
border: none;
border-radius: rpx(28);
background: linear-gradient(135deg, #ffffff 0%, #faf8ff 100%);
box-shadow: 0 rpx(12) rpx(40) rgba(124, 58, 237, 0.12);
text-align: left;
cursor: pointer;
}
.avatar-wrap {
position: relative;
flex-shrink: 0;
}
.avatar-ring {
width: rpx(120);
height: rpx(120);
border-radius: 50%;
overflow: hidden;
border: rpx(4) solid rgba(167, 139, 250, 0.35);
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-letter-wrap {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-letter {
font-size: rpx(48);
font-weight: 700;
color: #fff;
}
.online-dot {
position: absolute;
bottom: rpx(4);
right: rpx(4);
width: rpx(20);
height: rpx(20);
border-radius: 50%;
background: #22c55e;
border: rpx(4) solid #fff;
}
.user-meta {
flex: 1;
min-width: 0;
}
.nickname-text {
display: block;
font-size: rpx(34);
font-weight: 700;
color: #1e1b4b;
margin-bottom: rpx(12);
}
.user-name {
display: block;
font-size: rpx(32);
font-weight: 600;
color: #4338ca;
}
.user-sub {
display: block;
font-size: rpx(24);
color: #64748b;
margin-top: rpx(8);
}
.tags-row {
display: flex;
flex-wrap: wrap;
gap: rpx(12);
}
.tag {
padding: rpx(8) rpx(20);
border-radius: rpx(20);
font-size: rpx(22);
font-weight: 600;
}
.tag-purple {
background: #ede9fe;
color: #5b21b6;
}
.tag-gray {
background: #f1f5f9;
color: #64748b;
}
.chevron {
font-size: rpx(44);
color: #cbd5e1;
flex-shrink: 0;
}
.user-stats {
display: flex;
margin: 0 rpx(32) rpx(32);
padding: rpx(28);
background: #fff;
border-radius: rpx(24);
box-shadow: 0 rpx(8) rpx(24) rgba(15, 23, 42, 0.06);
}
.user-stat {
flex: 1;
border: none;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
gap: rpx(8);
cursor: pointer;
}
.user-stat__v {
font-size: rpx(40);
font-weight: 800;
color: #7c3aed;
}
.user-stat__l {
font-size: rpx(24);
color: #64748b;
}
.menu-section {
margin: 0 rpx(32);
background: #fff;
border-radius: rpx(24);
overflow: hidden;
box-shadow: 0 rpx(8) rpx(24) rgba(15, 23, 42, 0.06);
}
.menu-row {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: rpx(32) rpx(28);
border: none;
border-bottom: 1px solid #f1f5f9;
background: #fff;
font-size: rpx(30);
color: #334155;
cursor: pointer;
}
.menu-row:last-child {
border-bottom: none;
}
</style>

View File

@@ -0,0 +1,599 @@
<template>
<div class="test-page">
<div v-if="loading" class="test-loading">
<span class="test-loading-text">加载题目</span>
</div>
<div v-else-if="loadError" class="test-error-state">
<div class="test-error-ic"></div>
<span class="test-error-title">加载失败</span>
<span class="test-error-msg">{{ loadErrorMsg || '网络异常,请检查后重试' }}</span>
<button type="button" class="test-error-btn" @click="retryLoad">重新加载</button>
<span class="test-error-hint">若持续失败请检查浏览器是否可访问后台 apiBase</span>
</div>
<template v-else-if="currentQuestion">
<div v-if="usingLocalFallback" class="fallback-banner">
<span class="fallback-banner-ic">📶</span>
<span class="fallback-banner-text">
当前使用本地题库网络异常自动切换结果仍会在本机完成
</span>
</div>
<div class="progress-section">
<div class="progress-info">
<span class="question-count">问题 {{ currentIndex + 1 }}/{{ total }}</span>
<span class="time-remaining">剩余时间: {{ formatTime }}</span>
</div>
<div class="progress-bar-container">
<div class="progress-bar" :style="{ width: progress + '%' }" />
</div>
</div>
<div class="content-area">
<div class="question-card">
<span class="question-text">{{ currentQuestion.question }}</span>
<div class="options-container">
<button
v-for="(option, optIdx) in currentQuestion.options"
:key="optIdx"
type="button"
class="option-item"
:class="{ selected: answers[String(currentQuestion.id)] === option.value }"
@click="selectAnswer(option.value)"
>
<span
class="radio-button"
:class="{ checked: answers[String(currentQuestion.id)] === option.value }"
>
<span
v-if="answers[String(currentQuestion.id)] === option.value"
class="radio-inner"
/>
</span>
<span class="option-text">{{ option.text }}</span>
</button>
</div>
</div>
<div v-if="currentIndex === total - 1" class="last-hint">
<span class="last-hint-text">
最后一题选择后约 0.3 秒自动跳转结果页若未跳转请点右下角查看结果
</span>
</div>
</div>
<div class="footer-buttons">
<button
type="button"
class="nav-button secondary"
:class="{ disabled: currentIndex === 0 }"
:disabled="currentIndex === 0"
@click="prevQuestion"
>
<span class="button-text">上一题</span>
</button>
<button
v-if="currentIndex < total - 1"
type="button"
class="nav-button secondary"
@click="nextQuestion"
>
<span class="button-text">跳过</span>
</button>
<button
v-else
type="button"
class="nav-button primary"
:class="{ disabled: isSubmitting }"
:disabled="isSubmitting"
@click="finishTest"
>
<span class="button-text button-text-on-primary">
{{ isSubmitting ? '正在生成…' : '查看结果' }}
</span>
</button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onUnmounted } from 'vue'
import { loadQuestions, type TestQuestion } from '@/utils/questionBank'
import { calculateMbtiResult } from '@/utils/mbtiCalc'
import { saveTestResult } from '@/utils/testSubmit'
import { afterTestSubmitNavigate } from '@/utils/navigateAfterTest'
import { showToast } from '@/utils/toast'
const MBTI_TIME_SEC = 30 * 60
const loading = ref(true)
const loadError = ref(false)
const loadErrorMsg = ref('')
const usingLocalFallback = ref(false)
const questions = ref<TestQuestion[]>([])
const currentIndex = ref(0)
const currentQuestion = ref<TestQuestion | null>(null)
const answers = ref<Record<string, string>>({})
const total = ref(0)
const progress = ref(0)
const timeRemaining = ref(MBTI_TIME_SEC)
const initialSeconds = ref(MBTI_TIME_SEC)
const formatTime = ref('30:00')
const isSubmitting = ref(false)
let timer: ReturnType<typeof setInterval> | null = null
let advanceTimer: ReturnType<typeof setTimeout> | null = null
function stopTimer() {
if (timer) {
clearInterval(timer)
timer = null
}
}
function startTimer() {
stopTimer()
timer = setInterval(() => {
let time = timeRemaining.value - 1
if (time <= 0) {
stopTimer()
submitTest({ allowIncomplete: true })
return
}
const minutes = Math.floor(time / 60)
const seconds = time % 60
timeRemaining.value = time
formatTime.value = `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
}, 1000)
}
function retryLoad() {
startLoadQuestions()
}
function startLoadQuestions() {
loading.value = true
loadError.value = false
loadErrorMsg.value = ''
usingLocalFallback.value = false
loadQuestions('mbti', { allowLocalFallback: true })
.then((list) => {
if (!list.length) {
loading.value = false
loadError.value = true
loadErrorMsg.value = '暂无题目,请稍后再试'
return
}
const tot = list.length
const isLocal =
!!(list[0] && typeof list[0].id === 'string' && String(list[0].id).indexOf('L-') === 0)
questions.value = list
currentQuestion.value = list[0]
currentIndex.value = 0
answers.value = {}
total.value = tot
progress.value = Math.round((1 / tot) * 100)
timeRemaining.value = MBTI_TIME_SEC
initialSeconds.value = MBTI_TIME_SEC
formatTime.value = '30:00'
loading.value = false
loadError.value = false
usingLocalFallback.value = isLocal
startTimer()
})
.catch((err: Error) => {
loading.value = false
loadError.value = true
loadErrorMsg.value = err.message || '加载失败,请检查网络后重试'
})
}
startLoadQuestions()
function selectAnswer(value: string | undefined) {
const cq = currentQuestion.value
if (value == null || !cq || cq.id == null) return
if (advanceTimer) {
clearTimeout(advanceTimer)
advanceTimer = null
}
const questionId = String(cq.id)
const idx = currentIndex.value
const tot = total.value
const nextAnswers = { ...answers.value, [questionId]: value }
answers.value = nextAnswers
const answeredCount = Object.keys(nextAnswers).length
progress.value = tot ? Math.round(((idx + 1) / tot) * 100) : 0
advanceTimer = setTimeout(() => {
advanceTimer = null
if (
currentIndex.value !== idx ||
!currentQuestion.value ||
String(currentQuestion.value.id) !== String(cq.id)
)
return
if (idx < tot - 1) {
nextQuestion()
} else {
submitTest()
}
}, 320)
}
function prevQuestion() {
if (advanceTimer) {
clearTimeout(advanceTimer)
advanceTimer = null
}
if (currentIndex.value > 0) {
const newIndex = currentIndex.value - 1
const newQuestion = questions.value[newIndex]
const tot = total.value
currentIndex.value = newIndex
currentQuestion.value = newQuestion
progress.value = tot ? Math.round(((newIndex + 1) / tot) * 100) : 0
}
}
function nextQuestion() {
if (currentIndex.value < total.value - 1) {
const newIndex = currentIndex.value + 1
const newQuestion = questions.value[newIndex]
const tot = total.value
currentIndex.value = newIndex
currentQuestion.value = newQuestion
progress.value = tot ? Math.round(((newIndex + 1) / tot) * 100) : 0
}
}
function finishTest() {
const q = currentQuestion.value
if (!q) return
const qid = String(q.id)
if (answers.value[qid] == null) {
showToast('请先选择一项')
return
}
const tot = total.value
if (Object.keys(answers.value).length < tot) {
showToast('还有题目未作答,请返回补答')
return
}
submitTest()
}
function submitTest(opt: { allowIncomplete?: boolean } = {}) {
if (isSubmitting.value) return
const allowIncomplete = !!opt.allowIncomplete
stopTimer()
const tot = total.value
const n = Object.keys(answers.value).length
if (!allowIncomplete && n < tot) {
showToast(`还有 ${tot - n} 题未作答`)
startTimer()
return
}
isSubmitting.value = true
let result
try {
result = calculateMbtiResult(answers.value)
} catch {
showToast('计算结果失败,请重试')
isSubmitting.value = false
startTimer()
return
}
const resultData = {
...result,
answers: answers.value,
testDuration: initialSeconds.value - timeRemaining.value,
completedAt: new Date().toISOString(),
timestamp: new Date().toISOString(),
}
try {
localStorage.setItem('mbtiResult', JSON.stringify(resultData))
} catch {
/* ignore */
}
saveTestResult('mbti', resultData as Record<string, unknown>).then((extra) => {
const rid = extra && extra.id
const target = rid
? `/pages/result/mbti?id=${encodeURIComponent(String(rid))}&type=mbti`
: '/pages/result/mbti'
afterTestSubmitNavigate(target)
isSubmitting.value = false
})
}
onUnmounted(() => {
if (advanceTimer) clearTimeout(advanceTimer)
stopTimer()
})
</script>
<style scoped lang="scss">
@use '@/styles/global.scss' as *;
.test-page {
width: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: #fff;
}
.test-loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: rpx(80);
}
.test-loading-text {
font-size: rpx(30);
color: #666;
}
.test-error-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: rpx(80) rpx(60);
text-align: center;
}
.test-error-ic {
font-size: rpx(84);
line-height: 1;
margin-bottom: rpx(20);
}
.test-error-title {
font-size: rpx(36);
font-weight: 700;
color: #111827;
margin-bottom: rpx(12);
}
.test-error-msg {
font-size: rpx(26);
color: #6b7280;
line-height: 1.6;
margin-bottom: rpx(40);
max-width: rpx(520);
}
.test-error-btn {
padding: rpx(20) rpx(56);
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: #fff;
font-size: rpx(30);
font-weight: 600;
border-radius: rpx(48);
border: none;
box-shadow: 0 rpx(10) rpx(24) rgba(99, 102, 241, 0.3);
margin-bottom: rpx(28);
cursor: pointer;
}
.test-error-hint {
font-size: rpx(22);
color: #9ca3af;
}
.fallback-banner {
margin: rpx(16) rpx(24) 0;
padding: rpx(14) rpx(20);
background: #fef3c7;
border: rpx(1) solid #fcd34d;
border-radius: rpx(12);
display: flex;
align-items: center;
gap: rpx(10);
}
.fallback-banner-ic {
font-size: rpx(24);
}
.fallback-banner-text {
font-size: rpx(22);
color: #92400e;
line-height: 1.4;
flex: 1;
text-align: left;
}
.progress-section {
padding: rpx(32);
border-bottom: rpx(1) solid #e5e5e5;
flex-shrink: 0;
}
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: rpx(16);
}
.question-count {
font-size: rpx(28);
font-weight: 500;
color: #333;
}
.time-remaining {
font-size: rpx(28);
color: #999;
}
.progress-bar-container {
width: 100%;
height: rpx(8);
background-color: #e5e5e5;
border-radius: rpx(8);
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(135deg, #ff6b8a 0%, #ff8fa3 100%);
border-radius: rpx(8);
transition: width 0.3s ease;
}
.content-area {
flex: 1;
overflow-y: auto;
padding: rpx(32);
}
.question-card {
background-color: #fff;
border-radius: rpx(24);
padding: rpx(48);
box-shadow: 0 rpx(4) rpx(12) rgba(0, 0, 0, 0.08);
}
.question-text {
display: block;
font-size: rpx(40);
font-weight: 500;
color: #333;
line-height: 1.6;
margin-bottom: rpx(48);
}
.options-container {
display: flex;
flex-direction: column;
gap: rpx(32);
}
.option-item {
display: flex;
align-items: center;
padding: rpx(32);
border: rpx(2) solid #e5e5e5;
border-radius: rpx(16);
transition: all 0.3s ease;
background: #fff;
cursor: pointer;
text-align: left;
width: 100%;
&.selected {
background-color: rgba(255, 107, 138, 0.12);
border-color: #ff6b8a;
}
}
.radio-button {
width: rpx(40);
height: rpx(40);
border-radius: 50%;
border: rpx(2) solid #d1d5db;
display: flex;
align-items: center;
justify-content: center;
margin-right: rpx(24);
flex-shrink: 0;
transition: all 0.3s ease;
&.checked {
background-color: #ff6b8a;
border-color: #ff6b8a;
}
}
.radio-inner {
width: rpx(16);
height: rpx(16);
border-radius: 50%;
background-color: #fff;
}
.option-text {
flex: 1;
font-size: rpx(32);
color: #333;
line-height: 1.5;
}
.footer-buttons {
display: flex;
gap: rpx(24);
padding: rpx(32);
padding-bottom: calc(rpx(32) + env(safe-area-inset-bottom, 0px));
border-top: rpx(1) solid #e5e5e5;
flex-shrink: 0;
}
.nav-button {
flex: 1;
padding: rpx(28);
border-radius: rpx(16);
text-align: center;
cursor: pointer;
border: none;
font: inherit;
&.secondary {
background-color: #fff;
border: rpx(2) solid #ff6b8a;
.button-text {
color: #ff6b8a;
}
}
&.primary {
background: linear-gradient(135deg, #ff6b8a 0%, #ff8fa3 100%);
border: none;
box-shadow: 0 rpx(8) rpx(24) rgba(255, 107, 138, 0.35);
}
&.disabled {
opacity: 0.4;
pointer-events: none;
}
}
.button-text-on-primary {
color: #ffffff !important;
font-weight: 600;
}
.last-hint {
margin-top: rpx(24);
padding: rpx(20) rpx(24);
background: rgba(255, 107, 138, 0.08);
border-radius: rpx(16);
border: rpx(1) solid rgba(255, 107, 138, 0.2);
}
.last-hint-text {
font-size: rpx(26);
color: #be185d;
line-height: 1.5;
}
.button-text {
font-size: rpx(32);
font-weight: 500;
}
</style>

22
h5-vue/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"paths": { "@/*": ["./src/*"] },
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

10
h5-vue/tsconfig.node.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler"
},
"include": ["vite.config.ts"]
}

21
h5-vue/vite.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 5174,
proxy: {
'/api': {
target: process.env.VITE_PROXY_TARGET || 'http://127.0.0.1',
changeOrigin: true,
},
},
},
})

View File

@@ -2,6 +2,17 @@
const { request } = require('./utils/request.js')
const { isAuditHideAiMode } = require('./utils/miniprogramAuditGate.js')
function normalizeAvailableVersions(input, pricingType) {
const list = Array.isArray(input) ? input : []
const allowed = ['personal', 'enterprise', 'gaokao']
const normalized = list
.map((item) => String(item || '').toLowerCase())
.filter((item, idx, arr) => allowed.includes(item) && arr.indexOf(item) === idx)
if (normalized.length > 0) return normalized
if (pricingType && allowed.includes(String(pricingType))) return [String(pricingType)]
return ['personal']
}
// 全局注入:所有页面 onShow 自动上报 page_viewonShareAppMessage 自动上报 share
const _OrigPage = Page
Page = function (pageConfig) {
@@ -50,6 +61,7 @@ App({
// GET /api/config/runtime 内嵌的深度服务类目(与 /api/config/deep-pricing 同源)
deepPricingPersonal: null,
deepPricingEnterprise: null,
availableVersions: ['personal'],
// 当前使用范围personal 个人版 / enterprise 企业版(影响定价与 enterpriseId 写入)
appScope: 'personal',
// 扫码进入企业页时 scene 解析出的企业IDe_123提交测试/分析时优先使用
@@ -58,8 +70,8 @@ App({
defaultEnterpriseId: null,
// API 基础地址:默认走线上;本机/内网调试可在开发者工具执行
// wx.setStorageSync('apiBaseOverride', 'https://你的调试域名') 后重启小程序
//apiBase: 'https://mbtiapi.quwanzhi.com',
apiBase: 'http://mbti.com',
apiBase: 'https://mbtiapi.quwanzhi.com',
//apiBase: 'http://mbti.com',
// VIP信息
vipInfo: null,
// 测试次数
@@ -613,7 +625,8 @@ App({
userId: this.globalData.userInfo?.id ?? this.globalData.openId,
enterpriseId: enterpriseId != null ? enterpriseId : undefined,
testDuration: result.testDuration || 0,
timestamp: new Date().toISOString()
timestamp: new Date().toISOString(),
...(this.globalData.appScope === 'gaokao' ? { pricingScope: 'gaokao' } : {})
},
success: (res) => {
if (res.statusCode === 200 && res.data && res.data.code === 200 && res.data.data && typeof res.data.data === 'object') {
@@ -713,6 +726,7 @@ App({
} else {
this.globalData.deepPricingEnterprise = null
}
this.globalData.availableVersions = normalizeAvailableVersions(data.availableVersions, data.pricingType)
resolve(data)
} else {
reject(new Error(res.data && res.data.message ? res.data.message : '获取配置失败'))

View File

@@ -29,6 +29,7 @@
"pages/promo/withdrawals",
"pages/match-job/index",
"pages/gaokao/index",
"pages/gaokao/task-center",
"pages/gaokao/form",
"pages/gaokao/report",
"pages/ai-chat/index",
@@ -55,6 +56,12 @@
"iconPath": "images/camera.png",
"selectedIconPath": "images/camera-active.png"
},
{
"pagePath": "pages/ai-chat/index",
"text": "神仙AI",
"iconPath": "images/home.png",
"selectedIconPath": "images/home-active.png"
},
{
"pagePath": "pages/profile/index",
"text": "我的",

View File

@@ -11,66 +11,6 @@ const DEFAULT_ITEMS = [
{ pagePath: 'pages/profile/index', text: '我', iconKey: 'profile', iconUrl: '', highlight: false }
]
const AI_PATH = 'pages/ai-chat/index'
/** 与 iconKey 为 ai 时的图标 URL 归一(与 refreshFromConfig 内一致) */
function mapTabIconUrls(rawItems) {
return rawItems.map((it) => {
if (it.iconKey === 'ai') {
const u = (it.iconUrl && String(it.iconUrl).trim()) || ''
const raster = /\.(png|jpg|jpeg|webp)$/i.test(u)
if (raster) {
return {
...it,
iconUrl: u,
iconUrlActive: (it.iconUrlActive && String(it.iconUrlActive).trim()) || '/images/tab-ai-active.png'
}
}
return { ...it, iconUrl: '', iconUrlActive: '' }
}
return { ...it, iconUrl: it.iconUrl || '' }
})
}
/**
* 提审/面相审核:去掉神仙 AI非审核且列表无神仙 AI 时补回(与 app.json 仅 3 Tab 时 navigateTo 兜底配合)
*/
function applyAuditFilterAndMaybeInjectAi(items, gd) {
const hideAiTab = isAuditHideAiMode(gd)
const hideMiddleFab = shouldHideTabBarHighlightFab(gd)
let effectiveItems = items.filter((it) => {
const path = (it.pagePath || '').replace(/^\//, '')
if (hideAiTab && (it.iconKey === 'ai' || /ai-chat/.test(path))) return false
if (hideMiddleFab && it.highlight) return false
return true
})
const hasAiItem = effectiveItems.some((it) => {
const p = (it.pagePath || '').replace(/^\//, '')
return p === AI_PATH || it.iconKey === 'ai' || /ai-chat/.test(p)
})
if (!hideAiTab && !hasAiItem) {
const next = effectiveItems.slice()
const insertAt = Math.max(0, next.length - 1)
next.splice(insertAt, 0, {
pagePath: AI_PATH,
text: '神仙AI',
iconKey: 'ai',
iconUrl: '',
iconUrlActive: '',
highlight: false,
})
effectiveItems = next
}
return {
effectiveItems,
reviewMode: !!(gd.reviewMode || gd.maintenanceMode),
hideMiddleFab,
}
}
Component({
data: {
selected: 0,
@@ -93,14 +33,38 @@ Component({
methods: {
refreshFromConfig() {
const app = getApp() || {}
const gd = app.globalData || {}
try {
const app = getApp() || {}
const gd = app.globalData || {}
const cfg = gd.tabBar && Array.isArray(gd.tabBar.items) ? gd.tabBar.items : null
const rawItems = (cfg && cfg.length >= 2) ? cfg.slice() : DEFAULT_ITEMS.slice()
const items = mapTabIconUrls(rawItems)
const { effectiveItems, reviewMode, hideMiddleFab } = applyAuditFilterAndMaybeInjectAi(items, gd)
const items = rawItems.map((it) => {
if (it.iconKey === 'ai') {
const u = (it.iconUrl && String(it.iconUrl).trim()) || ''
const raster = /\.(png|jpg|jpeg|webp)$/i.test(u)
if (raster) {
return {
...it,
iconUrl: u,
iconUrlActive: (it.iconUrlActive && String(it.iconUrlActive).trim()) || '/images/tab-ai-active.png'
}
}
return { ...it, iconUrl: '', iconUrlActive: '' }
}
return { ...it, iconUrl: it.iconUrl || '' }
})
const reviewMode = !!(gd.reviewMode || gd.maintenanceMode)
const hideAiTab = isAuditHideAiMode(gd)
const hideMiddleFab = shouldHideTabBarHighlightFab(gd)
// 审核模式:隐藏神仙 AI审核模式 / face 关闭时:隐藏所有 highlight=true 的 Tab
const effectiveItems = items.filter((it) => {
const path = (it.pagePath || '').replace(/^\//, '')
if (hideAiTab && (it.iconKey === 'ai' || /ai-chat/.test(path))) return false
if (hideMiddleFab && it.highlight) return false
return true
})
this.setData({
items: effectiveItems,
@@ -108,17 +72,7 @@ Component({
hideMiddleFab,
}, () => this.updateSelected())
} catch (e) {
try {
const items = mapTabIconUrls(DEFAULT_ITEMS.slice())
const { effectiveItems, reviewMode, hideMiddleFab } = applyAuditFilterAndMaybeInjectAi(items, gd)
this.setData({
items: effectiveItems,
reviewMode,
hideMiddleFab,
}, () => this.updateSelected())
} catch (e2) {
this.setData({ items: [], reviewMode: false, hideMiddleFab: false }, () => this.updateSelected())
}
this.setData({ items: DEFAULT_ITEMS, reviewMode: false, hideMiddleFab: false }, () => this.updateSelected())
}
},
@@ -166,12 +120,18 @@ Component({
if (it.pagePath === 'pages/index/index' || it.iconKey === 'home') {
try {
const app = getApp()
const scope = (app && app.globalData && app.globalData.appScope) || 'personal'
const gd = (app && app.globalData) || {}
const scope = gd.selectedHomeVersion || gd.appScope || 'personal'
if (scope === 'enterprise') {
wx.navigateTo({ url: '/pages/enterprise/index' })
this.setData({ selected: index })
return
}
if (scope === 'gaokao') {
wx.navigateTo({ url: '/pages/gaokao/index' })
this.setData({ selected: index })
return
}
} catch (e) {}
}

View File

@@ -3,6 +3,10 @@ const { request } = require('../../utils/request.js')
const analytics = require('../../utils/analytics.js')
const { buildRecoMiniProgramPath } = require('../../utils/recoMiniProgram.js')
const aiChatIntent = require('../../utils/aiChatIntent.js')
const { isAuditHideAiMode, redirectIfMiniprogramAudit } = require('../../utils/miniprogramAuditGate.js')
const { buildDeepHeroDesc, filterCategories } = require('../../utils/deepPricingFilter.js')
const payment = require('../../utils/payment')
const { hasPhone, bindPhoneByCode } = require('../../utils/phoneAuth.js')
/** 功能类推荐卡「阅读数」按发布日起算时的锚点日期 */
const FEATURE_RECO_PUBLISHED_AT = '2026-01-01'
@@ -12,6 +16,45 @@ const REPORT_CTA_MIN_USER_TURNS = 10
/** 报告条仅展示一次(点过或离开带条页面后不再出现) */
const STORAGE_REPORT_CTA_DISMISSED = 'ai_chat_deep_report_cta_dismissed'
function embeddedDeepCategories(scope) {
const app = getApp()
const gd = (app && app.globalData) || {}
const list = scope === 'enterprise' ? gd.deepPricingEnterprise : gd.deepPricingPersonal
return Array.isArray(list) && list.length ? list : null
}
function rememberCategoryKeys(cat, seen) {
const id = String((cat && cat.id) || '')
const pk = String((cat && cat.productKey) || '')
if (id) seen.add('id:' + id)
if (pk) seen.add('pk:' + pk)
}
function categorySeenInSet(cat, seen) {
const id = String((cat && cat.id) || '')
const pk = String((cat && cat.productKey) || '')
if (id && seen.has('id:' + id)) return true
if (pk && seen.has('pk:' + pk)) return true
return false
}
function mergeDeepPricingFromRuntime(scope, apiList) {
let list = Array.isArray(apiList) ? apiList.slice() : []
const emb = embeddedDeepCategories(scope)
if (!emb || !emb.length) return list
if (!list.length) return emb.slice()
if (list.length === 1 && emb.length > list.length) return emb.slice()
const seen = new Set()
list.forEach((c) => rememberCategoryKeys(c, seen))
emb.forEach((c) => {
if (!categorySeenInSet(c, seen)) {
list.push(c)
rememberCategoryKeys(c, seen)
}
})
return list
}
// 兜底封面base64 SVG紫色渐变 + sparkle
const DEFAULT_COVER = 'data:image/svg+xml;utf8,' + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 440 220">' +
@@ -63,12 +106,33 @@ Page({
inlineRecoInterval: 3,
inlineRecoRoll: 0.5,
inlineRecoIconCount: 3,
inlineRecoIconsPool: ['✨', '💬', '📌']
inlineRecoIconsPool: ['✨', '💬', '📌'],
/** 聊天区固定高度px与底部固定 composer 对齐,避免 scroll-view 被内容撑开压到输入条下 */
chatScrollHeightPx: 500,
auditPurchaseMode: false,
auditPurchaseLoading: false,
auditPurchaseError: '',
auditPurchaseTab: 'personal',
auditPurchasePersonal: [],
auditPurchaseEnterprise: [],
auditDeepHeroDesc: '',
auditHasPhone: false,
auditPurchasing: false,
auditSuccessModal: {
visible: false,
title: '',
content: ''
}
},
onLoad(options) {
const { ensureRuntimeThenGate } = require('../../utils/miniprogramAuditGate.js')
ensureRuntimeThenGate(() => {
this._fallbackChatScrollHeight()
const app = getApp()
const boot = () => {
if (isAuditHideAiMode(app && app.globalData)) {
redirectIfMiniprogramAudit('功能升级中')
return
}
const cid = parseInt(options && options.cid, 10)
if (cid > 0) {
this.setData({ conversationId: cid })
@@ -97,7 +161,12 @@ Page({
} else {
pullRuntime()
}
})
}
if (app && typeof app.getRuntimeConfig === 'function') {
app.getRuntimeConfig().then(boot).catch(boot)
} else {
boot()
}
},
/** 分享链接里带 inviterId 时,绑定分销关系(复用现有分销系统) */
@@ -143,20 +212,21 @@ Page({
tryUrl('/api/ai/my-report/latest', () => tryUrl('/api/ai/report/my-latest'))
},
onReady() {
this._measureChatScrollHeight()
},
onShow() {
const { redirectIfMiniprogramAudit } = require('../../utils/miniprogramAuditGate.js')
if (redirectIfMiniprogramAudit()) return
try {
const tb = typeof this.getTabBar === 'function' ? this.getTabBar() : null
if (tb && typeof tb.updateSelected === 'function') {
tb.updateSelected()
} else {
const c = typeof this.selectComponent === 'function' ? this.selectComponent('#custom-tab-bar') : null
if (c && typeof c.refreshFromConfig === 'function') c.refreshFromConfig()
else if (c && typeof c.updateSelected === 'function') c.updateSelected()
}
} catch (e) {}
const app = getApp()
if (isAuditHideAiMode(app && app.globalData)) {
redirectIfMiniprogramAudit('功能升级中')
return
}
if (this.data.auditPurchaseMode) {
this.setData({ auditPurchaseMode: false })
}
const tb = typeof this.getTabBar === 'function' ? this.getTabBar() : null
if (tb && typeof tb.updateSelected === 'function') tb.updateSelected()
try {
const st = wx.getStorageSync('token')
if (st && app && app.globalData && !app.globalData.token) {
@@ -166,10 +236,21 @@ Page({
this.loadQuickQuestions()
this.loadMyReportStatus()
this._refreshReportCta()
if (typeof wx.onWindowResize === 'function' && !this._chatResizeListening) {
this._winResizeHandler = () => this._scheduleMeasureChatScroll()
wx.onWindowResize(this._winResizeHandler)
this._chatResizeListening = true
}
this._scheduleMeasureChatScroll()
},
/** 离开对话页时若报告条正在展示,记为已曝光,避免反复出现 */
onHide() {
if (this._chatResizeListening && this._winResizeHandler && typeof wx.offWindowResize === 'function') {
wx.offWindowResize(this._winResizeHandler)
this._chatResizeListening = false
this._winResizeHandler = null
}
if (this.data.showReportCta) {
this._dismissReportCtaPermanently()
}
@@ -179,6 +260,246 @@ Page({
this.setData({ draft: e.detail.value })
},
onInputFocus() {
this._scheduleMeasureChatScroll()
},
onAuditPurchaseSwitchTab(e) {
const tab = (e.currentTarget.dataset.tab || '') === 'enterprise' ? 'enterprise' : 'personal'
this.setData({ auditPurchaseTab: tab })
},
_enterAuditPurchaseMode(options) {
const tab = options && options.tab === 'enterprise' ? 'enterprise' : this.data.auditPurchaseTab
const app = getApp()
const ep = (app && app.globalData && app.globalData.enterprisePermissions) || null
this.setData({
auditPurchaseMode: true,
auditPurchaseTab: tab,
auditDeepHeroDesc: buildDeepHeroDesc(ep),
auditHasPhone: hasPhone()
})
this._loadAuditPurchasePricing()
},
_loadAuditPurchasePricing() {
this.setData({ auditPurchaseLoading: true, auditPurchaseError: '' })
Promise.all([this._requestAuditDeepPricing('personal'), this._requestAuditDeepPricing('enterprise')])
.then(([personal, enterprise]) => {
const pErr = personal && personal.__error
const eErr = enterprise && enterprise.__error
const pList = Array.isArray(personal) ? personal : []
const eList = Array.isArray(enterprise) ? enterprise : []
const app = getApp()
const ep = (app && app.globalData && app.globalData.enterprisePermissions) || null
this.setData({
auditPurchasePersonal: filterCategories(pList, ep),
auditPurchaseEnterprise: filterCategories(eList, ep),
auditDeepHeroDesc: buildDeepHeroDesc(ep),
auditPurchaseLoading: false,
auditPurchaseError: pErr && eErr
? (personal.__errorMsg || enterprise.__errorMsg || '加载失败,请稍后重试')
: ((!pList.length && !eList.length) ? '暂无可展示的套餐' : '')
})
})
.catch(() => {
this.setData({
auditPurchaseLoading: false,
auditPurchaseError: '加载失败,请稍后重试'
})
})
},
_requestAuditDeepPricing(scope, triedRuntime) {
const app = getApp()
const apiBase = (((app && app.globalData && app.globalData.apiBase) || '') + '').replace(/\/$/, '')
if (!apiBase) {
if (!triedRuntime && app && typeof app.getRuntimeConfig === 'function') {
return app
.getRuntimeConfig()
.catch(() => null)
.then(() => this._requestAuditDeepPricing(scope, true))
}
return Promise.resolve({ __error: true, __errorMsg: '服务地址未配置' })
}
return new Promise((resolve) => {
wx.request({
url: `${apiBase}/api/config/deep-pricing`,
method: 'GET',
data: { scope },
timeout: 25000,
success: (res) => {
if (res.statusCode === 200 && res.data && res.data.code === 200 && Array.isArray(res.data.data && res.data.data.categories)) {
const merged = mergeDeepPricingFromRuntime(scope, res.data.data.categories)
resolve(merged)
} else {
const emb = embeddedDeepCategories(scope)
if (emb && emb.length) {
resolve(emb.slice())
return
}
resolve({
__error: true,
__errorMsg: (res && res.data && res.data.message) || '响应异常'
})
}
},
fail: (err) => {
const emb = embeddedDeepCategories(scope)
if (emb && emb.length) {
resolve(emb.slice())
return
}
resolve({
__error: true,
__errorMsg: (err && err.errMsg) || '网络异常'
})
}
})
})
},
handleAuditPurchaseTap(e) {
const tab = e.currentTarget.dataset.tab
const index = e.currentTarget.dataset.index
this._handleAuditPurchase(tab, index)
},
onGetPhoneNumberForAuditPurchase(e) {
const tab = e.currentTarget.dataset.tab
const index = e.currentTarget.dataset.index
const { code, errMsg } = e.detail || {}
if (errMsg && errMsg.indexOf('getPhoneNumber:fail') === 0) {
if (!hasPhone()) {
wx.showToast({ title: '需要授权手机号才能继续', icon: 'none' })
return
}
this._handleAuditPurchase(tab, index)
return
}
if (!code) {
if (hasPhone()) {
this._handleAuditPurchase(tab, index)
} else {
wx.showToast({ title: '获取手机号失败', icon: 'none' })
}
return
}
bindPhoneByCode(code)
.then(() => {
this.setData({ auditHasPhone: true })
this._handleAuditPurchase(tab, index)
})
.catch(() => {})
},
_handleAuditPurchase(tab, index) {
if (index === undefined || index === null) return
const isEnterprise = tab === 'enterprise'
const list = isEnterprise ? this.data.auditPurchaseEnterprise : this.data.auditPurchasePersonal
const category = list[index]
if (!category) return
this.setData({ auditPurchaseTab: isEnterprise ? 'enterprise' : 'personal' })
if (category.actionType === 'buy' && category.productKey) {
this._purchaseAuditPersonal(category)
} else {
this._applyAuditConsult(category)
}
},
_purchaseAuditPersonal(category) {
if (this.data.auditPurchasing) return
this.setData({ auditPurchasing: true })
wx.showLoading({ title: '处理中...', mask: true })
const deepProductId = category.id || category.productKey || ''
const title = category.title || '个人深度服务1v1深度解读'
payment.purchasePersonalDeepService({
deepProductId,
description: title,
success: () => {
wx.hideLoading()
this.setData({ auditPurchasing: false })
this._reportAuditCrmLead(category, 'buy')
const successMsg = (category.successMessage || '购买成功!我们的顾问会尽快与您联系,为您提供专属深度解读服务。').trim()
this._showAuditSuccessModal('购买成功', successMsg)
},
fail: () => {
wx.hideLoading()
this.setData({ auditPurchasing: false })
}
})
},
_applyAuditConsult(category) {
const successMsg = (category.successMessage || '感谢您的申请,我们的顾问会尽快与您联系!').trim()
wx.showLoading({ title: '提交中...', mask: true })
this._reportAuditCrmLead(category, 'consult', () => {
wx.hideLoading()
this._showAuditSuccessModal('申请成功', successMsg)
})
},
_showAuditSuccessModal(title, content) {
this.setData({
auditSuccessModal: {
visible: true,
title: title || '成功',
content: content || ''
}
})
},
catchTapAuditSuccess() {},
closeAuditSuccessModal() {
this.setData({ 'auditSuccessModal.visible': false })
},
_reportAuditCrmLead(category, actionType, onDone) {
const app = getApp()
const apiBase = (((app && app.globalData && app.globalData.apiBase) || '') + '').replace(/\/$/, '')
const apiKey = (category.consultWechat || '').trim()
const isConsult = actionType === 'consult'
const deepConsult = isConsult
const finish = () => {
if (typeof onDone === 'function') onDone()
}
if (!apiBase) {
finish()
return
}
if (!deepConsult && !apiKey) {
finish()
return
}
const isEnterprise = this.data.auditPurchaseTab === 'enterprise'
const source = (isEnterprise ? '企业深度服务' : '个人深度服务') + (category.title ? `-${category.title}` : '')
const remark =
actionType === 'buy'
? '完成付款'
: isEnterprise
? '申请咨询并降低30%成本'
: '申请咨询'
wx.request({
url: `${apiBase}/api/crm/report`,
method: 'POST',
header: {
Authorization: `Bearer ${wx.getStorageSync('token') || ''}`,
'Content-Type': 'application/json'
},
data: {
apiKey,
source,
remark,
siteTags: category.title || '',
deepConsult: deepConsult ? true : false
},
success() {},
fail() {},
complete: finish
})
},
// ---------- 数据加载 ----------
loadArticles() {
@@ -368,24 +689,32 @@ Page({
const apply = (body) => {
if (!body || body.code !== 200) return false
const d = body.data || {}
this.setData({
mbtiType: d.mbtiType || '',
nickname: d.nickname || '',
quickQuestions: this.buildQuickQuestions(d.questions || [])
})
this.setData(
{
mbtiType: d.mbtiType || '',
nickname: d.nickname || '',
quickQuestions: this.buildQuickQuestions(d.questions || [])
},
() => this._scheduleMeasureChatScroll()
)
return true
}
const applyDirect = (d) => {
if (!d || !Array.isArray(d.questions) || d.questions.length === 0) return false
this.setData({
mbtiType: d.mbtiType || '',
nickname: d.nickname || '',
quickQuestions: this.buildQuickQuestions(d.questions)
})
this.setData(
{
mbtiType: d.mbtiType || '',
nickname: d.nickname || '',
quickQuestions: this.buildQuickQuestions(d.questions)
},
() => this._scheduleMeasureChatScroll()
)
return true
}
const fallbackLocal = () => {
this.setData({ quickQuestions: this.buildQuickQuestions([]) })
this.setData({ quickQuestions: this.buildQuickQuestions([]) }, () =>
this._scheduleMeasureChatScroll()
)
}
const tryLegacy = () => {
request({
@@ -468,6 +797,8 @@ Page({
const msgs = (d.messages || []).filter(m => m.role !== 'system')
this.setData({ messages: msgs }, () => {
this.scrollToBottom()
setTimeout(() => this.scrollToBottom(), 120)
this._scheduleMeasureChatScroll()
this._refreshReportCta()
})
}
@@ -586,7 +917,7 @@ Page({
sending: true
},
() => {
this.setData({ scrollTarget: 'msg-typing' })
this.scrollToBottom()
}
)
@@ -658,6 +989,8 @@ Page({
setPayload,
() => {
this.scrollToBottom()
setTimeout(() => this.scrollToBottom(), 120)
setTimeout(() => this.scrollToBottom(), 380)
this._refreshReportCta()
}
)
@@ -895,14 +1228,77 @@ Page({
})
},
scrollToBottom() {
if (this.data.sending) {
this.setData({ scrollTarget: 'msg-typing' })
return
_fallbackChatScrollHeight() {
try {
const sys = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync()
const wh = sys.windowHeight || 600
const sw = sys.screenWidth || 375
const rpx2px = (r) => (r / 750) * sw
const inset = sys.safeAreaInsets || {}
const safeBottom = typeof inset.bottom === 'number' ? inset.bottom : 0
// Tab 区 + 固定 composer含快捷语行在 rpx 上的经验预留
const reserve = rpx2px(168 + 300) + safeBottom
const h = Math.max(200, Math.floor(wh - reserve - rpx2px(20)))
this.setData({ chatScrollHeightPx: h })
} catch (e) {
this.setData({ chatScrollHeightPx: 500 })
}
const msgs = this.data.messages
const last = msgs.length ? msgs[msgs.length - 1] : null
this.setData({ scrollTarget: last ? ('msg-' + last.id) : 'bottom' })
},
_scheduleMeasureChatScroll() {
if (this._chatLayoutTimer) clearTimeout(this._chatLayoutTimer)
this._chatLayoutTimer = setTimeout(() => {
this._chatLayoutTimer = null
this._measureChatScrollHeight()
}, 80)
},
_measureChatScrollHeight(retry) {
wx.nextTick(() => {
wx.createSelectorQuery()
.in(this)
.select('.chat-scroll')
.boundingClientRect()
.select('.composer-wrap')
.boundingClientRect()
.exec((res) => {
const scrollRect = res && res[0]
const composerRect = res && res[1]
if (!scrollRect || !composerRect || typeof composerRect.top !== 'number') {
this._fallbackChatScrollHeight()
return
}
const gapPx = 6
const h = composerRect.top - scrollRect.top - gapPx
if (h > 120) {
this.setData({ chatScrollHeightPx: Math.round(h) }, () => {
this.scrollToBottom()
})
} else if (!retry) {
setTimeout(() => this._measureChatScrollHeight(true), 200)
} else {
this._fallbackChatScrollHeight()
}
})
})
},
/**
* 滚到底部(对齐微信聊天:新发/收信后总能看到最新消息)。
* scroll-view 的 scroll-into-view 在连续设为同一 id 时不会触发,需先清空再设;
* 锚点统一用 #bottom位于打字条、报告条之后避免滚到最后一条气泡 id 仍被底部输入区挡住。
*/
scrollToBottom() {
const go = () => {
this.setData({ scrollTarget: 'bottom' })
}
this.setData({ scrollTarget: '' }, () => {
if (typeof wx !== 'undefined' && typeof wx.nextTick === 'function') {
wx.nextTick(go)
} else {
setTimeout(go, 20)
}
})
},
/** 对话内上传简历:直传 OSS 后走 /api/ai/chat由神仙 AI 结合测评与正文回复(不跳转匹配工作页) */

View File

@@ -3,7 +3,5 @@
"navigationBarBackgroundColor": "#faf9ff",
"navigationBarTextStyle": "black",
"backgroundColor": "#f4f3f9",
"usingComponents": {
"custom-tab-bar": "/custom-tab-bar/index"
}
"usingComponents": {}
}

View File

@@ -1,6 +1,159 @@
<!-- 神仙 AI · 对话页(精选推荐嵌入第 2 轮及以后的助手气泡 · MBTI 去测进测评列表) -->
<view class="ai-page">
<scroll-view class="chat-scroll" scroll-y="true" scroll-into-view="{{scrollTarget}}" scroll-with-animation="true" enhanced show-scrollbar="{{false}}">
<view class="container audit-purchase" wx:if="{{auditPurchaseMode}}">
<view class="success-mask" wx:if="{{auditSuccessModal.visible}}" bindtap="closeAuditSuccessModal">
<view class="success-dialog" catchtap="catchTapAuditSuccess">
<view class="success-icon-wrap">
<view class="success-icon-circle">
<text class="success-icon-check">✓</text>
</view>
</view>
<text class="success-dialog-title">{{auditSuccessModal.title}}</text>
<text class="success-dialog-content">{{auditSuccessModal.content}}</text>
<text class="success-dialog-hint">顾问将通过您预留的联系方式与您沟通,请留意来电或短信。</text>
<view class="success-dialog-btns success-dialog-btns--single">
<button class="success-btn-close success-btn-close--primary" bindtap="closeAuditSuccessModal">我知道了</button>
</view>
</view>
</view>
<view class="deep-hero">
<text class="deep-hero__eyebrow">UNDERSTAND YOURSELF · 了解自己</text>
<text class="deep-hero__title">更深入地看见你自己</text>
<text class="deep-hero__desc">{{auditDeepHeroDesc}}</text>
</view>
<view class="tabs-container deep-tabs">
<view class="tabs-list">
<view class="tab-item {{auditPurchaseTab === 'personal' ? 'active' : ''}}" data-tab="personal" bindtap="onAuditPurchaseSwitchTab">
<text class="tab-text">个人解读</text>
</view>
<view class="tab-item {{auditPurchaseTab === 'enterprise' ? 'active' : ''}}" data-tab="enterprise" bindtap="onAuditPurchaseSwitchTab">
<text class="tab-text">团队与企业</text>
</view>
</view>
</view>
<view wx:if="{{auditPurchaseLoading}}" class="loading-wrap">
<text class="loading-text">加载中...</text>
</view>
<view wx:elif="{{auditPurchaseError}}" class="purchase-error-state">
<view class="purchase-error-ic">⚠️</view>
<text class="purchase-error-title">加载失败</text>
<text class="purchase-error-msg">{{auditPurchaseError}}</text>
<text class="purchase-error-hint">请稍后重试</text>
</view>
<block wx:else>
<view wx:if="{{auditPurchaseTab === 'personal'}}">
<view wx:for="{{auditPurchasePersonal}}" wx:key="id" class="pricing-card {{index === 0 ? 'featured' : ''}}" style="margin: 24rpx;">
<view class="card-header">
<text class="plan-title">{{item.title}}</text>
<view class="price-section" wx:if="{{item.price !== undefined}}">
<text class="price-symbol">¥</text>
<text class="price-amount">{{item.price}}</text>
<text class="price-unit">{{item.priceUnit || '/次'}}</text>
</view>
<text class="plan-subtitle" wx:if="{{item.subtitle}}">{{item.subtitle}}</text>
</view>
<view class="features-list" wx:if="{{item.features && item.features.length}}">
<view class="feature-item" wx:for="{{item.features}}" wx:for-item="f" wx:key="*this">
<view class="check-icon">✓</view>
<text class="feature-text">{{f}}</text>
</view>
</view>
<button
class="purchase-button primary"
wx:if="{{item.actionType === 'buy' && !auditHasPhone}}"
open-type="getPhoneNumber"
bindgetphonenumber="onGetPhoneNumberForAuditPurchase"
data-tab="personal"
data-index="{{index}}"
disabled="{{auditPurchasing}}"
>
<text class="button-text">{{item.purchaseButtonText || '了解自己并付款'}}</text>
</button>
<button
class="purchase-button primary"
wx:elif="{{item.actionType === 'buy' && auditHasPhone}}"
bindtap="handleAuditPurchaseTap"
data-tab="personal"
data-index="{{index}}"
disabled="{{auditPurchasing}}"
>
<text class="button-text">{{item.purchaseButtonText || '了解自己并付款'}}</text>
</button>
<button
class="purchase-button primary"
wx:else
bindtap="handleAuditPurchaseTap"
data-tab="personal"
data-index="{{index}}"
disabled="{{auditPurchasing}}"
>
<text class="button-text">{{item.buttonText || '申请咨询'}}</text>
</button>
</view>
<view wx:if="{{!auditPurchaseLoading && auditPurchasePersonal.length === 0}}" class="empty-tip">
<text>暂无可用的个人版套餐</text>
</view>
</view>
<view wx:else>
<view wx:for="{{auditPurchaseEnterprise}}" wx:key="id" class="pricing-card {{index === 1 ? 'featured-blue' : ''}}" style="margin: 24rpx;">
<view class="card-header-flex">
<view class="header-left">
<text class="plan-title-sm">{{item.title}}</text>
<text class="plan-desc" wx:if="{{item.subtitle}}">{{item.subtitle}}</text>
</view>
<view class="header-right" wx:if="{{item.priceDisplay || item.price}}">
<text class="price-amount-small">{{item.priceDisplay || '¥' + item.price}}</text>
<text class="price-limit" wx:if="{{item.userLimit}}">{{item.userLimit}}</text>
</view>
</view>
<view class="features-list" wx:if="{{item.features && item.features.length}}">
<view class="feature-item" wx:for="{{item.features}}" wx:for-item="f" wx:key="*this">
<view class="check-icon blue">✓</view>
<text class="feature-text">{{f}}</text>
</view>
</view>
<button
class="purchase-button secondary"
wx:if="{{!auditHasPhone}}"
open-type="getPhoneNumber"
bindgetphonenumber="onGetPhoneNumberForAuditPurchase"
data-tab="enterprise"
data-index="{{index}}"
disabled="{{auditPurchasing}}"
>
<text class="button-text">{{item.buttonText || '申请咨询并降低30%成本'}}</text>
</button>
<button
class="purchase-button secondary"
wx:else
bindtap="handleAuditPurchaseTap"
data-tab="enterprise"
data-index="{{index}}"
disabled="{{auditPurchasing}}"
>
<text class="button-text">{{item.buttonText || '申请咨询并降低30%成本'}}</text>
</button>
</view>
<view wx:if="{{!auditPurchaseLoading && auditPurchaseEnterprise.length === 0}}" class="empty-tip">
<text>暂无可用的企业版套餐</text>
</view>
</view>
</block>
</view>
<scroll-view
wx:else
class="chat-scroll"
style="height: {{chatScrollHeightPx}}px;"
scroll-y="true"
scroll-into-view="{{scrollTarget}}"
scroll-with-animation="true"
enhanced
show-scrollbar="{{false}}"
>
<!-- MBTI 引导:去测 → 详细性格测试列表(不直跳拍摄) -->
<view class="mbti-remind-card" wx:if="{{messages.length === 0}}">
<image class="mbti-remind-avatar" src="/images/shenxian-ai-logo.png" mode="aspectFill" />
@@ -79,10 +232,10 @@
</scroll-view>
<!-- 与 Tab 之间的白底垫层,避免聊天记录从输入条/底栏缝隙透出pointer-events:none 不挡操作) -->
<view class="ai-bottom-solid" aria-hidden="true"></view>
<view class="ai-bottom-solid" aria-hidden="true" wx:if="{{!auditPurchaseMode}}"></view>
<!-- 底部:快捷语(横滑)+ 输入区,固定在 Tab 之上 -->
<view class="composer-wrap {{quickQuestions.length > 0 ? 'composer-wrap--has-chips' : ''}}">
<view class="composer-wrap {{quickQuestions.length > 0 ? 'composer-wrap--has-chips' : ''}}" wx:if="{{!auditPurchaseMode}}">
<view class="quick-chips-outer" wx:if="{{quickQuestions.length > 0}}">
<scroll-view
class="quick-chips-scroll"
@@ -114,6 +267,7 @@
placeholder-class="input-ph"
value="{{draft}}"
bindinput="onInput"
bindfocus="onInputFocus"
bindconfirm="onSend"
confirm-type="send"
confirm-hold="true"
@@ -132,7 +286,4 @@
</button>
</view>
</view>
<!-- 非 app.json Tab 页时 getTabBar 可能不可用onShow 内用 selectComponent('#custom-tab-bar') 同步选中态 -->
<custom-tab-bar id="custom-tab-bar" />
</view>

View File

@@ -1,3 +1,5 @@
@import "../purchase/index.wxss";
/* 神仙 AI · 对话页样式 */
page {
height: 100%;
@@ -8,12 +10,120 @@ page {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: stretch;
background: linear-gradient(180deg, #f0edff 0%, #f8f7fc 18%, #ffffff 42%);
/* TabBar + 底部 composer快捷语 + 输入行) */
padding-bottom: calc(var(--tabbar-pad, 170rpx) + 200rpx + env(safe-area-inset-bottom));
/* 聊天态内边距交给 chat-scroll 处理,审核态复用 purchase 页 container 样式 */
padding: 0;
padding-bottom: calc(var(--tabbar-pad) + 16rpx);
box-sizing: border-box;
}
/* 审核模式下:在神仙 AI Tab 内直接展示「了解自己」内容,不做路由跳转 */
.audit-purchase {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-bottom: calc(var(--tabbar-pad) + 16rpx);
}
/* 复刻 purchase 页成功弹窗交互 */
.success-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 10020;
display: flex;
align-items: center;
justify-content: center;
}
.success-dialog {
width: 600rpx;
background: #fff;
border-radius: 24rpx;
padding: 56rpx 40rpx 40rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.success-icon-wrap {
margin-bottom: 32rpx;
}
.success-icon-circle {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background: linear-gradient(135deg, #22c55e, #16a34a);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(34, 197, 94, 0.35);
}
.success-icon-check {
font-size: 56rpx;
color: #fff;
font-weight: 700;
line-height: 1;
}
.success-dialog-title {
font-size: 40rpx;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 20rpx;
}
.success-dialog-content {
font-size: 28rpx;
color: #555;
text-align: center;
line-height: 1.7;
margin-bottom: 16rpx;
}
.success-dialog-hint {
font-size: 24rpx;
color: #94a3b8;
text-align: center;
line-height: 1.6;
margin-bottom: 28rpx;
padding: 0 8rpx;
}
.success-dialog-btns {
width: 100%;
display: flex;
flex-direction: row;
gap: 16rpx;
}
.success-dialog-btns--single {
flex-direction: column;
}
.success-btn-close {
flex: 1;
width: 100%;
background: #f5f5f5;
color: #333;
font-size: 28rpx;
padding: 24rpx 0;
border-radius: 12rpx;
border: none;
}
.success-btn-close--primary {
background: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
color: #fff;
font-weight: 600;
}
/* MBTI 引导卡 */
.mbti-remind-card {
margin: 4rpx 0 20rpx;
@@ -98,12 +208,16 @@ page {
transform: scale(0.98);
}
/* 聊天区:不透明白底,避免页面渐变从气泡后透出 */
/*
* 高度必须由行内 px 指定。仅用 flex:1 时,部分基础库下 scroll-view 会被内容撑高,
* 可视区域实际盖住底部固定 composer表现为「最后几条永远压在输入条下」。
*/
.chat-scroll {
flex: 1;
min-height: 0;
width: 100%;
margin: 0 12rpx;
padding: 20rpx 22rpx 36rpx;
flex-shrink: 0;
min-height: 0;
padding: 20rpx 22rpx 28rpx;
box-sizing: border-box;
background-color: #ffffff;
border-radius: 28rpx;
@@ -291,8 +405,11 @@ page {
margin-bottom: 24rpx;
}
/* 列表末尾锚点:可视区高度已避开 composer此处只需少量留白 */
.scroll-bottom {
height: 40rpx;
height: 32rpx;
min-height: 32rpx;
flex-shrink: 0;
}
/* ==== 深度报告 CTA ==== */
@@ -340,7 +457,7 @@ page {
right: 0;
bottom: 0;
z-index: 9990;
height: calc(var(--tabbar-height, 168rpx) + env(safe-area-inset-bottom) + 260rpx);
height: calc(var(--tabbar-height, 168rpx) + env(safe-area-inset-bottom) + 210rpx);
background-color: #f8f7fc;
pointer-events: none;
}
@@ -350,7 +467,7 @@ page {
position: fixed;
left: 0;
right: 0;
bottom: calc(var(--tabbar-height, 168rpx) + env(safe-area-inset-bottom));
bottom: calc(var(--tabbar-height, 168rpx) + env(safe-area-inset-bottom) - 45rpx);
width: 100%;
box-sizing: border-box;
background-color: #ffffff;

View File

@@ -12,13 +12,50 @@ Page({
aiAnalysisText: '智能分析',
maintenanceMode: false,
reviewMode: false,
permFace: true
permFace: true,
showVersionPicker: false
},
_navigateByVersion(version) {
try {
getApp().globalData.appScope = version || 'personal'
getApp().globalData.selectedHomeVersion = version || 'personal'
} catch (e) {}
if (version === 'enterprise') {
wx.navigateTo({ url: '/pages/enterprise/index' })
return
}
if (version === 'gaokao') {
wx.navigateTo({ url: '/pages/gaokao/index' })
return
}
wx.switchTab({ url: '/pages/index/index' })
},
openVersionPicker() {
this.setData({ showVersionPicker: true })
},
closeVersionPicker() {
this.setData({ showVersionPicker: false })
},
noop() {},
selectVersion(e) {
const version = String(e.currentTarget.dataset.version || '')
this.setData({ showVersionPicker: false })
if (!version) return
this._navigateByVersion(version)
},
onLoad(options) {
// 企业版首页:固定 scope=enterprise
const app = getApp()
try { app.globalData.appScope = 'enterprise' } catch (e) {}
try {
app.globalData.appScope = 'enterprise'
app.globalData.selectedHomeVersion = 'enterprise'
} catch (e) {}
// ── 解析入参(兼容两种来源:扫码 scene / 分享链接 options──
const rawScene = (options && options.scene) ? decodeURIComponent(options.scene) : ''
@@ -183,7 +220,10 @@ Page({
const tabBar = this.getTabBar()
if (typeof tabBar.updateSelected === 'function') tabBar.updateSelected()
}
try { getApp().globalData.appScope = 'enterprise' } catch (e) {}
try {
getApp().globalData.appScope = 'enterprise'
getApp().globalData.selectedHomeVersion = 'enterprise'
} catch (e) {}
const gd = getApp().globalData
const maintenanceMode = !!(gd.reviewMode || gd.maintenanceMode)
const ep4 = gd.enterprisePermissions
@@ -200,9 +240,12 @@ Page({
// 切换到个人版
switchToPersonal() {
wx.switchTab({
url: '/pages/index/index'
})
const versions = (getApp().globalData.availableVersions || []).filter((v) => v === 'personal' || v === 'enterprise' || v === 'gaokao')
if (versions.length >= 3) {
this.openVersionPicker()
return
}
this._navigateByVersion('personal')
},
// 开始AI面部测试先校验是否已上传简历再跳转相机审核模式下直接跳转 test-select

View File

@@ -75,6 +75,25 @@
<text class="button-text">{{startButtonEnterprise || '开始性格测试'}}</text>
</view>
<view class="version-picker-mask" wx:if="{{showVersionPicker}}" bindtap="closeVersionPicker">
<view class="version-picker-dialog" catchtap="noop">
<view class="version-picker-head">
<view class="version-picker-badge">版本选择</view>
<view class="version-picker-close" bindtap="closeVersionPicker">×</view>
</view>
<view class="version-picker-title">请选择使用版本</view>
<view class="version-picker-desc">当前企业支持三种版本,选择后将进入对应流程</view>
<view class="version-picker-actions">
<button class="version-btn version-btn-personal" data-version="personal" bindtap="selectVersion">个人版</button>
<button class="version-btn version-btn-enterprise" data-version="enterprise" bindtap="selectVersion">企业版</button>
<button class="version-btn version-btn-gaokao" data-version="gaokao" bindtap="selectVersion">高考版</button>
</view>
<view class="version-picker-foot">
<button class="version-cancel-btn" bindtap="closeVersionPicker">取消</button>
</view>
</view>
</view>
<!-- 自定义底部导航 -->
<custom-tab-bar />
</view>

View File

@@ -246,6 +246,108 @@
font-weight: 700;
}
.version-picker-mask {
position: fixed;
inset: 0;
background: rgba(10, 15, 30, 0.52);
z-index: 20000;
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx;
box-sizing: border-box;
}
.version-picker-dialog {
width: 100%;
max-width: 640rpx;
background: #fff;
border-radius: 30rpx;
padding: 26rpx 30rpx 24rpx;
box-shadow: 0 24rpx 70rpx rgba(16, 24, 40, 0.28);
}
.version-picker-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.version-picker-badge {
padding: 8rpx 18rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, rgba(79, 70, 229, 0.12) 0%, rgba(124, 58, 237, 0.16) 100%);
color: #4f46e5;
font-size: 22rpx;
font-weight: 600;
}
.version-picker-close {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: #f3f4f6;
color: #6b7280;
display: flex;
align-items: center;
justify-content: center;
font-size: 34rpx;
line-height: 1;
}
.version-picker-title {
text-align: center;
font-size: 36rpx;
font-weight: 700;
color: #111827;
margin-top: 12rpx;
}
.version-picker-desc {
margin-top: 12rpx;
text-align: center;
font-size: 24rpx;
color: #6b7280;
line-height: 1.6;
}
.version-picker-actions {
margin-top: 28rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.version-btn {
height: 84rpx;
line-height: 84rpx;
border-radius: 999rpx;
color: #fff;
font-size: 29rpx;
font-weight: 600;
border: none;
box-shadow: 0 8rpx 22rpx rgba(79, 70, 229, 0.22);
}
.version-btn-personal { background: linear-gradient(135deg, #ef4444 0%, #f97316 100%); }
.version-btn-enterprise { background: linear-gradient(135deg, #2563eb 0%, #6366f1 100%); }
.version-btn-gaokao { background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%); }
.version-picker-foot {
margin-top: 14rpx;
}
.version-cancel-btn {
height: 76rpx;
line-height: 76rpx;
border-radius: 999rpx;
background: #f3f4f6;
color: #6b7280;
font-size: 27rpx;
font-weight: 600;
border: 1rpx solid #e5e7eb;
}
.upload-button {
margin: 10rpx 40rpx 0;
border: 2rpx solid #8b5cf6;

View File

@@ -1,6 +1,4 @@
const gaokaoApi = require('../../utils/gaokao')
const app = getApp()
const { isAuditHideAiMode, redirectIfMiniprogramAudit } = require('../../utils/miniprogramAuditGate.js')
/** 科类/选科备选项:首项为占位,不可作为有效保存值 */
const SUBJECT_PLACEHOLDER = '请选择科类/选科'
@@ -121,18 +119,7 @@ Page({
/** 每次页面展示拉取(含从上级页返回),避免栈内页面不触发 onLoad 时看不到已保存内容 */
onShow() {
const run = () => {
if (isAuditHideAiMode(app.globalData)) {
redirectIfMiniprogramAudit('版本审核中暂不可用')
return
}
this.loadFormFromServer()
}
if (app.getRuntimeConfig) {
app.getRuntimeConfig().finally(run)
} else {
run()
}
this.loadFormFromServer()
},
loadFormFromServer() {

View File

@@ -1,120 +1,146 @@
const gaokaoApi = require('../../utils/gaokao')
const app = getApp()
const { isAuditHideAiMode, redirectIfMiniprogramAudit } = require('../../utils/miniprogramAuditGate.js')
Page({
data: {
loading: false,
tasks: {
mbti: { code: 'mbti', status: 'todo', resultText: '', testResultId: 0, id: 0, testType: 'mbti', typeName: 'MBTI性格', emoji: '🧠', testTime: '' },
pdp: { code: 'pdp', status: 'todo', resultText: '', testResultId: 0, id: 0, testType: 'pdp', typeName: 'PDP行为', emoji: '🦁', testTime: '' },
disc: { code: 'disc', status: 'todo', resultText: '', testResultId: 0, id: 0, testType: 'disc', typeName: 'DISC测评', emoji: '📊', testTime: '' },
face: { code: 'face', status: 'todo', resultText: '', testResultId: 0, id: 0, testType: 'ai', typeName: '拍照面相', emoji: '📷', testTime: '', recordTestType: 'ai' },
form: { code: 'form', status: 'todo', resultText: '', testResultId: 0, id: 0, testType: 'form', typeName: '高考信息表单', emoji: '📝', testTime: '' }
},
canAnalyze: false,
missingItems: [],
analyzing: false
statusBarHeight: 0,
navbarHeight: 88,
siteTitle: '高考版AI性格测试',
startButtonGaokao: '开始面部测试',
aiAnalysisText: '智能分析',
maintenanceMode: false,
reviewMode: false,
permFace: true,
showVersionPicker: false
},
_navigateByVersion(version) {
try {
getApp().globalData.appScope = version || 'personal'
getApp().globalData.selectedHomeVersion = version || 'personal'
} catch (e) {}
if (version === 'enterprise') {
wx.navigateTo({ url: '/pages/enterprise/index' })
return
}
if (version === 'gaokao') {
wx.navigateTo({ url: '/pages/gaokao/index' })
return
}
wx.switchTab({ url: '/pages/index/index' })
},
openVersionPicker() {
this.setData({ showVersionPicker: true })
},
closeVersionPicker() {
this.setData({ showVersionPicker: false })
},
noop() {},
selectVersion(e) {
const version = String(e.currentTarget.dataset.version || '')
this.setData({ showVersionPicker: false })
if (!version) return
this._navigateByVersion(version)
},
onLoad(options) {
try {
app.globalData.appScope = 'gaokao'
app.globalData.selectedHomeVersion = 'gaokao'
} catch (e) {}
try {
require('../../utils/thirdPartyContext.js').ingestThirdPartyOnPageLoad(options || {}, app)
} catch (e) {}
const systemInfo = wx.getSystemInfoSync()
const statusBarHeight = systemInfo.statusBarHeight || 0
const screenWidth = systemInfo.screenWidth || 375
const statusBarHeightRpx = (statusBarHeight * 750) / screenWidth
const navbarHeightRpx = statusBarHeightRpx + 88
const gd = app.globalData
const maintenanceMode = !!(gd.reviewMode || gd.maintenanceMode)
const ep = gd.enterprisePermissions
const pf = !ep || ep.face !== false
this.setData({
statusBarHeight: statusBarHeightRpx,
navbarHeight: navbarHeightRpx,
siteTitle: gd.siteTitle || '高考版AI性格测试',
startButtonGaokao: (maintenanceMode || !pf) ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonEnterprise) || '开始面部测试'),
aiAnalysisText: (gd.textConfig && gd.textConfig.aiAnalysisText) || '智能分析',
maintenanceMode,
reviewMode: maintenanceMode,
permFace: pf
})
},
onShow() {
const run = () => {
if (isAuditHideAiMode(app.globalData)) {
redirectIfMiniprogramAudit('版本审核中暂不可用')
return
}
this.refreshStatus()
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
const tabBar = this.getTabBar()
if (typeof tabBar.updateSelected === 'function') tabBar.updateSelected()
}
if (app.getRuntimeConfig) {
app.getRuntimeConfig().finally(run)
} else {
run()
}
},
refreshStatus() {
this.setData({ loading: true })
gaokaoApi
.getTaskStatus({ scene: 'gaokao_hub' })
.then((data) => {
try {
getApp().globalData.appScope = 'gaokao'
getApp().globalData.selectedHomeVersion = 'gaokao'
} catch (e) {}
app.ensureLogin()
.then(() => app.getRuntimeConfig())
.then((cfg) => {
if (cfg && cfg.siteTitle) {
app.globalData.siteTitle = cfg.siteTitle
}
const gd = app.globalData
const maintenanceMode = !!(gd.reviewMode || gd.maintenanceMode)
const ep = gd.enterprisePermissions
const pf = !ep || ep.face !== false
this.setData({
tasks: data.tasks || this.data.tasks,
canAnalyze: !!data.canAnalyze,
missingItems: data.missingItems || []
siteTitle: gd.siteTitle || '高考版AI性格测试',
startButtonGaokao: (maintenanceMode || !pf) ? '开始性格测试' : ((gd.textConfig && gd.textConfig.startButtonEnterprise) || '开始面部测试'),
aiAnalysisText: (gd.textConfig && gd.textConfig.aiAnalysisText) || '智能分析',
maintenanceMode,
reviewMode: maintenanceMode,
permFace: pf
})
})
.catch((e) => {
wx.showToast({ title: e.message || '加载失败', icon: 'none' })
})
.finally(() => this.setData({ loading: false }))
.catch(() => {})
},
goTask(e) {
const code = e.currentTarget.dataset.code
const resultId = Number(e.currentTarget.dataset.resultId || 0)
const done = (e.currentTarget.dataset.status || '') === 'done'
if (code === 'mbti') {
if (done && resultId > 0) {
wx.navigateTo({ url: `/pages/result/mbti?id=${resultId}&type=mbti` })
} else {
wx.navigateTo({ url: '/pages/test/mbti' })
}
switchVersion() {
const versions = (getApp().globalData.availableVersions || []).filter((v) => v === 'personal' || v === 'enterprise' || v === 'gaokao')
if (versions.length >= 3) {
this.openVersionPicker()
return
}
if (code === 'pdp') {
if (done && resultId > 0) {
wx.navigateTo({ url: `/pages/result/pdp?id=${resultId}&type=pdp` })
} else {
wx.navigateTo({ url: '/pages/test/pdp' })
}
this._navigateByVersion('personal')
},
startAITest() {
const ep = getApp().globalData.enterprisePermissions
if (this.data.maintenanceMode || (ep && ep.face === false)) {
wx.navigateTo({ url: '/pages/test-select/index' })
return
}
if (code === 'disc') {
if (done && resultId > 0) {
wx.navigateTo({ url: `/pages/result/disc?id=${resultId}&type=disc` })
} else {
wx.navigateTo({ url: '/pages/test/disc' })
}
return
}
if (code === 'face') {
const recType = String(e.currentTarget.dataset.recordType || 'ai').toLowerCase()
const typeParam = recType === 'face' ? 'face' : 'ai'
if (done && resultId > 0) {
wx.navigateTo({ url: `/pages/index/result?id=${resultId}&type=${typeParam}` })
} else {
wx.switchTab({ url: '/pages/index/camera' })
}
return
}
if (code === 'form') {
wx.navigateTo({ url: '/pages/gaokao/form' })
wx.switchTab({ url: '/pages/index/camera' })
},
openTaskCenter() {
wx.navigateTo({ url: '/pages/gaokao/task-center' })
},
onShareAppMessage() {
const { getSharePath } = require('../../utils/share')
return {
title: '神仙团队AI性格测试 (高考版)',
path: getSharePath('/pages/gaokao/index')
}
},
onAnalyzeTap() {
if (!this.data.canAnalyze) {
const nameMap = {
mbti: 'MBTI测试',
pdp: 'PDP测试',
disc: 'DISC测试',
face: '拍照面相',
form: '高考信息表单'
}
const msg = (this.data.missingItems || []).map((k) => nameMap[k] || k).join('、')
wx.showToast({
title: msg ? `请先完成:${msg}` : '请先完成全部任务',
icon: 'none',
duration: 2500
})
return
onShareTimeline() {
const { buildShareQuery } = require('../../utils/share')
return {
title: '神仙团队AI性格测试 (高考版)',
query: buildShareQuery()
}
this.setData({ analyzing: true })
wx.navigateTo({
url: '/pages/gaokao/report?pendingAnalyze=1',
complete: () => {
this.setData({ analyzing: false })
}
})
}
})

View File

@@ -1,4 +1,8 @@
{
"navigationBarTitleText": "高考志愿"
"navigationStyle": "custom",
"usingComponents": {
"custom-tab-bar": "/custom-tab-bar/index"
},
"enablePullDownRefresh": false
}

View File

@@ -1,90 +1,100 @@
<view class="page">
<view class="container">
<view class="header">
<view class="title">高考志愿任务中心</view>
<view class="desc">完成 MBTI、PDP、DISC、拍照面相与高考信息表单后即可生成综合分析若定价非 0请在报告页解锁全文与问卷类测评一致。</view>
</view>
<view class="task-list">
<view class="task-card" data-code="mbti" data-status="{{tasks.mbti.status}}" data-result-id="{{tasks.mbti.testResultId}}" bindtap="goTask">
<view class="left">
<view class="task-title-row">
<text class="task-emoji">{{tasks.mbti.emoji}}</text>
<text class="name">{{tasks.mbti.typeName}}</text>
</view>
<view class="result result--value" wx:if="{{tasks.mbti.status==='done'}}">{{tasks.mbti.resultText || '已完成'}}</view>
<view class="result todo" wx:else>未完成,请去测试</view>
<view class="task-time" wx:if="{{tasks.mbti.status==='done' && tasks.mbti.testTime}}">{{tasks.mbti.testTime}}</view>
<view class="container" style="padding-top: {{navbarHeight}}px;">
<view class="custom-navbar" style="padding-top: {{statusBarHeight}}rpx;">
<view class="navbar-content">
<view class="switch-version-btn" bindtap="switchVersion">
<text class="switch-version-icon">🎓</text>
<text class="switch-version-text">高考版</text>
</view>
<view class="status {{tasks.mbti.status==='done'?'done':''}}">{{tasks.mbti.status==='done'?'查看结果':'去完成'}}</view>
</view>
<view class="task-card" data-code="pdp" data-status="{{tasks.pdp.status}}" data-result-id="{{tasks.pdp.testResultId}}" bindtap="goTask">
<view class="left">
<view class="task-title-row">
<text class="task-emoji">{{tasks.pdp.emoji}}</text>
<text class="name">{{tasks.pdp.typeName}}</text>
</view>
<view class="result result--value" wx:if="{{tasks.pdp.status==='done'}}">{{tasks.pdp.resultText || '已完成'}}</view>
<view class="result todo" wx:else>未完成,请去测试</view>
<view class="task-time" wx:if="{{tasks.pdp.status==='done' && tasks.pdp.testTime}}">{{tasks.pdp.testTime}}</view>
</view>
<view class="status {{tasks.pdp.status==='done'?'done':''}}">{{tasks.pdp.status==='done'?'查看结果':'去完成'}}</view>
</view>
<view class="task-card" data-code="disc" data-status="{{tasks.disc.status}}" data-result-id="{{tasks.disc.testResultId}}" bindtap="goTask">
<view class="left">
<view class="task-title-row">
<text class="task-emoji">{{tasks.disc.emoji}}</text>
<text class="name">{{tasks.disc.typeName}}</text>
</view>
<view class="result result--value" wx:if="{{tasks.disc.status==='done'}}">{{tasks.disc.resultText || '已完成'}}</view>
<view class="result todo" wx:else>未完成,请去测试</view>
<view class="task-time" wx:if="{{tasks.disc.status==='done' && tasks.disc.testTime}}">{{tasks.disc.testTime}}</view>
</view>
<view class="status {{tasks.disc.status==='done'?'done':''}}">{{tasks.disc.status==='done'?'查看结果':'去完成'}}</view>
</view>
<view class="task-card" data-code="face" data-status="{{tasks.face.status}}" data-result-id="{{tasks.face.testResultId}}" data-record-type="{{tasks.face.recordTestType}}" bindtap="goTask">
<view class="left">
<view class="task-title-row">
<text class="task-emoji">{{tasks.face.emoji}}</text>
<text class="name">{{tasks.face.typeName}}</text>
</view>
<view class="result result--value" wx:if="{{tasks.face.status==='done'}}">{{tasks.face.resultText || '已完成'}}</view>
<view class="result todo" wx:else>未完成,请去拍摄</view>
<view class="task-time" wx:if="{{tasks.face.status==='done' && tasks.face.testTime}}">{{tasks.face.testTime}}</view>
</view>
<view class="status {{tasks.face.status==='done'?'done':''}}">{{tasks.face.status==='done'?'查看结果':'去完成'}}</view>
</view>
<view class="task-card" data-code="form" data-status="{{tasks.form.status}}" data-result-id="{{tasks.form.testResultId}}" bindtap="goTask">
<view class="left">
<view class="task-title-row">
<text class="task-emoji">{{tasks.form.emoji}}</text>
<text class="name">{{tasks.form.typeName}}</text>
</view>
<view class="result result--value" wx:if="{{tasks.form.status==='done'}}">
<block wx:if="{{tasks.form.resultText}}">已填写:{{tasks.form.resultText}}</block>
<block wx:else>已完成</block>
</view>
<view class="result todo" wx:else>未完成,请先填写</view>
<view class="task-time" wx:if="{{tasks.form.status==='done' && tasks.form.testTime}}">{{tasks.form.testTime}}</view>
</view>
<view class="status {{tasks.form.status==='done'?'done':''}}">{{tasks.form.status==='done'?'查看结果':'去完成'}}</view>
<view class="navbar-title">{{siteTitle || '高考版AI性格测试'}}</view>
<view class="navbar-placeholder"></view>
</view>
</view>
<view class="bg-decoration bg-top-right"></view>
<view class="bg-decoration bg-bottom-left"></view>
<view class="top-image-section">
<view class="image-container">
<view class="image-wrapper">
<image class="main-image" src="/images/mbti-team-image.png" mode="aspectFit"></image>
</view>
<view class="float-tag tag-1" wx:if="{{!reviewMode && permFace}}">面相分析</view>
<view class="float-tag tag-2" wx:if="{{!reviewMode && permFace}}">骨相分析</view>
<view class="float-tag tag-3">性格测评</view>
<view class="float-tag tag-1" wx:if="{{reviewMode}}">MBTI</view>
<view class="float-tag tag-2" wx:if="{{reviewMode}}">DISC</view>
</view>
</view>
<view class="bottom-actions">
<button
class="action-btn {{canAnalyze ? 'action-btn--primary' : 'action-btn--disabled'}}"
loading="{{analyzing}}"
bindtap="onAnalyzeTap"
disabled="{{analyzing}}"
>
{{canAnalyze ? '开始综合分析' : '综合分析(未完成)'}}
</button>
<view class="process-section">
<view class="section-title">测试流程</view>
<view class="process-steps" wx:if="{{!reviewMode && permFace}}">
<view class="step-item">
<view class="step-circle active">1</view>
<text class="step-label">STEP1</text>
<text class="step-text">拍摄照片</text>
</view>
<view class="step-line"></view>
<view class="step-item">
<view class="step-circle">2</view>
<text class="step-label">STEP2</text>
<text class="step-text">{{aiAnalysisText || '分析'}}</text>
</view>
<view class="step-line"></view>
<view class="step-item">
<view class="step-circle">3</view>
<text class="step-label">STEP3</text>
<text class="step-text">生成报告</text>
</view>
</view>
<view class="process-steps" wx:if="{{reviewMode || !permFace}}">
<view class="step-item">
<view class="step-circle active">1</view>
<text class="step-label">STEP1</text>
<text class="step-text">选择测试</text>
</view>
<view class="step-line"></view>
<view class="step-item">
<view class="step-circle">2</view>
<text class="step-label">STEP2</text>
<text class="step-text">回答问题</text>
</view>
<view class="step-line"></view>
<view class="step-item">
<view class="step-circle">3</view>
<text class="step-label">STEP3</text>
<text class="step-text">查看结果</text>
</view>
</view>
</view>
<view class="start-button" bindtap="startAITest">
<text class="button-text">{{startButtonGaokao || '开始性格测试'}}</text>
</view>
<view class="task-center-entry" bindtap="openTaskCenter">
<text class="task-center-entry__icon">🗂️</text>
<text class="task-center-entry__text">进入高考志愿任务中心(含历史记录)</text>
<text class="task-center-entry__arrow"></text>
</view>
<view class="version-picker-mask" wx:if="{{showVersionPicker}}" bindtap="closeVersionPicker">
<view class="version-picker-dialog" catchtap="noop">
<view class="version-picker-head">
<view class="version-picker-badge">版本选择</view>
<view class="version-picker-close" bindtap="closeVersionPicker">×</view>
</view>
<view class="version-picker-title">请选择使用版本</view>
<view class="version-picker-desc">当前企业支持三种版本,选择后将进入对应流程</view>
<view class="version-picker-actions">
<button class="version-btn version-btn-personal" data-version="personal" bindtap="selectVersion">个人版</button>
<button class="version-btn version-btn-enterprise" data-version="enterprise" bindtap="selectVersion">企业版</button>
<button class="version-btn version-btn-gaokao" data-version="gaokao" bindtap="selectVersion">高考版</button>
</view>
<view class="version-picker-foot">
<button class="version-cancel-btn" bindtap="closeVersionPicker">取消</button>
</view>
</view>
</view>
<custom-tab-bar />
</view>

View File

@@ -1,142 +1,380 @@
.page {
min-height: 100vh;
background: #f7f8fc;
box-sizing: border-box;
}
.container {
min-height: 100%;
background: #f7f8fc;
padding: 32rpx 24rpx;
/* 底部固定操作条占位(单主按钮 + 安全区) */
padding-bottom: calc(140rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.header {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
}
.title {
font-size: 36rpx;
font-weight: 700;
color: #222;
}
.desc {
font-size: 24rpx;
color: #666;
margin-top: 8rpx;
}
.task-list {
min-height: 100vh;
width: 100vw;
overflow-x: hidden;
overflow-y: auto;
background: #f3f6fb;
position: relative;
display: flex;
flex-direction: column;
gap: 16rpx;
padding-bottom: calc(100rpx + env(safe-area-inset-bottom) + 40rpx);
box-sizing: border-box;
}
.task-card {
/* 背景装饰圆形 */
.bg-decoration {
position: absolute;
width: 600rpx;
height: 600rpx;
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
.bg-top-right {
top: -200rpx;
right: -200rpx;
background: radial-gradient(circle, rgba(37, 99, 235, 0.08) 0%, transparent 70%);
}
.bg-bottom-left {
bottom: 0;
left: 0;
background: radial-gradient(circle, rgba(37, 99, 235, 0.05) 0%, transparent 70%);
}
.custom-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
background: #ffffff;
z-index: 10000;
width: 100%;
}
.navbar-content {
height: 88rpx;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 40rpx;
position: relative;
background: #fff;
border-radius: 14rpx;
padding: 22rpx 20rpx;
min-height: 88rpx;
}
.navbar-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
font-size: 36rpx;
font-weight: 700;
color: #1e3a8a;
text-align: center;
flex: 1;
}
.navbar-placeholder {
width: 140rpx;
flex-shrink: 0;
}
.switch-version-btn {
display: flex;
align-items: center;
gap: 8rpx;
padding: 12rpx 20rpx;
background: #e8f0ff;
border-radius: 30rpx;
border: 1rpx solid rgba(37, 99, 235, 0.3);
flex-shrink: 0;
z-index: 10;
}
.switch-version-icon {
font-size: 24rpx;
color: #1d4ed8;
}
.switch-version-text {
font-size: 24rpx;
color: #2563eb;
font-weight: 700;
}
.top-image-section {
width: 100%;
padding: 15rpx 40rpx 20rpx;
flex-shrink: 0;
box-sizing: border-box;
position: relative;
z-index: 1;
margin-top: 0;
}
.image-container {
position: relative;
width: 100%;
}
.image-wrapper {
position: relative;
width: 100%;
padding-top: 100%;
border-radius: 50%;
overflow: hidden;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.12);
}
.main-image {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 85%;
height: 85%;
display: block;
}
.float-tag {
position: absolute;
padding: 12rpx 24rpx;
border-radius: 30rpx;
font-size: 24rpx;
font-weight: 600;
color: #2563eb;
background: #ffffff;
box-shadow: 0 4rpx 14rpx rgba(37, 99, 235, 0.12);
white-space: nowrap;
z-index: 10;
}
.tag-1 {
top: 15%;
right: 10rpx;
}
.tag-2 {
bottom: 25%;
left: 0;
}
.tag-3 {
bottom: 25%;
right: 0;
}
.process-section {
padding: 15rpx 40rpx 20rpx;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.section-title {
font-size: 32rpx;
font-weight: 700;
color: #2563eb;
text-align: center;
margin-bottom: 25rpx;
}
.process-steps {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 10rpx;
}
.step-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.step-circle {
width: 70rpx;
height: 70rpx;
border-radius: 50%;
background: #b8b9bc;
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
font-weight: 700;
color: #fff;
margin-bottom: 12rpx;
}
.step-circle.active {
background: #2563eb;
}
.step-label {
font-size: 20rpx;
color: #9ca3af;
margin-bottom: 4rpx;
}
.step-text {
font-size: 24rpx;
color: #374151;
font-weight: 600;
}
.step-line {
width: 50rpx;
height: 4rpx;
background: #e0e0e0;
margin: 0 6rpx 50rpx;
}
.start-button {
margin: 20rpx 40rpx 0;
background: #2563eb;
border-radius: 50rpx;
padding: 24rpx;
text-align: center;
box-shadow: 0 8rpx 24rpx rgba(37, 99, 235, 0.2);
position: relative;
z-index: 1;
}
.task-center-entry {
margin: 18rpx 36rpx 0;
background: #ffffff;
border: 1rpx solid #dbeafe;
border-radius: 18rpx;
padding: 22rpx 24rpx;
display: flex;
align-items: center;
box-shadow: 0 8rpx 18rpx rgba(37, 99, 235, 0.08);
}
.task-center-entry__icon {
font-size: 34rpx;
}
.task-center-entry__text {
flex: 1;
margin-left: 12rpx;
color: #1e3a8a;
font-size: 26rpx;
font-weight: 500;
}
.task-center-entry__arrow {
color: #93c5fd;
font-size: 34rpx;
}
.button-text {
font-size: 30rpx;
color: #fff;
font-weight: 700;
}
.version-picker-mask {
position: fixed;
inset: 0;
background: rgba(10, 15, 30, 0.52);
z-index: 20000;
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx;
box-sizing: border-box;
}
.version-picker-dialog {
width: 100%;
max-width: 640rpx;
background: #fff;
border-radius: 30rpx;
padding: 26rpx 30rpx 24rpx;
box-shadow: 0 24rpx 70rpx rgba(16, 24, 40, 0.28);
}
.version-picker-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.task-title-row {
display: flex;
align-items: center;
gap: 12rpx;
.version-picker-badge {
padding: 8rpx 18rpx;
border-radius: 999rpx;
background: #e8f0ff;
color: #1d4ed8;
font-size: 22rpx;
font-weight: 600;
}
.task-emoji {
font-size: 36rpx;
.version-picker-close {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: #f3f4f6;
color: #6b7280;
display: flex;
align-items: center;
justify-content: center;
font-size: 34rpx;
line-height: 1;
}
.name {
font-size: 30rpx;
color: #222;
font-weight: 600;
.version-picker-title {
text-align: center;
font-size: 36rpx;
font-weight: 700;
color: #111827;
margin-top: 12rpx;
}
.task-time {
margin-top: 6rpx;
font-size: 22rpx;
color: #9ca3af;
}
.result {
margin-top: 8rpx;
.version-picker-desc {
margin-top: 12rpx;
text-align: center;
font-size: 24rpx;
color: #666;
color: #6b7280;
line-height: 1.6;
}
.result--value {
font-size: 30rpx;
font-weight: 600;
color: #4f46e5;
}
.result.todo {
color: #d04848;
}
.status {
font-size: 24rpx;
color: #7c3aed;
}
.status.done {
color: #1f9d55;
}
/* 底部悬浮:全宽主操作 */
.bottom-actions {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 200;
width: 100%;
box-sizing: border-box;
padding: 16rpx 24rpx;
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 -8rpx 32rpx rgba(15, 23, 42, 0.08);
border-top: 1rpx solid #eef0f4;
.version-picker-actions {
margin-top: 28rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.action-btn {
width: 100% !important;
margin: 0;
padding: 24rpx 32rpx;
line-height: 1.35;
border-radius: 16rpx;
font-size: 30rpx;
font-weight: 700;
border: none;
box-sizing: border-box;
}
.action-btn::after {
border: none;
}
.action-btn--primary {
background: #7c3aed;
.version-btn {
height: 84rpx;
line-height: 84rpx;
border-radius: 999rpx;
color: #fff;
font-size: 29rpx;
font-weight: 600;
border: none;
box-shadow: 0 6rpx 16rpx rgba(37, 99, 235, 0.16);
}
.action-btn--disabled {
background: #e5e7eb;
color: #9ca3af;
.version-btn-personal { background: #2563eb; }
.version-btn-enterprise { background: #2563eb; }
.version-btn-gaokao { background: #2563eb; }
.version-picker-foot {
margin-top: 14rpx;
}
.version-cancel-btn {
height: 76rpx;
line-height: 76rpx;
border-radius: 999rpx;
background: #f3f4f6;
color: #4b5563;
font-size: 27rpx;
font-weight: 600;
border: 1rpx solid #d1d5db;
}
custom-tab-bar {
display: block;
height: 0;
overflow: visible;
}

View File

@@ -13,7 +13,6 @@ const inviteCodeGate = require('../../utils/inviteCodeGate.js')
const { openTimelineShareHint } = require('../../utils/resultProfileGate.js')
const { computeJourney, markShared } = require('../../utils/gaokaoJourneyState.js')
const { getEnterpriseIdForApiPayload } = require('../../utils/enterpriseContext.js')
const { isAuditHideAiMode, redirectIfMiniprogramAudit } = require('../../utils/miniprogramAuditGate.js')
/** GET /api/test/detail | share-detail 返回体 -> mergeApiReport 入参 */
function mapTestDetailToReportPayload(detail) {
@@ -242,60 +241,55 @@ Page({
},
onLoad(options) {
const bootstrap = () => {
if (isAuditHideAiMode(app.globalData)) {
redirectIfMiniprogramAudit('版本审核中暂不可用')
return
}
try {
wx.showShareMenu({ withShareTicket: true, menus: ['shareAppMessage', 'shareTimeline'] })
} catch (e) {}
try {
wx.showShareMenu({ withShareTicket: true, menus: ['shareAppMessage', 'shareTimeline'] })
} catch (e) {}
const fromShareFs = options && (String(options.fs) === '1' || options.from === 'share')
const sid = options && options.id != null && options.id !== '' ? String(options.id) : ''
const st = options && options.st ? String(options.st).trim() : ''
const fromShareFs = options && (String(options.fs) === '1' || options.from === 'share')
const sid = options && options.id != null && options.id !== '' ? String(options.id) : ''
const st = options && options.st ? String(options.st).trim() : ''
if (sid && st) {
this.setData({ fromShare: true })
this.loadShareDetail(sid, st)
return
}
const ec =
typeof this.getOpenerEventChannel === 'function' ? this.getOpenerEventChannel() : null
if (ec && typeof ec.once === 'function') {
ec.once('gaokaoAnalyzeReport', (payload) => {
if (payload && payload.report) {
this.applyPayloadOnly({
id: payload.id,
createdAt: payload.createdAt || 0,
overview: payload.overview || '',
report: payload.report
})
}
})
}
const pendingAnalyze = options && String(options.pendingAnalyze) === '1'
if (pendingAnalyze) {
// 等 runtime 后再跑;用微任务启动分析,避免原 onReady 与异步 onLoad 竞态
setTimeout(() => this.beginAnalyzeFlow(), 0)
return
}
const rid = options && options.id != null ? parseInt(String(options.id), 10) : 0
this._detailReportId = rid > 0 && !Number.isNaN(rid) ? rid : 0
if (fromShareFs) {
this.setData({ fromShare: true })
}
const delay = options && options.fromAnalyze === '1' ? 400 : 0
setTimeout(() => this.load(), delay)
if (sid && st) {
this.setData({ fromShare: true })
this.loadShareDetail(sid, st)
return
}
if (app.getRuntimeConfig) {
app.getRuntimeConfig().then(bootstrap).catch(bootstrap)
} else {
bootstrap()
const ec =
typeof this.getOpenerEventChannel === 'function' ? this.getOpenerEventChannel() : null
if (ec && typeof ec.once === 'function') {
ec.once('gaokaoAnalyzeReport', (payload) => {
if (payload && payload.report) {
this.applyPayloadOnly({
id: payload.id,
createdAt: payload.createdAt || 0,
overview: payload.overview || '',
report: payload.report
})
}
})
}
const pendingAnalyze = options && String(options.pendingAnalyze) === '1'
if (pendingAnalyze) {
this._pendingAnalyze = true
return
}
const rid = options && options.id != null ? parseInt(String(options.id), 10) : 0
this._detailReportId = rid > 0 && !Number.isNaN(rid) ? rid : 0
if (fromShareFs) {
this.setData({ fromShare: true })
}
const delay = options && options.fromAnalyze === '1' ? 400 : 0
setTimeout(() => this.load(), delay)
},
onReady() {
if (this._pendingAnalyze) {
this._pendingAnalyze = false
this.beginAnalyzeFlow()
}
},

View File

@@ -0,0 +1,116 @@
const gaokaoApi = require('../../utils/gaokao')
Page({
data: {
loading: false,
tasks: {
mbti: { code: 'mbti', status: 'todo', resultText: '', testResultId: 0, id: 0, testType: 'mbti', typeName: 'MBTI性格', emoji: '🧠', testTime: '' },
pdp: { code: 'pdp', status: 'todo', resultText: '', testResultId: 0, id: 0, testType: 'pdp', typeName: 'PDP行为', emoji: '🦁', testTime: '' },
disc: { code: 'disc', status: 'todo', resultText: '', testResultId: 0, id: 0, testType: 'disc', typeName: 'DISC测评', emoji: '📊', testTime: '' },
face: { code: 'face', status: 'todo', resultText: '', testResultId: 0, id: 0, testType: 'ai', typeName: '拍照面相', emoji: '📷', testTime: '', recordTestType: 'ai' },
form: { code: 'form', status: 'todo', resultText: '', testResultId: 0, id: 0, testType: 'form', typeName: '高考信息表单', emoji: '📝', testTime: '' }
},
canAnalyze: false,
missingItems: [],
analyzing: false
},
onShow() {
try {
const app = getApp()
app.globalData.appScope = 'gaokao'
app.globalData.selectedHomeVersion = 'gaokao'
} catch (e) {}
this.refreshStatus()
},
refreshStatus() {
this.setData({ loading: true })
gaokaoApi
.getTaskStatus({ scene: 'gaokao_hub' })
.then((res) => {
this.setData({
tasks: res.tasks || this.data.tasks,
canAnalyze: !!res.canAnalyze,
missingItems: res.missingItems || []
})
})
.catch((e) => {
wx.showToast({ title: e.message || '加载失败', icon: 'none' })
})
.finally(() => this.setData({ loading: false }))
},
goTask(e) {
const code = e.currentTarget.dataset.code
const resultId = Number(e.currentTarget.dataset.resultId || 0)
const done = (e.currentTarget.dataset.status || '') === 'done'
if (code === 'mbti') {
if (done && resultId > 0) {
wx.navigateTo({ url: `/pages/result/mbti?id=${resultId}&type=mbti&vscope=gaokao` })
} else {
wx.navigateTo({ url: '/pages/test/mbti' })
}
return
}
if (code === 'pdp') {
if (done && resultId > 0) {
wx.navigateTo({ url: `/pages/result/pdp?id=${resultId}&type=pdp&vscope=gaokao` })
} else {
wx.navigateTo({ url: '/pages/test/pdp' })
}
return
}
if (code === 'disc') {
if (done && resultId > 0) {
wx.navigateTo({ url: `/pages/result/disc?id=${resultId}&type=disc&vscope=gaokao` })
} else {
wx.navigateTo({ url: '/pages/test/disc' })
}
return
}
if (code === 'face') {
const recType = String(e.currentTarget.dataset.recordType || 'ai').toLowerCase()
const typeParam = recType === 'face' ? 'face' : 'ai'
if (done && resultId > 0) {
wx.navigateTo({ url: `/pages/index/result?id=${resultId}&type=${typeParam}&vscope=gaokao` })
} else {
wx.switchTab({ url: '/pages/index/camera' })
}
return
}
if (code === 'form') {
wx.navigateTo({ url: '/pages/gaokao/form' })
}
},
onAnalyzeTap() {
if (!this.data.canAnalyze) {
const nameMap = {
mbti: 'MBTI测试',
pdp: 'PDP测试',
disc: 'DISC测试',
face: '拍照面相',
form: '高考信息表单'
}
const msg = (this.data.missingItems || []).map((k) => nameMap[k] || k).join('、')
wx.showToast({
title: msg ? `请先完成:${msg}` : '请先完成全部任务',
icon: 'none',
duration: 2500
})
return
}
this.setData({ analyzing: true })
wx.navigateTo({
url: '/pages/gaokao/report?pendingAnalyze=1',
complete: () => {
this.setData({ analyzing: false })
}
})
},
goHistoryTests() {
wx.navigateTo({ url: '/pages/history/index?gaokaoOnly=1' })
}
})

View File

@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "高考志愿任务中心",
"enablePullDownRefresh": true
}

View File

@@ -0,0 +1,93 @@
<view class="page">
<view class="container">
<view class="header">
<view class="title">高考志愿任务中心</view>
<view class="desc">完成 MBTI、PDP、DISC、拍照面相与高考信息表单后即可生成综合分析若定价非 0请在报告页解锁全文与问卷类测评一致。</view>
</view>
<view class="task-list">
<view class="task-card" data-code="mbti" data-status="{{tasks.mbti.status}}" data-result-id="{{tasks.mbti.testResultId}}" bindtap="goTask">
<view class="left">
<view class="task-title-row">
<text class="task-emoji">{{tasks.mbti.emoji}}</text>
<text class="name">{{tasks.mbti.typeName}}</text>
</view>
<view class="result result--value" wx:if="{{tasks.mbti.status==='done'}}">{{tasks.mbti.resultText || '已完成'}}</view>
<view class="result todo" wx:else>未完成,请去测试</view>
<view class="task-time" wx:if="{{tasks.mbti.status==='done' && tasks.mbti.testTime}}">{{tasks.mbti.testTime}}</view>
</view>
<view class="status {{tasks.mbti.status==='done'?'done':''}}">{{tasks.mbti.status==='done'?'查看结果':'去完成'}}</view>
</view>
<view class="task-card" data-code="pdp" data-status="{{tasks.pdp.status}}" data-result-id="{{tasks.pdp.testResultId}}" bindtap="goTask">
<view class="left">
<view class="task-title-row">
<text class="task-emoji">{{tasks.pdp.emoji}}</text>
<text class="name">{{tasks.pdp.typeName}}</text>
</view>
<view class="result result--value" wx:if="{{tasks.pdp.status==='done'}}">{{tasks.pdp.resultText || '已完成'}}</view>
<view class="result todo" wx:else>未完成,请去测试</view>
<view class="task-time" wx:if="{{tasks.pdp.status==='done' && tasks.pdp.testTime}}">{{tasks.pdp.testTime}}</view>
</view>
<view class="status {{tasks.pdp.status==='done'?'done':''}}">{{tasks.pdp.status==='done'?'查看结果':'去完成'}}</view>
</view>
<view class="task-card" data-code="disc" data-status="{{tasks.disc.status}}" data-result-id="{{tasks.disc.testResultId}}" bindtap="goTask">
<view class="left">
<view class="task-title-row">
<text class="task-emoji">{{tasks.disc.emoji}}</text>
<text class="name">{{tasks.disc.typeName}}</text>
</view>
<view class="result result--value" wx:if="{{tasks.disc.status==='done'}}">{{tasks.disc.resultText || '已完成'}}</view>
<view class="result todo" wx:else>未完成,请去测试</view>
<view class="task-time" wx:if="{{tasks.disc.status==='done' && tasks.disc.testTime}}">{{tasks.disc.testTime}}</view>
</view>
<view class="status {{tasks.disc.status==='done'?'done':''}}">{{tasks.disc.status==='done'?'查看结果':'去完成'}}</view>
</view>
<view class="task-card" data-code="face" data-status="{{tasks.face.status}}" data-result-id="{{tasks.face.testResultId}}" data-record-type="{{tasks.face.recordTestType}}" bindtap="goTask">
<view class="left">
<view class="task-title-row">
<text class="task-emoji">{{tasks.face.emoji}}</text>
<text class="name">{{tasks.face.typeName}}</text>
</view>
<view class="result result--value" wx:if="{{tasks.face.status==='done'}}">{{tasks.face.resultText || '已完成'}}</view>
<view class="result todo" wx:else>未完成,请去拍摄</view>
<view class="task-time" wx:if="{{tasks.face.status==='done' && tasks.face.testTime}}">{{tasks.face.testTime}}</view>
</view>
<view class="status {{tasks.face.status==='done'?'done':''}}">{{tasks.face.status==='done'?'查看结果':'去完成'}}</view>
</view>
<view class="task-card" data-code="form" data-status="{{tasks.form.status}}" data-result-id="{{tasks.form.testResultId}}" bindtap="goTask">
<view class="left">
<view class="task-title-row">
<text class="task-emoji">{{tasks.form.emoji}}</text>
<text class="name">{{tasks.form.typeName}}</text>
</view>
<view class="result result--value" wx:if="{{tasks.form.status==='done'}}">
<block wx:if="{{tasks.form.resultText}}">已填写:{{tasks.form.resultText}}</block>
<block wx:else>已完成</block>
</view>
<view class="result todo" wx:else>未完成,请先填写</view>
<view class="task-time" wx:if="{{tasks.form.status==='done' && tasks.form.testTime}}">{{tasks.form.testTime}}</view>
</view>
<view class="status {{tasks.form.status==='done'?'done':''}}">{{tasks.form.status==='done'?'查看结果':'去完成'}}</view>
</view>
</view>
<view class="history-entry" bindtap="goHistoryTests">
<text class="history-entry__text">历史测评记录</text>
</view>
</view>
<view class="bottom-actions">
<button
class="action-btn {{canAnalyze ? 'action-btn--primary' : 'action-btn--disabled'}}"
loading="{{analyzing}}"
bindtap="onAnalyzeTap"
disabled="{{analyzing}}"
>
{{canAnalyze ? '开始综合分析' : '综合分析(未完成)'}}
</button>
</view>
</view>

View File

@@ -0,0 +1,154 @@
.page {
min-height: 100vh;
background: #f7f8fc;
box-sizing: border-box;
}
.container {
min-height: 100%;
background: #f7f8fc;
padding: 32rpx 24rpx;
/* 底部固定操作条占位(单主按钮 + 安全区) */
padding-bottom: calc(140rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.header {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
}
.title {
font-size: 36rpx;
font-weight: 700;
color: #222;
}
.desc {
font-size: 24rpx;
color: #666;
margin-top: 8rpx;
}
.task-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.task-card {
background: #fff;
border-radius: 14rpx;
padding: 22rpx 20rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.task-title-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.task-emoji {
font-size: 36rpx;
line-height: 1;
}
.name {
font-size: 30rpx;
color: #222;
font-weight: 600;
}
.task-time {
margin-top: 6rpx;
font-size: 22rpx;
color: #9ca3af;
}
.result {
margin-top: 8rpx;
font-size: 24rpx;
color: #666;
}
.result--value {
font-size: 30rpx;
font-weight: 600;
color: #4f46e5;
}
.result.todo {
color: #d04848;
}
.status {
font-size: 24rpx;
color: #7c3aed;
}
.status.done {
color: #1f9d55;
}
.history-entry {
margin: 18rpx auto 0;
width: 100%;
padding: 8rpx 0;
text-align: center;
}
.history-entry__text {
font-size: 24rpx;
color: #9ca3af;
font-weight: 500;
}
/* 底部悬浮:全宽主操作 */
.bottom-actions {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 200;
width: 100%;
box-sizing: border-box;
padding: 16rpx 24rpx;
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 -8rpx 32rpx rgba(15, 23, 42, 0.08);
border-top: 1rpx solid #eef0f4;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.action-btn {
width: 100% !important;
margin: 0;
padding: 24rpx 32rpx;
line-height: 1.35;
border-radius: 16rpx;
font-size: 30rpx;
font-weight: 700;
border: none;
box-sizing: border-box;
}
.action-btn::after {
border: none;
}
.action-btn--primary {
background: #7c3aed;
color: #fff;
}
.action-btn--disabled {
background: #e5e7eb;
color: #9ca3af;
}

Some files were not shown because too many files have changed in this diff Show More