高考版优化

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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