高考版优化

审核模式优化
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

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)
}