高考版优化
审核模式优化
This commit is contained in:
2067
admin/pnpm-lock.yaml
generated
Normal file
2067
admin/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
516
admin/src/components/OrderStats.vue
Normal file
516
admin/src/components/OrderStats.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
1406
admin/src/views/MiniProgramPreview.vue
Normal file
1406
admin/src/views/MiniProgramPreview.vue
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
493
admin/src/views/superadmin/GaokaoHub.vue
Normal file
493
admin/src/views/superadmin/GaokaoHub.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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 天人脸分析、MBTI、PDP、DISC 等关键测试的完成情况</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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ class TestProductPricing
|
||||
|
||||
/**
|
||||
* @param int|null $enterpriseId 用于查价的企业 ID:personal 时走 admin_personal+eid;enterprise 时走 admin_enterprise+eid
|
||||
* @param string $pricingTier personal=用户侧个人版定价(含企业后台「个人版」专属价);enterprise=企业版定价
|
||||
* @return array{0:int,1:string} [amountFen, pricingType personal|enterprise]
|
||||
* @param string $pricingTier personal|enterprise|gaokao;gaokao=高考版全局/企业 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];
|
||||
}
|
||||
}
|
||||
|
||||
100
api/app/controller/admin/Analytics.php
Normal file
100
api/app/controller/admin/Analytics.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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_personal);pricingScope=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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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|gaokao:gaokao 读取 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:解析定价用的 enterpriseId(enterprise / 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]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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, '归并完成');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
311
api/app/controller/superadmin/Gaokao.php
Normal file
311
api/app/controller/superadmin/Gaokao.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ class Enterprise extends Model
|
||||
'contactName' => 'string',
|
||||
'contactPhone' => 'string',
|
||||
'contactEmail' => 'string',
|
||||
'idCardNumber' => 'string',
|
||||
'businessLicenseUrl' => 'string',
|
||||
'balance' => 'float',
|
||||
'status' => 'string',
|
||||
'permissions' => 'json',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
153
api/docs/高考版全局定价开发清单.md
Normal file
153
api/docs/高考版全局定价开发清单.md
Normal 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 版本场景截图)。
|
||||
- 更新后的产品文档与运维说明。
|
||||
186
api/docs/高考版开关与全局定价收口说明.md
Normal file
186
api/docs/高考版开关与全局定价收口说明.md
Normal 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. 备注
|
||||
|
||||
本文档是需求规格稿,先用于评审与排期;你确认后再按本文档开始开发。
|
||||
@@ -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
2
h5-vue/.env.development
Normal file
@@ -0,0 +1,2 @@
|
||||
# 后端 API 根地址(与小程序合法域名一致)。开发时可用 Vite 代理 /api
|
||||
VITE_API_BASE=
|
||||
4
h5-vue/.gitignore
vendored
Normal file
4
h5-vue/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
*.local
|
||||
.DS_Store
|
||||
40
h5-vue/README.md
Normal file
40
h5-vue/README.md
Normal 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
15
h5-vue/env.d.ts
vendored
Normal 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
12
h5-vue/index.html
Normal 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
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
24
h5-vue/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
h5-vue/public/images/mbti-team-image.png
Normal file
BIN
h5-vue/public/images/mbti-team-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 264 KiB |
13
h5-vue/src/App.vue
Normal file
13
h5-vue/src/App.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style>
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
184
h5-vue/src/components/CustomTabBar.vue
Normal file
184
h5-vue/src/components/CustomTabBar.vue
Normal 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>
|
||||
19
h5-vue/src/layouts/TabLayout.vue
Normal file
19
h5-vue/src/layouts/TabLayout.vue
Normal 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
17
h5-vue/src/main.ts
Normal 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
112
h5-vue/src/router/index.ts
Normal 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
98
h5-vue/src/stores/app.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
28
h5-vue/src/styles/global.scss
Normal file
28
h5-vue/src/styles/global.scss
Normal 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
10
h5-vue/src/utils/audit.ts
Normal 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)
|
||||
}
|
||||
17
h5-vue/src/utils/enterpriseContext.ts
Normal file
17
h5-vue/src/utils/enterpriseContext.ts
Normal 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
|
||||
}
|
||||
82
h5-vue/src/utils/mbtiCalc.ts
Normal file
82
h5-vue/src/utils/mbtiCalc.ts
Normal 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] || {},
|
||||
}
|
||||
}
|
||||
159
h5-vue/src/utils/mbtiDescriptions.ts
Normal file
159
h5-vue/src/utils/mbtiDescriptions.ts
Normal 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', '企业家', '律师'],
|
||||
},
|
||||
}
|
||||
269
h5-vue/src/utils/mbtiLocalQuestions.ts
Normal file
269
h5-vue/src/utils/mbtiLocalQuestions.ts
Normal 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 }
|
||||
7
h5-vue/src/utils/navigateAfterTest.ts
Normal file
7
h5-vue/src/utils/navigateAfterTest.ts
Normal 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'))
|
||||
}
|
||||
174
h5-vue/src/utils/questionBank.ts
Normal file
174
h5-vue/src/utils/questionBank.ts
Normal 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))
|
||||
})
|
||||
}
|
||||
32
h5-vue/src/utils/request.ts
Normal file
32
h5-vue/src/utils/request.ts
Normal 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 }
|
||||
}
|
||||
24
h5-vue/src/utils/storage.ts
Normal file
24
h5-vue/src/utils/storage.ts
Normal 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 */
|
||||
}
|
||||
}
|
||||
52
h5-vue/src/utils/testSubmit.ts
Normal file
52
h5-vue/src/utils/testSubmit.ts
Normal 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
23
h5-vue/src/utils/toast.ts
Normal 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)
|
||||
}
|
||||
343
h5-vue/src/views/pages/TestSelectPage.vue
Normal file
343
h5-vue/src/views/pages/TestSelectPage.vue
Normal 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>
|
||||
103
h5-vue/src/views/stub/PageStub.vue
Normal file
103
h5-vue/src/views/stub/PageStub.vue
Normal 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>
|
||||
84
h5-vue/src/views/tab/CameraPage.vue
Normal file
84
h5-vue/src/views/tab/CameraPage.vue
Normal 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>
|
||||
392
h5-vue/src/views/tab/IndexHome.vue
Normal file
392
h5-vue/src/views/tab/IndexHome.vue
Normal 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>
|
||||
289
h5-vue/src/views/tab/ProfilePage.vue
Normal file
289
h5-vue/src/views/tab/ProfilePage.vue
Normal 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>
|
||||
599
h5-vue/src/views/test/MbtiTestPage.vue
Normal file
599
h5-vue/src/views/test/MbtiTestPage.vue
Normal 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
22
h5-vue/tsconfig.json
Normal 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
10
h5-vue/tsconfig.node.json
Normal 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
21
h5-vue/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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_view;onShareAppMessage 自动上报 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 解析出的企业ID(e_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 : '获取配置失败'))
|
||||
|
||||
@@ -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": "我的",
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 结合测评与正文回复(不跳转匹配工作页) */
|
||||
|
||||
@@ -3,7 +3,5 @@
|
||||
"navigationBarBackgroundColor": "#faf9ff",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#f4f3f9",
|
||||
"usingComponents": {
|
||||
"custom-tab-bar": "/custom-tab-bar/index"
|
||||
}
|
||||
"usingComponents": {}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"navigationBarTitleText": "高考志愿"
|
||||
"navigationStyle": "custom",
|
||||
"usingComponents": {
|
||||
"custom-tab-bar": "/custom-tab-bar/index"
|
||||
},
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
116
miniprogram/pages/gaokao/task-center.js
Normal file
116
miniprogram/pages/gaokao/task-center.js
Normal 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' })
|
||||
}
|
||||
})
|
||||
4
miniprogram/pages/gaokao/task-center.json
Normal file
4
miniprogram/pages/gaokao/task-center.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "高考志愿任务中心",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
93
miniprogram/pages/gaokao/task-center.wxml
Normal file
93
miniprogram/pages/gaokao/task-center.wxml
Normal 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>
|
||||
154
miniprogram/pages/gaokao/task-center.wxss
Normal file
154
miniprogram/pages/gaokao/task-center.wxss
Normal 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
Reference in New Issue
Block a user