feat: 小程序与管理端迭代(神仙AI、了解自己CRM、AI测试入口、报表与分润等)
Made-with: Cursor
This commit is contained in:
@@ -1,10 +1,49 @@
|
||||
<template>
|
||||
<div class="dashboard-viewport" v-loading="loading">
|
||||
<header class="dash-head">
|
||||
<h1 class="dash-title">数据概览</h1>
|
||||
<p class="dash-tagline"></p>
|
||||
<h1 class="dash-title">企业概览</h1>
|
||||
<p class="dash-tagline">实时掌握余额、扣款、冻结佣金与补充建议</p>
|
||||
</header>
|
||||
|
||||
<div class="wallet-strip">
|
||||
<div class="wallet-card wallet-card--primary">
|
||||
<div class="wallet-info">
|
||||
<div class="wallet-label">当前余额</div>
|
||||
<div class="wallet-value">¥{{ fenToYuan(finance.balanceFen) }}</div>
|
||||
<div class="wallet-foot">历史充值 ¥{{ fenToYuan(finance.manualRechargeFen) }}</div>
|
||||
</div>
|
||||
<div class="wallet-icon"><el-icon><Wallet /></el-icon></div>
|
||||
<el-button type="primary" size="small" class="wallet-action" @click="openRechargeDialog">立即充值</el-button>
|
||||
</div>
|
||||
<div class="wallet-card wallet-card--consume">
|
||||
<div class="wallet-info">
|
||||
<div class="wallet-label">本月已扣款</div>
|
||||
<div class="wallet-value">¥{{ fenToYuan(finance.monthConsumeFen) }}</div>
|
||||
<div class="wallet-foot">日均 ¥{{ fenToYuan(finance.avgDailyConsumeFen) }} · 今日 ¥{{ fenToYuan(finance.todayConsumeFen) }}</div>
|
||||
</div>
|
||||
<div class="wallet-icon"><el-icon><TrendCharts /></el-icon></div>
|
||||
</div>
|
||||
<div class="wallet-card wallet-card--frozen">
|
||||
<div class="wallet-info">
|
||||
<div class="wallet-label">冻结佣金</div>
|
||||
<div class="wallet-value">¥{{ fenToYuan(finance.frozenCommissionFen) }}</div>
|
||||
<div class="wallet-foot">余额补足后自动解冻</div>
|
||||
</div>
|
||||
<div class="wallet-icon"><el-icon><Lock /></el-icon></div>
|
||||
</div>
|
||||
<div class="wallet-card wallet-card--suggest">
|
||||
<div class="wallet-info">
|
||||
<div class="wallet-label">建议充值</div>
|
||||
<div class="wallet-value">
|
||||
<template v-if="finance.suggestRechargeFen > 0">¥{{ fenToYuan(finance.suggestRechargeFen) }}</template>
|
||||
<template v-else>—</template>
|
||||
</div>
|
||||
<div class="wallet-foot">{{ suggestHint }}</div>
|
||||
</div>
|
||||
<div class="wallet-icon"><el-icon><WarningFilled /></el-icon></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dash-kpis">
|
||||
<div v-for="(card, i) in kpiCards" :key="card.key" class="stat-card" :style="{ animationDelay: `${i * 45}ms` }">
|
||||
<div class="stat-info">
|
||||
@@ -15,21 +54,43 @@
|
||||
<el-icon><component :is="card.icon" /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card-recharge" :style="{ animationDelay: `${kpiCards.length * 45}ms` }">
|
||||
<div class="stat-card-recharge-inner">
|
||||
<div class="stat-info stat-info--recharge">
|
||||
<div class="stat-label">企业余额</div>
|
||||
<div class="stat-value-row">
|
||||
<span class="stat-value">{{ enterpriseBalanceDisplay }}</span>
|
||||
<el-button link type="primary" size="small" class="recharge-btn-inline" @click="goEnterpriseRecharge">充值</el-button>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="rechargeVisible" title="企业余额充值" width="420px" :close-on-click-modal="false" destroy-on-close>
|
||||
<div class="recharge-dialog">
|
||||
<el-form :model="rechargeForm" label-width="80px" size="default">
|
||||
<el-form-item label="金额(元)">
|
||||
<el-input-number
|
||||
v-model="rechargeForm.amountYuan"
|
||||
:min="1"
|
||||
:max="100000"
|
||||
:step="100"
|
||||
controls-position="right"
|
||||
style="width:100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="quickAmounts.length" label="快速">
|
||||
<div class="recharge-quick">
|
||||
<el-button
|
||||
v-for="a in quickAmounts"
|
||||
:key="a"
|
||||
size="small"
|
||||
:type="rechargeForm.amountYuan === a ? 'primary' : 'default'"
|
||||
@click="rechargeForm.amountYuan = a"
|
||||
>¥{{ a }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="recharge-action">
|
||||
<el-button type="primary" :loading="rechargeLoading" @click="generateRechargeQr">生成充值码</el-button>
|
||||
<span class="recharge-tip">用微信扫码,在小程序完成支付</span>
|
||||
</div>
|
||||
<div class="stat-icon amber">
|
||||
<el-icon><Wallet /></el-icon>
|
||||
<div v-if="rechargeQrcode" class="recharge-qr">
|
||||
<img :src="rechargeQrcode" alt="充值二维码" />
|
||||
<div class="recharge-qr-text">扫码支付 ¥{{ rechargeAmountYuanDisplay }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<div class="dash-catalog" v-if="testCatalog.length">
|
||||
<div
|
||||
@@ -158,7 +219,9 @@ import {
|
||||
Reading,
|
||||
Histogram,
|
||||
Medal,
|
||||
Wallet
|
||||
Wallet,
|
||||
Lock,
|
||||
WarningFilled
|
||||
} from '@element-plus/icons-vue'
|
||||
import { request } from '@/utils/request'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -192,12 +255,65 @@ const stats = reactive({
|
||||
activeToday: 0
|
||||
})
|
||||
|
||||
/** 企业余额(分),来自 /admin/finance/overview,与财务页一致 */
|
||||
const balanceFen = ref(0)
|
||||
/** 企业钱包概览(分),来自 /admin/finance/overview */
|
||||
const finance = reactive({
|
||||
balanceFen: 0,
|
||||
manualRechargeFen: 0,
|
||||
frozenCommissionFen: 0,
|
||||
monthConsumeFen: 0,
|
||||
todayConsumeFen: 0,
|
||||
avgDailyConsumeFen: 0,
|
||||
suggestRechargeFen: 0,
|
||||
})
|
||||
|
||||
const fenToYuan = (fen: number) => (Number(fen || 0) / 100).toFixed(2)
|
||||
|
||||
const enterpriseBalanceDisplay = computed(() => `¥${fenToYuan(balanceFen.value)}`)
|
||||
const suggestHint = computed(() => {
|
||||
if (finance.avgDailyConsumeFen <= 0) return '暂无消耗,按需充值即可'
|
||||
if (finance.suggestRechargeFen <= 0) return '余额可支撑 > 14 日'
|
||||
return '≥ 2 周使用量'
|
||||
})
|
||||
|
||||
const rechargeVisible = ref(false)
|
||||
const rechargeLoading = ref(false)
|
||||
const rechargeForm = reactive({ amountYuan: 500 })
|
||||
const rechargeQrcode = ref('')
|
||||
const quickAmounts = [100, 500, 1000, 3000, 5000]
|
||||
const rechargeAmountYuanDisplay = computed(() => Number(rechargeForm.amountYuan || 0).toFixed(2))
|
||||
|
||||
function openRechargeDialog() {
|
||||
rechargeQrcode.value = ''
|
||||
if (finance.suggestRechargeFen > 0) {
|
||||
rechargeForm.amountYuan = Math.max(100, Math.round(finance.suggestRechargeFen / 100))
|
||||
} else if (!rechargeForm.amountYuan) {
|
||||
rechargeForm.amountYuan = 500
|
||||
}
|
||||
rechargeVisible.value = true
|
||||
}
|
||||
|
||||
async function generateRechargeQr() {
|
||||
if (rechargeLoading.value) return
|
||||
const amountFen = Math.round(Number(rechargeForm.amountYuan || 0) * 100)
|
||||
if (amountFen <= 0) {
|
||||
ElMessage.warning('请先输入充值金额')
|
||||
return
|
||||
}
|
||||
rechargeLoading.value = true
|
||||
rechargeQrcode.value = ''
|
||||
try {
|
||||
const res: any = await request.post('/admin/finance/recharge-qrcode', { amountFen })
|
||||
const qr = res?.data?.qrcode || res?.qrcode
|
||||
if (typeof qr === 'string' && qr) {
|
||||
rechargeQrcode.value = qr
|
||||
} else {
|
||||
ElMessage.error(res?.message || '二维码生成失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '二维码生成失败')
|
||||
} finally {
|
||||
rechargeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const testTrends = ref<
|
||||
Array<{ date: string; face: number; mbti: number; pdp: number; disc: number; total: number }>
|
||||
@@ -244,7 +360,7 @@ const kpiCards = computed(() => [
|
||||
])
|
||||
|
||||
function goEnterpriseRecharge() {
|
||||
void router.push({ path: '/admin/settings', query: { tab: 'finance' } })
|
||||
openRechargeDialog()
|
||||
}
|
||||
|
||||
const catalogIconMap: Record<string, { icon: typeof Camera; tone: string }> = {
|
||||
@@ -389,9 +505,22 @@ function summarizeTypes(row: TopUserRow) {
|
||||
async function loadFinanceBalance() {
|
||||
try {
|
||||
const res: any = await request.get('/admin/finance/overview')
|
||||
balanceFen.value = Number(res?.data?.balanceFen ?? 0)
|
||||
const d = res?.data || {}
|
||||
finance.balanceFen = Number(d.balanceFen ?? 0)
|
||||
finance.manualRechargeFen = Number(d.manualRechargeFen ?? 0)
|
||||
finance.frozenCommissionFen = Number(d.frozenCommissionFen ?? 0)
|
||||
finance.monthConsumeFen = Number(d.monthConsumeFen ?? 0)
|
||||
finance.todayConsumeFen = Number(d.todayConsumeFen ?? 0)
|
||||
finance.avgDailyConsumeFen = Number(d.avgDailyConsumeFen ?? 0)
|
||||
finance.suggestRechargeFen = Number(d.suggestRechargeFen ?? 0)
|
||||
} catch {
|
||||
balanceFen.value = 0
|
||||
finance.balanceFen = 0
|
||||
finance.manualRechargeFen = 0
|
||||
finance.frozenCommissionFen = 0
|
||||
finance.monthConsumeFen = 0
|
||||
finance.todayConsumeFen = 0
|
||||
finance.avgDailyConsumeFen = 0
|
||||
finance.suggestRechargeFen = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,11 +655,73 @@ const loadInviteQrcode = async () => {
|
||||
max-width: 920px;
|
||||
}
|
||||
|
||||
.dash-kpis {
|
||||
.wallet-strip {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.wallet-card {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px 16px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 4px 12px rgba(15, 23, 42, 0.03);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
overflow: hidden;
|
||||
animation: dashFadeUp 0.45s ease-out both;
|
||||
}
|
||||
|
||||
.wallet-info { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.wallet-label { font-size: 12px; color: #64748b; font-weight: 500; }
|
||||
.wallet-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.wallet-foot { font-size: 11px; color: #94a3b8; }
|
||||
.wallet-icon {
|
||||
width: 36px; height: 36px; border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wallet-card--primary { background: linear-gradient(135deg, #eef2ff 0%, #ffffff 60%); }
|
||||
.wallet-card--primary .wallet-icon { background: #eef2ff; color: #4f46e5; }
|
||||
.wallet-card--primary .wallet-value { color: #3730a3; }
|
||||
.wallet-card--consume .wallet-icon { background: #ecfdf5; color: #10b981; }
|
||||
.wallet-card--frozen .wallet-icon { background: #fffbeb; color: #b45309; }
|
||||
.wallet-card--suggest .wallet-icon { background: #fef2f2; color: #ef4444; }
|
||||
.wallet-action {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
padding: 4px 12px;
|
||||
font-weight: 600;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.recharge-dialog { display: flex; flex-direction: column; gap: 12px; }
|
||||
.recharge-quick { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.recharge-action { display: flex; align-items: center; gap: 10px; }
|
||||
.recharge-tip { font-size: 12px; color: #94a3b8; }
|
||||
.recharge-qr { display: flex; flex-direction: column; align-items: center; gap: 8px; margin-top: 6px; }
|
||||
.recharge-qr img { width: 200px; height: 200px; border: 1px solid #e2e8f0; border-radius: 8px; background: #fff; padding: 8px; }
|
||||
.recharge-qr-text { font-size: 13px; color: #475569; }
|
||||
|
||||
.dash-kpis {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -983,11 +1174,17 @@ const loadInviteQrcode = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.wallet-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.dash-kpis {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.wallet-strip { grid-template-columns: 1fr; }
|
||||
|
||||
.dash-catalog {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@@ -86,27 +86,29 @@
|
||||
<VChart v-if="hasProductSeries" class="trend-chart-echarts" :option="productSeriesOption" autoresize />
|
||||
<div v-else class="empty-chart">暂无产品数据</div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-card top-ranking-card">
|
||||
<div class="card-header">
|
||||
<el-icon><UserFilled /></el-icon>
|
||||
<span>分销商收益排行</span>
|
||||
<span>分销商 Top 10 排行榜</span>
|
||||
<span class="head-hint">点击「查看订单」定位该分销商带来的订单</span>
|
||||
</div>
|
||||
<div v-if="overviewTopDistributors.length" class="overview-list">
|
||||
<div v-for="item in overviewTopDistributors" :key="item.id" class="overview-list-item">
|
||||
<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="28" :src="item.avatar" class="agent-avatar">
|
||||
<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>
|
||||
<span>团队 {{ item.teamCount || 0 }} 人</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overview-side">
|
||||
<strong>{{ formatCurrency(parseFloat(item.totalCommission || 0)) }}</strong>
|
||||
<span>累计收益</span>
|
||||
<el-button link type="primary" size="small" @click="gotoInviterOrders(item)">查看订单</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,6 +171,11 @@
|
||||
{{ row.createdAt ? new Date(row.createdAt * 1000).toLocaleString() : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="110" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="gotoInviterOrders(row)">该分销商订单</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="distributors.length === 0 && !loading" class="empty-placeholder">暂无分销商数据</div>
|
||||
</div>
|
||||
@@ -389,6 +396,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, watch, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
Refresh, User, Money, Clock, Connection, Search,
|
||||
CaretTop, TrendCharts, PieChart, UserFilled, List
|
||||
@@ -398,11 +406,13 @@ import { ElMessage } from 'element-plus'
|
||||
import { formatCurrency } from '@/utils/format'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { LineChart, PieChart as EchartsPieChart } from 'echarts/charts'
|
||||
import { LineChart, PieChart as EchartsPieChart, BarChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
|
||||
import VChart from 'vue-echarts'
|
||||
|
||||
use([CanvasRenderer, LineChart, EchartsPieChart, GridComponent, TooltipComponent, LegendComponent])
|
||||
use([CanvasRenderer, LineChart, BarChart, EchartsPieChart, GridComponent, TooltipComponent, LegendComponent])
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const activeTab = ref('overview')
|
||||
const distSearch = ref('')
|
||||
@@ -445,35 +455,59 @@ const hasProductSeries = computed(() =>
|
||||
productCommissionSeries.value.some(item => Number(item.value || 0) > 0)
|
||||
)
|
||||
|
||||
const cumulativeTrend = computed(() => {
|
||||
let acc = 0
|
||||
return commissionTrend.value.map(item => {
|
||||
acc += Number(item.amount || 0)
|
||||
return Number(acc.toFixed(2))
|
||||
})
|
||||
})
|
||||
|
||||
const commissionTrendOption = computed(() => ({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 40, right: 20, top: 24, bottom: 30 },
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
|
||||
legend: { data: ['每日佣金', '累计佣金'], top: 0, textStyle: { color: '#64748b', fontSize: 11 } },
|
||||
grid: { left: 44, right: 50, top: 30, bottom: 30 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: commissionTrend.value.map(item => item.date.slice(5)),
|
||||
axisLine: { lineStyle: { color: '#e5e7eb' } },
|
||||
axisLabel: { color: '#6b7280' }
|
||||
axisLine: { lineStyle: { color: '#e2e8f0' } },
|
||||
axisLabel: { color: '#94a3b8', fontSize: 11 }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { lineStyle: { color: '#e5e7eb' } },
|
||||
splitLine: { lineStyle: { color: '#f3f4f6' } },
|
||||
axisLabel: {
|
||||
color: '#6b7280',
|
||||
formatter: (value: number) => `¥${value}`
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '每日',
|
||||
nameTextStyle: { color: '#94a3b8', fontSize: 10 },
|
||||
axisLine: { lineStyle: { color: '#e2e8f0' } },
|
||||
splitLine: { lineStyle: { color: '#f1f5f9' } },
|
||||
axisLabel: { color: '#94a3b8', fontSize: 10, formatter: (v: number) => `¥${v}` }
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '累计',
|
||||
nameTextStyle: { color: '#94a3b8', fontSize: 10 },
|
||||
axisLine: { lineStyle: { color: '#e2e8f0' } },
|
||||
splitLine: { show: false },
|
||||
axisLabel: { color: '#94a3b8', fontSize: 10, formatter: (v: number) => `¥${v}` }
|
||||
}
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '佣金',
|
||||
name: '每日佣金',
|
||||
type: 'bar',
|
||||
barMaxWidth: 18,
|
||||
itemStyle: { color: '#4F46E5', borderRadius: [4, 4, 0, 0] },
|
||||
data: commissionTrend.value.map(item => Number(item.amount || 0))
|
||||
},
|
||||
{
|
||||
name: '累计佣金',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: true,
|
||||
symbolSize: 6,
|
||||
itemStyle: { color: '#7c3aed' },
|
||||
areaStyle: { color: 'rgba(124, 58, 237, 0.08)' },
|
||||
data: commissionTrend.value.map(item => Number(item.amount || 0))
|
||||
yAxisIndex: 1,
|
||||
showSymbol: false,
|
||||
lineStyle: { color: '#10B981', width: 2 },
|
||||
itemStyle: { color: '#10B981' },
|
||||
data: cumulativeTrend.value
|
||||
}
|
||||
]
|
||||
}))
|
||||
@@ -576,7 +610,7 @@ const loadOverview = async () => {
|
||||
if (distributorsRes.code === 200 && distributorsRes.data) {
|
||||
overviewTopDistributors.value = [...(distributorsRes.data.list || [])]
|
||||
.sort((a: any, b: any) => parseFloat(b.totalCommission || 0) - parseFloat(a.totalCommission || 0))
|
||||
.slice(0, 5)
|
||||
.slice(0, 10)
|
||||
} else {
|
||||
overviewTopDistributors.value = []
|
||||
}
|
||||
@@ -726,6 +760,18 @@ watch(commFilter, () => {
|
||||
}
|
||||
})
|
||||
|
||||
function gotoInviterOrders(row: any) {
|
||||
const id = row?.id ?? row?.userId ?? row?.inviterId
|
||||
if (!id) {
|
||||
ElMessage.warning('分销商 ID 缺失')
|
||||
return
|
||||
}
|
||||
router.push({
|
||||
path: '/admin/orders',
|
||||
query: { inviterId: String(id), inviterName: row?.agentName || '' },
|
||||
})
|
||||
}
|
||||
|
||||
const refresh = async () => {
|
||||
if (activeTab.value === 'overview') {
|
||||
await loadOverview()
|
||||
@@ -929,41 +975,74 @@ onMounted(() => {
|
||||
.overview-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 180px;
|
||||
gap: 8px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.overview-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 14px;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
background-color: #f9fafb;
|
||||
border: 1px solid #f3f4f6;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover { background-color: #eef2ff; }
|
||||
}
|
||||
|
||||
.rank-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.rank-top { background: #fef3c7; color: #b45309; }
|
||||
}
|
||||
|
||||
.top-ranking-card {
|
||||
grid-column: 1 / -1;
|
||||
|
||||
.card-header { justify-content: space-between; }
|
||||
|
||||
.head-hint {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
font-weight: 400;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.overview-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.overview-side {
|
||||
min-width: 120px;
|
||||
min-width: 150px;
|
||||
text-align: right;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
|
||||
strong {
|
||||
font-size: 14px;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
color: #4f46e5;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
329
admin/src/views/admin/OrderTimelineDrawer.vue
Normal file
329
admin/src/views/admin/OrderTimelineDrawer.vue
Normal file
@@ -0,0 +1,329 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
v-model="visible"
|
||||
:title="drawerTitle"
|
||||
direction="rtl"
|
||||
size="480px"
|
||||
:destroy-on-close="true"
|
||||
>
|
||||
<div v-if="order" class="timeline-drawer">
|
||||
<div class="summary-card">
|
||||
<div class="summary-row">
|
||||
<span class="label">订单号</span>
|
||||
<span class="value monospaced">{{ order.orderNo || '—' }}</span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span class="label">用户</span>
|
||||
<span class="value">{{ order.userName || '—' }} <em>{{ formatPhone(order.userPhone) }}</em></span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span class="label">产品</span>
|
||||
<span class="value">{{ order.productTitle || productTypeLabel(order.productType) }}</span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span class="label">金额</span>
|
||||
<span class="value amount">{{ formatAmount(order.amount) }}</span>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<span class="label">状态</span>
|
||||
<span :class="['status-badge', 'status-' + (order.status || 'pending')]">{{ statusLabel(order.status) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="section-title">订单时间线</h4>
|
||||
<ul class="timeline">
|
||||
<li v-for="step in timelineSteps" :key="step.key" :class="['timeline-step', step.active ? 'active' : 'inactive']">
|
||||
<span class="dot" />
|
||||
<div class="step-body">
|
||||
<div class="step-title">{{ step.title }}</div>
|
||||
<div class="step-time">{{ step.active ? formatDateTime(step.time) : '—' }}</div>
|
||||
<div class="step-desc" v-if="step.desc">{{ step.desc }}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<template v-if="commissionList.length">
|
||||
<h4 class="section-title">分销分润</h4>
|
||||
<div class="commission-list">
|
||||
<div v-for="cr in commissionList" :key="cr.id" class="commission-card">
|
||||
<div class="commission-top">
|
||||
<span class="inviter">{{ cr.inviterName || cr.inviterNickname || ('#' + cr.inviterId) }}</span>
|
||||
<span :class="['status-badge', 'status-' + mapCommissionStatus(cr.status)]">{{ commissionStatusLabel(cr.status) }}</span>
|
||||
</div>
|
||||
<div class="commission-meta">
|
||||
<span>佣金 <strong>{{ formatAmount(cr.commissionFen) }}</strong></span>
|
||||
<span v-if="cr.rate != null">比例 {{ Number(cr.rate * 100).toFixed(1) }}%</span>
|
||||
<span v-if="cr.paidAt">结算 {{ formatDateTime(cr.paidAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="empty-commission">无分销分润记录</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface OrderInput {
|
||||
orderNo?: string
|
||||
userName?: string
|
||||
userPhone?: string
|
||||
userId?: number
|
||||
productType?: string
|
||||
productTitle?: string
|
||||
amount?: number
|
||||
status?: string
|
||||
createdAt?: number | null
|
||||
payTime?: number | null
|
||||
refundTime?: number | null
|
||||
cancelTime?: number | null
|
||||
commissions?: any[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
order: OrderInput | null
|
||||
commissions?: any[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v: boolean) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const drawerTitle = computed(() => `订单详情 · ${props.order?.orderNo || ''}`)
|
||||
|
||||
const commissionList = computed(() => {
|
||||
const inline = Array.isArray(props.order?.commissions) ? props.order!.commissions! : []
|
||||
const extra = Array.isArray(props.commissions) ? props.commissions : []
|
||||
return [...inline, ...extra]
|
||||
})
|
||||
|
||||
const timelineSteps = computed(() => {
|
||||
const order = props.order
|
||||
if (!order) return []
|
||||
const status = order.status || 'pending'
|
||||
const commissionPaidAt = commissionList.value.find(c => c.paidAt)?.paidAt ?? null
|
||||
const createdActive = !!order.createdAt
|
||||
const paidActive = !!order.payTime || ['paid', 'completed', 'refunded'].includes(status)
|
||||
const settledActive = !!commissionPaidAt || status === 'completed'
|
||||
const refundActive = status === 'refunded' && !!(order.refundTime || order.payTime)
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'created',
|
||||
title: '下单',
|
||||
time: order.createdAt,
|
||||
active: createdActive,
|
||||
desc: '用户发起订单',
|
||||
},
|
||||
{
|
||||
key: 'paid',
|
||||
title: '付款',
|
||||
time: order.payTime,
|
||||
active: paidActive,
|
||||
desc: paidActive ? '支付已到账' : '等待支付',
|
||||
},
|
||||
{
|
||||
key: 'settled',
|
||||
title: '分销结算',
|
||||
time: commissionPaidAt,
|
||||
active: settledActive,
|
||||
desc: settledActive
|
||||
? (commissionList.value.length ? '佣金按规则结算' : '无分销,直接完成')
|
||||
: '尚未结算',
|
||||
},
|
||||
...(refundActive
|
||||
? [{ key: 'refunded', title: '退款', time: order.refundTime || order.payTime, active: true, desc: '已发起退款' }]
|
||||
: []),
|
||||
]
|
||||
})
|
||||
|
||||
function formatPhone(phone?: string) {
|
||||
if (!phone) return ''
|
||||
if (phone.length === 11) return phone.substring(0, 3) + '****' + phone.substring(7)
|
||||
return phone
|
||||
}
|
||||
|
||||
function formatDateTime(ts?: number | null) {
|
||||
if (ts == null) return '—'
|
||||
const d = new Date(Number(ts) * 1000)
|
||||
return (
|
||||
d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') + ' ' +
|
||||
String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
|
||||
)
|
||||
}
|
||||
|
||||
function formatAmount(amountFen?: number | string | null) {
|
||||
if (amountFen == null) return '¥0.00'
|
||||
const n = typeof amountFen === 'number' ? amountFen : Number(amountFen)
|
||||
if (!Number.isFinite(n)) return '¥0.00'
|
||||
return '¥' + (n / 100).toFixed(2)
|
||||
}
|
||||
|
||||
function productTypeLabel(type?: string) {
|
||||
const map: Record<string, string> = {
|
||||
face: 'AI人脸分析',
|
||||
mbti: 'MBTI',
|
||||
disc: 'DISC',
|
||||
pdp: 'PDP',
|
||||
sbti: 'SBTI',
|
||||
report: '完整报告',
|
||||
}
|
||||
return map[type || ''] || type || '—'
|
||||
}
|
||||
|
||||
function statusLabel(status?: string) {
|
||||
const map: Record<string, string> = {
|
||||
pending: '待支付',
|
||||
paid: '已支付',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消',
|
||||
refunded: '已退款',
|
||||
failed: '失败',
|
||||
}
|
||||
return map[status || ''] || status || '—'
|
||||
}
|
||||
|
||||
function mapCommissionStatus(s: string) {
|
||||
if (s === 'paid' || s === 'settled') return 'completed'
|
||||
if (s === 'frozen') return 'frozen'
|
||||
if (s === 'cancelled') return 'cancelled'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
function commissionStatusLabel(s: string) {
|
||||
const map: Record<string, string> = {
|
||||
frozen: '冻结中',
|
||||
pending: '待结算',
|
||||
paid: '已结算',
|
||||
settled: '已结算',
|
||||
cancelled: '已撤销',
|
||||
}
|
||||
return map[s] || s || '—'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.timeline-drawer { padding: 8px 4px; }
|
||||
|
||||
.summary-card {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
|
||||
.label { color: #64748b; flex-shrink: 0; width: 56px; }
|
||||
.value { color: #0f172a; font-weight: 500; }
|
||||
.value.amount { color: #4f46e5; font-weight: 700; }
|
||||
.value.monospaced { font-family: 'SF Mono', Menlo, monospace; font-weight: 500; }
|
||||
em { color: #94a3b8; font-style: normal; margin-left: 6px; font-size: 12px; }
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
margin: 20px 0 10px;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.timeline-step {
|
||||
position: relative;
|
||||
padding-left: 22px;
|
||||
|
||||
.dot {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 4px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 14px;
|
||||
bottom: -10px;
|
||||
width: 2px;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
&:last-child::before { display: none; }
|
||||
|
||||
&.active .dot { background: #4f46e5; box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.15); }
|
||||
|
||||
.step-title { font-size: 13px; font-weight: 600; color: #0f172a; }
|
||||
.step-time { font-size: 12px; color: #64748b; margin-top: 2px; }
|
||||
.step-desc { font-size: 12px; color: #94a3b8; margin-top: 2px; }
|
||||
|
||||
&.inactive {
|
||||
.step-title, .step-time, .step-desc { color: #94a3b8; }
|
||||
}
|
||||
}
|
||||
|
||||
.commission-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.commission-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
|
||||
.commission-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.inviter { font-size: 13px; color: #0f172a; font-weight: 600; }
|
||||
}
|
||||
|
||||
.commission-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
flex-wrap: wrap;
|
||||
|
||||
strong { color: #4f46e5; font-weight: 600; }
|
||||
}
|
||||
}
|
||||
|
||||
.empty-commission {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
padding: 8px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -91,7 +91,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="orders" style="width: 100%" v-loading="loading" class="custom-table">
|
||||
<div v-if="inviterId" class="inviter-banner">
|
||||
<span>筛选:分销商 #{{ inviterId }}<span v-if="inviterName"> · {{ inviterName }}</span></span>
|
||||
<el-button link type="primary" size="small" @click="clearInviterFilter">清除筛选</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="orders" style="width: 100%" v-loading="loading" class="custom-table" row-key="id">
|
||||
<el-table-column label="订单号" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="order-id">{{ row.orderNo }}</span>
|
||||
@@ -105,13 +110,11 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="产品" min-width="180">
|
||||
<el-table-column label="产品" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<div class="product-cell">
|
||||
<div class="product-main">{{ row.productTitle || productTypeLabel(row.productType) }}</div>
|
||||
<div class="product-sub" v-if="row.productTitle">
|
||||
{{ productTypeLabel(row.productType) }}
|
||||
</div>
|
||||
<div class="product-sub" v-if="row.productTitle">{{ productTypeLabel(row.productType) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -120,45 +123,36 @@
|
||||
<span class="amount-cell">{{ formatAmount(row.amount) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="支付方式" width="100" align="center">
|
||||
<el-table-column label="支付方式" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ payMethodLabel(row.payMethod) }}
|
||||
<span class="pay-method">{{ payMethodLabel(row.payMethod) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<el-table-column label="状态" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||
<span :class="['status-badge', 'status-' + (row.status || 'pending')]">{{ statusLabel(row.status) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="关联测试" min-width="200">
|
||||
<el-table-column label="时间" width="170">
|
||||
<template #default="{ row }">
|
||||
<div class="test-data-cell">
|
||||
<template v-if="row.testData && row.testData.length">
|
||||
<div
|
||||
v-for="(t, i) in row.testData"
|
||||
:key="t.id || i"
|
||||
class="test-item"
|
||||
>
|
||||
<span class="test-type">{{ testTypeLabel(t.testType) }}</span>
|
||||
<span class="test-summary">{{ t.resultSummary || '—' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else class="no-test">—</span>
|
||||
<div class="time-cell">
|
||||
<div class="time-row"><em>创建</em>{{ formatDate(row.createdAt) }}</div>
|
||||
<div class="time-row"><em>支付</em>{{ row.payTime ? formatDate(row.payTime) : '—' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="160">
|
||||
<el-table-column label="操作" width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.createdAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="支付时间" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ row.payTime ? formatDate(row.payTime) : '—' }}
|
||||
<el-button link type="primary" @click="openTimeline(row)">
|
||||
<el-icon><View /></el-icon>
|
||||
<span>详情</span>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<OrderTimelineDrawer v-model="timelineVisible" :order="currentOrder" :commissions="currentCommissions" />
|
||||
|
||||
<div class="empty-state" v-if="orders.length === 0 && !loading">
|
||||
<span>暂无订单数据</span>
|
||||
</div>
|
||||
@@ -181,15 +175,20 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Refresh, ShoppingCart, Box, Money, Search } from '@element-plus/icons-vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Refresh, ShoppingCart, Box, Money, Search, View } from '@element-plus/icons-vue'
|
||||
import { request } from '@/utils/request'
|
||||
import OrderTimelineDrawer from './OrderTimelineDrawer.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ embedded?: boolean; ordersApiPath?: string }>(),
|
||||
{ embedded: false, ordersApiPath: '/admin/orders' }
|
||||
)
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const statusFilter = ref('')
|
||||
@@ -201,6 +200,44 @@ const totalRevenueFen = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = 20
|
||||
|
||||
const timelineVisible = ref(false)
|
||||
const currentOrder = ref<any | null>(null)
|
||||
const currentCommissions = ref<any[]>([])
|
||||
|
||||
const inviterId = ref<string | number>('')
|
||||
const inviterName = ref<string>('')
|
||||
|
||||
function readInviterFromRoute() {
|
||||
const q = route?.query || {}
|
||||
const id = Array.isArray(q.inviterId) ? q.inviterId[0] : q.inviterId
|
||||
const name = Array.isArray(q.inviterName) ? q.inviterName[0] : q.inviterName
|
||||
inviterId.value = (id ?? '') as any
|
||||
inviterName.value = (name ?? '') as string
|
||||
}
|
||||
|
||||
function clearInviterFilter() {
|
||||
inviterId.value = ''
|
||||
inviterName.value = ''
|
||||
const q = { ...route.query }
|
||||
delete (q as any).inviterId
|
||||
delete (q as any).inviterName
|
||||
router.replace({ path: route.path, query: q })
|
||||
currentPage.value = 1
|
||||
loadOrders()
|
||||
}
|
||||
|
||||
watch(() => route.query, () => {
|
||||
readInviterFromRoute()
|
||||
currentPage.value = 1
|
||||
loadOrders()
|
||||
})
|
||||
|
||||
function openTimeline(row: any) {
|
||||
currentOrder.value = row
|
||||
currentCommissions.value = Array.isArray(row?.commissions) ? row.commissions : []
|
||||
timelineVisible.value = true
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '待支付', value: 'pending' },
|
||||
@@ -303,15 +340,17 @@ function statusTagType(
|
||||
async function loadOrders() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await request.get(props.ordersApiPath, {
|
||||
params: {
|
||||
page: currentPage.value,
|
||||
pageSize,
|
||||
keyword: searchQuery.value,
|
||||
status: statusFilter.value,
|
||||
productType: productFilter.value
|
||||
}
|
||||
})
|
||||
const params: Record<string, any> = {
|
||||
page: currentPage.value,
|
||||
pageSize,
|
||||
keyword: searchQuery.value,
|
||||
status: statusFilter.value,
|
||||
productType: productFilter.value
|
||||
}
|
||||
if (inviterId.value) {
|
||||
params.inviterId = inviterId.value
|
||||
}
|
||||
const res: any = await request.get(props.ordersApiPath, { params })
|
||||
const list = res.data?.list ?? res?.list ?? []
|
||||
orders.value = list
|
||||
total.value = res.data?.total ?? res?.total ?? 0
|
||||
@@ -327,7 +366,10 @@ async function loadOrders() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadOrders())
|
||||
onMounted(() => {
|
||||
readInviterFromRoute()
|
||||
loadOrders()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -531,8 +573,34 @@ onMounted(() => loadOrders())
|
||||
|
||||
.amount-cell {
|
||||
font-size: 13px;
|
||||
color: #111827;
|
||||
font-weight: 500;
|
||||
color: #4f46e5;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pay-method {
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.product-sub {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.time-cell {
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
line-height: 1.5;
|
||||
em {
|
||||
display: inline-block;
|
||||
width: 32px;
|
||||
color: #94a3b8;
|
||||
font-style: normal;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.time-row + .time-row { margin-top: 2px; }
|
||||
}
|
||||
|
||||
.test-data-cell {
|
||||
@@ -619,4 +687,17 @@ onMounted(() => loadOrders())
|
||||
.page-container.is-embedded {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.inviter-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
margin: 0 20px 0;
|
||||
background: #eef2ff;
|
||||
color: #4338ca;
|
||||
font-size: 13px;
|
||||
border-radius: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>用户数据</h2>
|
||||
<p class="subtitle">按企业用户池分类查看和管理用户数据</p>
|
||||
<h2>用户测试画像</h2>
|
||||
<p class="subtitle">以用户为核心 · 一行看清面容分析、MBTI / DISC / PDP 结果</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button variant="outline" size="small" @click="exportData">
|
||||
@@ -12,8 +12,49 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 画像汇总条 -->
|
||||
<div class="profile-summary">
|
||||
<div class="summary-card">
|
||||
<div class="summary-ic ic-blue">👥</div>
|
||||
<div class="summary-body">
|
||||
<div class="summary-label">总用户数</div>
|
||||
<div class="summary-value">{{ total }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-ic ic-teal">🪞</div>
|
||||
<div class="summary-body">
|
||||
<div class="summary-label">已面容分析</div>
|
||||
<div class="summary-value">
|
||||
{{ profileStats.faceCount }}
|
||||
<span class="summary-sub">/ 本页 {{ users.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-ic ic-indigo">🧠</div>
|
||||
<div class="summary-body">
|
||||
<div class="summary-label">已完成 MBTI</div>
|
||||
<div class="summary-value">
|
||||
{{ profileStats.mbtiCount }}
|
||||
<span class="summary-sub">/ 本页 {{ users.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-ic ic-amber">📊</div>
|
||||
<div class="summary-body">
|
||||
<div class="summary-label">至少一项测试</div>
|
||||
<div class="summary-value">
|
||||
{{ profileStats.anyTestCount }}
|
||||
<span class="summary-sub">/ 本页 {{ users.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-section">
|
||||
<div class="search-section admin-filter-bar">
|
||||
<el-input
|
||||
v-model="searchTerm"
|
||||
placeholder="搜索昵称、手机号、地区、MBTI..."
|
||||
@@ -26,133 +67,93 @@
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-select
|
||||
v-model="coldFaceFilter"
|
||||
multiple
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
placeholder="冷脸分析"
|
||||
class="coldface-select"
|
||||
@change="() => { currentPage = 1; loadUsers() }"
|
||||
>
|
||||
<el-option label="暖" value="warm" />
|
||||
<el-option label="中" value="neutral" />
|
||||
<el-option label="冷" value="cold" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="loadUsers">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 用户列表表格 -->
|
||||
<div class="table-card">
|
||||
<el-table :data="users" style="width: 100%" v-loading="loading" class="user-table">
|
||||
<el-table-column label="用户信息" min-width="180">
|
||||
<el-table-column label="用户" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="user-info-cell">
|
||||
<img
|
||||
v-if="displayAvatarUrl(row.avatar)"
|
||||
:src="displayAvatarUrl(row.avatar)"
|
||||
class="user-avatar user-avatar-img"
|
||||
referrerpolicy="no-referrer"
|
||||
alt=""
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="user-avatar user-avatar-letter"
|
||||
:style="{ backgroundColor: avatarBgColor(row) }"
|
||||
>
|
||||
{{ avatarLetter(row) }}
|
||||
<div class="user-cell">
|
||||
<div class="user-avatar">
|
||||
<img
|
||||
v-if="displayAvatarUrl(row.avatar)"
|
||||
:src="displayAvatarUrl(row.avatar)"
|
||||
referrerpolicy="no-referrer"
|
||||
alt=""
|
||||
/>
|
||||
<span v-else :style="{ backgroundColor: avatarBgColor(row), width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff' }">
|
||||
{{ avatarLetter(row) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="user-details">
|
||||
<div class="username">{{ row.username || '未设置昵称' }}</div>
|
||||
<div class="openid-line">{{ row.openid || '—' }}</div>
|
||||
<div class="phone-line">{{ row.phone}}</div>
|
||||
<div class="user-meta">
|
||||
<div class="user-name">{{ row.username || '未设置昵称' }}</div>
|
||||
<div class="user-sub">
|
||||
<span
|
||||
v-if="row.coldFaceLevel"
|
||||
:class="['coldface-tag', 'coldface-' + row.coldFaceLevel]"
|
||||
:title="coldFaceTooltip(row)"
|
||||
>{{ coldFaceLabel(row.coldFaceLevel) }}<template v-if="row.coldFaceScore != null"> · {{ row.coldFaceScore }}</template></span>
|
||||
<span v-else class="empty-hint">未测面相</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
|
||||
|
||||
<el-table-column label="人脸分析" width="180" align="center">
|
||||
<el-table-column label="联系方式" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<div class="test-results">
|
||||
<template v-if="row.faceMbtiType || row.faceDiscType || row.facePdpType">
|
||||
<el-tag
|
||||
v-if="row.faceMbtiType"
|
||||
size="small"
|
||||
class="result-tag"
|
||||
type="success"
|
||||
@click.stop="handleClickTestTag(row, 'face')"
|
||||
>
|
||||
{{ row.faceMbtiType }}
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-if="row.faceDiscType"
|
||||
size="small"
|
||||
class="result-tag"
|
||||
type="info"
|
||||
@click.stop="handleClickTestTag(row, 'face')"
|
||||
>
|
||||
{{ row.faceDiscType }}
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-if="row.facePdpType"
|
||||
size="small"
|
||||
class="result-tag"
|
||||
type="warning"
|
||||
@click.stop="handleClickTestTag(row, 'face')"
|
||||
>
|
||||
{{ row.facePdpType }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<span v-else class="no-test">—</span>
|
||||
<div class="contact-cell">
|
||||
<div class="phone">{{ row.phone || '—' }}</div>
|
||||
<div class="openid-line" :title="row.openid || ''">{{ row.openid ? (String(row.openid).length > 16 ? String(row.openid).slice(0,16) + '…' : row.openid) : '—' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="MBTI" width="120" align="center">
|
||||
<el-table-column label="测评标签" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="test-results">
|
||||
<template v-if="row.mbtiType">
|
||||
<el-tag size="small" class="result-tag" @click.stop="handleClickTestTag(row, 'mbti')">
|
||||
{{ row.mbtiType }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<span v-else class="no-test">—</span>
|
||||
<el-tag v-if="row.mbtiType" size="small" class="result-tag tag-mbti" @click.stop="handleClickTestTag(row, 'mbti')">MBTI · {{ row.mbtiType }}</el-tag>
|
||||
<el-tag v-if="row.discType" size="small" class="result-tag tag-disc" type="info" @click.stop="handleClickTestTag(row, 'disc')">DISC · {{ row.discType }}</el-tag>
|
||||
<el-tag v-if="row.pdpType" size="small" class="result-tag tag-pdp" type="warning" @click.stop="handleClickTestTag(row, 'pdp')">PDP · {{ row.pdpType }}</el-tag>
|
||||
<el-tag v-if="row.faceMbtiType" size="small" class="result-tag tag-face" type="success" @click.stop="handleClickTestTag(row, 'face')">面·{{ row.faceMbtiType }}</el-tag>
|
||||
<el-tag v-if="row.faceDiscType" size="small" class="result-tag tag-face" type="success" effect="plain" @click.stop="handleClickTestTag(row, 'face')">面·{{ row.faceDiscType }}</el-tag>
|
||||
<el-tag v-if="row.facePdpType" size="small" class="result-tag tag-face" type="success" effect="plain" @click.stop="handleClickTestTag(row, 'face')">面·{{ row.facePdpType }}</el-tag>
|
||||
<span v-if="!row.mbtiType && !row.discType && !row.pdpType && !row.faceMbtiType && !row.faceDiscType && !row.facePdpType" class="no-test">暂无测评</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="PDP" width="120" align="center">
|
||||
<el-table-column label="测评次数 · 最近活跃" width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="test-results">
|
||||
<template v-if="row.pdpType">
|
||||
<el-tag
|
||||
size="small"
|
||||
class="result-tag"
|
||||
type="warning"
|
||||
@click.stop="handleClickTestTag(row, 'pdp')"
|
||||
>
|
||||
{{ row.pdpType }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<span v-else class="no-test">—</span>
|
||||
<div class="activity-cell">
|
||||
<div class="activity-row">测评 <strong>{{ row.testCount || 0 }}</strong> 次</div>
|
||||
<div class="activity-row sub">{{ row.lastTestAt ? formatDate(row.lastTestAt) : '—' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="DISC" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="test-results">
|
||||
<template v-if="row.discType">
|
||||
<el-tag
|
||||
size="small"
|
||||
class="result-tag"
|
||||
type="info"
|
||||
@click.stop="handleClickTestTag(row, 'disc')"
|
||||
>
|
||||
{{ row.discType }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<span v-else class="no-test">—</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="注册时间" width="150">
|
||||
<el-table-column label="注册时间" width="130">
|
||||
<template #default="{ row }">
|
||||
<span class="time-cell">{{ formatDate(row.createdAt) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<el-table-column label="操作" width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link @click="handleView(row)">
|
||||
<el-icon><View /></el-icon>
|
||||
@@ -713,6 +714,7 @@ const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = 10
|
||||
const searchTerm = ref('')
|
||||
const coldFaceFilter = ref<string[]>([])
|
||||
const showDetailDialog = ref(false)
|
||||
const detailUser = ref<Record<string, any> | null>(null)
|
||||
|
||||
@@ -729,6 +731,24 @@ const currentTestType = computed(() => (currentTest.value?.testType || '').toLow
|
||||
|
||||
const users = ref<any[]>([])
|
||||
|
||||
/** 页内画像汇总 */
|
||||
const profileStats = computed(() => {
|
||||
const list = users.value || []
|
||||
let faceCount = 0
|
||||
let mbtiCount = 0
|
||||
let anyTestCount = 0
|
||||
for (const u of list) {
|
||||
const hasFace = !!(u.faceMbtiType || u.faceDiscType || u.facePdpType)
|
||||
const hasMbti = !!u.mbtiType
|
||||
const hasDisc = !!u.discType
|
||||
const hasPdp = !!u.pdpType
|
||||
if (hasFace) faceCount++
|
||||
if (hasMbti) mbtiCount++
|
||||
if (hasFace || hasMbti || hasDisc || hasPdp) anyTestCount++
|
||||
}
|
||||
return { faceCount, mbtiCount, anyTestCount }
|
||||
})
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -737,6 +757,9 @@ async function loadUsers() {
|
||||
pageSize,
|
||||
keyword: searchTerm.value
|
||||
}
|
||||
if (coldFaceFilter.value.length > 0) {
|
||||
params.coldFaceLevel = coldFaceFilter.value.join(',')
|
||||
}
|
||||
const res: any = await request.get('/admin/app-users', { params })
|
||||
const list = res.data?.list ?? res?.list ?? []
|
||||
users.value = list.map((row: any) => ({
|
||||
@@ -773,6 +796,20 @@ function avatarBgColor(row: { username?: string; nickname?: string }) {
|
||||
return AVATAR_PALETTE[Math.abs(hash) % AVATAR_PALETTE.length]
|
||||
}
|
||||
|
||||
function coldFaceLabel(level: string | null | undefined) {
|
||||
if (level === 'cold') return '冷'
|
||||
if (level === 'warm') return '暖'
|
||||
if (level === 'neutral') return '中'
|
||||
return ''
|
||||
}
|
||||
|
||||
function coldFaceTooltip(row: any) {
|
||||
const parts: string[] = []
|
||||
if (row?.coldFaceScore != null) parts.push(`冷脸分:${row.coldFaceScore}`)
|
||||
if (row?.coldFaceUpdatedAt) parts.push(`更新于 ${formatDate(row.coldFaceUpdatedAt)}`)
|
||||
return parts.join(' · ') || '尚未完成面相分析'
|
||||
}
|
||||
|
||||
function formatPhone(phone: string) {
|
||||
if (!phone) return '-'
|
||||
if (phone.length === 11) return phone.substring(0, 3) + '****' + phone.substring(7)
|
||||
@@ -1169,11 +1206,85 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.profile-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
@media (max-width: 900px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.summary-ic {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.ic-blue { background: #eff6ff; }
|
||||
&.ic-teal { background: #f0fdfa; }
|
||||
&.ic-indigo { background: #eef2ff; }
|
||||
&.ic-amber { background: #fffbeb; }
|
||||
}
|
||||
|
||||
.summary-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
.summary-sub {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #9ca3af;
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-section {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.coldface-select {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
@@ -1294,6 +1405,7 @@ onMounted(() => {
|
||||
|
||||
.result-tag {
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.no-test {
|
||||
@@ -1302,6 +1414,29 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.contact-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
|
||||
.phone { color: #0f172a; font-size: 13px; font-weight: 500; }
|
||||
.openid-line { color: #94a3b8; font-size: 11px; font-family: 'SF Mono', Menlo, monospace; }
|
||||
}
|
||||
|
||||
.activity-cell {
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
|
||||
strong {
|
||||
color: #4f46e5;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.activity-row.sub { color: #94a3b8; font-size: 12px; margin-top: 2px; }
|
||||
}
|
||||
|
||||
.time-cell {
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
<template>
|
||||
<div class="commerce-hub">
|
||||
<div class="hub-header">
|
||||
<h2>订单和财务</h2>
|
||||
<p class="hub-subtitle">
|
||||
监督全平台订单与资金流水;全局定价影响各企业在管理后台侧的采购与结算。企业日常接单请在「管理后台」查看。
|
||||
</p>
|
||||
</div>
|
||||
<div class="sa-page commerce-hub">
|
||||
<SaPageHeader
|
||||
title="订单和财务"
|
||||
subtitle="监督全平台订单与资金流水;全局定价影响各企业在管理后台侧的采购与结算。企业日常接单请在「管理后台」查看。"
|
||||
/>
|
||||
|
||||
<div class="custom-tabs-container tabs-scroll">
|
||||
<div class="custom-tabs tabs-many">
|
||||
<div
|
||||
v-for="t in innerTabs"
|
||||
:key="t.value"
|
||||
:class="['tab-item', { active: activeTab === t.value }]"
|
||||
@click="selectTab(t.value)"
|
||||
>
|
||||
{{ t.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SaTabs v-model="activeTab" :items="innerTabs" @change="onTabChange" />
|
||||
|
||||
<div class="hub-body">
|
||||
<Orders
|
||||
@@ -27,7 +14,6 @@
|
||||
orders-api-path="/superadmin/orders"
|
||||
/>
|
||||
<Finance v-if="activeTab === 'finance'" embedded />
|
||||
<!-- 与独立「全局定价」一致:含个人版价格 + 深度服务左右栏(个人/企业) -->
|
||||
<Pricing v-if="activeTab === 'pricing'" embedded />
|
||||
</div>
|
||||
</div>
|
||||
@@ -39,6 +25,8 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import Orders from '@/views/admin/Orders.vue'
|
||||
import Finance from './Finance.vue'
|
||||
import Pricing from './Pricing.vue'
|
||||
import SaPageHeader from '@/components/superadmin/SaPageHeader.vue'
|
||||
import SaTabs from '@/components/superadmin/SaTabs.vue'
|
||||
|
||||
const TAB_IDS = ['orders', 'finance', 'pricing'] as const
|
||||
type TabId = (typeof TAB_IDS)[number]
|
||||
@@ -66,99 +54,21 @@ const applyRouteTab = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const selectTab = (tab: TabId) => {
|
||||
activeTab.value = tab
|
||||
const onTabChange = (tab: TabId) => {
|
||||
const q: Record<string, string> = {}
|
||||
Object.entries(route.query).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && k !== 'tab') {
|
||||
q[k] = Array.isArray(v) ? String(v[0]) : String(v)
|
||||
}
|
||||
})
|
||||
if (tab !== 'orders') {
|
||||
q.tab = tab
|
||||
}
|
||||
if (tab !== 'orders') q.tab = tab
|
||||
router.replace({ path: '/superadmin/commerce', 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">
|
||||
.commerce-hub {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.hub-subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-tabs-container {
|
||||
background-color: #f3f4f6;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
&.tabs-scroll {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.custom-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
min-width: min-content;
|
||||
|
||||
&.tabs-many .tab-item {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hub-body {
|
||||
width: 100%;
|
||||
}
|
||||
<style scoped>
|
||||
.hub-body { width: 100%; }
|
||||
</style>
|
||||
|
||||
@@ -79,6 +79,55 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分销排行榜:TOP 分销员 + 邀请与分润 -->
|
||||
<div class="content-card top-agents-card">
|
||||
<div class="card-title">
|
||||
分销排行榜 · TOP {{ topAgents.length || 0 }}
|
||||
<span class="card-title-sub">按累计分润排序 · 近 {{ topAgentsDays }} 天</span>
|
||||
</div>
|
||||
<div class="filter-group top-days-group">
|
||||
<div
|
||||
v-for="d in [7, 30, 90]"
|
||||
:key="d"
|
||||
:class="['filter-item', { active: topAgentsDays === d }]"
|
||||
@click="changeTopAgentsDays(d)"
|
||||
>近 {{ d }} 天</div>
|
||||
</div>
|
||||
<el-table
|
||||
:data="topAgents"
|
||||
class="custom-table"
|
||||
v-loading="loadingTopAgents"
|
||||
empty-text="暂无分销记录"
|
||||
>
|
||||
<el-table-column label="名次" width="72">
|
||||
<template #default="{ $index }">
|
||||
<span
|
||||
class="rank-badge"
|
||||
:class="{ 'rank-badge--gold': $index === 0, 'rank-badge--silver': $index === 1, 'rank-badge--bronze': $index === 2 }"
|
||||
>{{ $index + 1 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分销员" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="agent-cell">
|
||||
<el-avatar :src="row.avatar" :size="28" v-if="row.avatar" />
|
||||
<div>
|
||||
<div class="agent-name">{{ row.nickname || '未命名' }}</div>
|
||||
<div class="agent-meta">ID {{ row.userId }} · {{ row.phone || '无手机号' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分享次数" width="110" align="center" prop="shareCount" />
|
||||
<el-table-column label="邀请绑定" width="110" align="center" prop="inviteBound" />
|
||||
<el-table-column label="累计分润(元)" width="150" align="right">
|
||||
<template #default="{ row }">
|
||||
<strong class="amount-positive">¥{{ (Number(row.totalCommissionFen || 0) / 100).toFixed(2) }}</strong>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提现管理 -->
|
||||
@@ -331,6 +380,9 @@ const overview = reactive({
|
||||
|
||||
const withdrawals = ref<any[]>([])
|
||||
const commissions = ref<any[]>([])
|
||||
const topAgents = ref<any[]>([])
|
||||
const topAgentsDays = ref(30)
|
||||
const loadingTopAgents = ref(false)
|
||||
|
||||
const distEnabled = ref(true)
|
||||
const promoCenterTitle = ref('推广中心')
|
||||
@@ -394,6 +446,26 @@ const loadOverview = async () => {
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ── 分销 TOP 榜(依赖 /superadmin/analytics/share-stats)
|
||||
const loadTopAgents = async () => {
|
||||
loadingTopAgents.value = true
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/analytics/share-stats', {
|
||||
params: { days: topAgentsDays.value, page: 1, pageSize: 20 }
|
||||
})
|
||||
topAgents.value = res?.data?.list || []
|
||||
} catch (e) {
|
||||
topAgents.value = []
|
||||
} finally {
|
||||
loadingTopAgents.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const changeTopAgentsDays = (d: number) => {
|
||||
topAgentsDays.value = d
|
||||
void loadTopAgents()
|
||||
}
|
||||
|
||||
// ── 提现列表
|
||||
const loadWithdrawals = async () => {
|
||||
loading.value = true
|
||||
@@ -508,7 +580,7 @@ const saveSettings = async () => {
|
||||
}
|
||||
|
||||
watch(activeTab, (t) => {
|
||||
if (t === 'overview') loadOverview()
|
||||
if (t === 'overview') { loadOverview(); loadTopAgents() }
|
||||
else if (t === 'withdrawals') loadWithdrawals()
|
||||
else if (t === 'commissions') loadCommissions()
|
||||
else if (t === 'settings') loadSettings()
|
||||
@@ -518,13 +590,13 @@ watch(withFilter, () => { if (activeTab.value === 'withdrawals') loadWithdrawa
|
||||
watch(commFilter, () => { if (activeTab.value === 'commissions') loadCommissions() })
|
||||
|
||||
const refresh = async () => {
|
||||
if (activeTab.value === 'overview') await loadOverview()
|
||||
if (activeTab.value === 'overview') { await loadOverview(); await loadTopAgents() }
|
||||
else if (activeTab.value === 'withdrawals') await loadWithdrawals()
|
||||
else if (activeTab.value === 'commissions') await loadCommissions()
|
||||
ElMessage.success('数据已刷新')
|
||||
}
|
||||
|
||||
onMounted(() => { loadOverview() })
|
||||
onMounted(() => { loadOverview(); loadTopAgents() })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -699,4 +771,69 @@ onMounted(() => { loadOverview() })
|
||||
.page-container.is-embedded {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
/* 分销 TOP 榜卡 */
|
||||
.top-agents-card {
|
||||
margin-top: 16px;
|
||||
padding: 20px;
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
.card-title-sub {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
.top-days-group {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
margin: 8px 0 12px;
|
||||
background: #f3f4f6;
|
||||
padding: 4px;
|
||||
border-radius: 10px;
|
||||
|
||||
.filter-item {
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.filter-item.active {
|
||||
background: #fff;
|
||||
color: #1e40af;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.rank-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #e5e7eb;
|
||||
color: #6b7280;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
}
|
||||
.rank-badge--gold { background: linear-gradient(135deg, #fde68a, #f59e0b); color: #78350f; }
|
||||
.rank-badge--silver { background: linear-gradient(135deg, #e5e7eb, #9ca3af); color: #1f2937; }
|
||||
.rank-badge--bronze { background: linear-gradient(135deg, #fecaca, #b45309); color: #fff; }
|
||||
|
||||
.agent-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
.agent-name { font-weight: 600; color: #111827; font-size: 13px; }
|
||||
.agent-meta { font-size: 11px; color: #9ca3af; margin-top: 2px; }
|
||||
}
|
||||
|
||||
.amount-positive { color: #d97706; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
<template>
|
||||
<div class="distribution-hub">
|
||||
<div class="hub-header">
|
||||
<h2>分销管理</h2>
|
||||
<p class="hub-subtitle">
|
||||
平台级分销规则、提现与佣金;小程序埋点用于监督各端行为数据。企业管理员日常操作在「管理后台」侧完成。
|
||||
</p>
|
||||
</div>
|
||||
<div class="sa-page distribution-hub">
|
||||
<SaPageHeader
|
||||
title="分销管理"
|
||||
subtitle="平台级分销规则、提现与佣金;小程序埋点用于监督各端行为数据。企业管理员日常操作在「管理后台」侧完成。"
|
||||
/>
|
||||
|
||||
<div class="custom-tabs-container tabs-scroll">
|
||||
<div class="custom-tabs tabs-many">
|
||||
<div
|
||||
v-for="t in innerTabs"
|
||||
:key="t.value"
|
||||
:class="['tab-item', { active: activeTab === t.value }]"
|
||||
@click="selectTab(t.value)"
|
||||
>
|
||||
{{ t.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SaTabs v-model="activeTab" :items="innerTabs" @change="onTabChange" />
|
||||
|
||||
<div class="hub-body">
|
||||
<Distribution v-if="activeTab === 'distribution'" embedded />
|
||||
@@ -32,6 +19,8 @@ import { ref, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import Distribution from './Distribution.vue'
|
||||
import MpAnalytics from './MpAnalytics.vue'
|
||||
import SaPageHeader from '@/components/superadmin/SaPageHeader.vue'
|
||||
import SaTabs from '@/components/superadmin/SaTabs.vue'
|
||||
|
||||
const TAB_IDS = ['distribution', 'analytics'] as const
|
||||
type TabId = (typeof TAB_IDS)[number]
|
||||
@@ -58,103 +47,24 @@ const applyRouteTab = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const selectTab = (tab: TabId) => {
|
||||
activeTab.value = tab
|
||||
const onTabChange = (tab: TabId) => {
|
||||
const q: Record<string, string> = {}
|
||||
Object.entries(route.query).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && k !== 'tab') {
|
||||
q[k] = Array.isArray(v) ? String(v[0]) : String(v)
|
||||
}
|
||||
})
|
||||
if (tab !== 'distribution') {
|
||||
q.tab = tab
|
||||
}
|
||||
if (tab !== 'distribution') q.tab = tab
|
||||
router.replace({
|
||||
path: '/superadmin/distribution',
|
||||
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">
|
||||
.distribution-hub {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.hub-subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
line-height: 1.55;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-tabs-container {
|
||||
background-color: #f3f4f6;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
&.tabs-scroll {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.custom-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
min-width: min-content;
|
||||
|
||||
&.tabs-many .tab-item {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hub-body {
|
||||
width: 100%;
|
||||
}
|
||||
<style scoped>
|
||||
.hub-body { width: 100%; }
|
||||
</style>
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
<template>
|
||||
<div class="enterprise-hub">
|
||||
<div class="hub-header">
|
||||
<h2>企业管理</h2>
|
||||
<p class="hub-subtitle">
|
||||
维护企业档案与平台用户池,对应各企业管理员在「普通管理后台」可见的数据范围。个人侧无企业归属的测试用户在统计与列表中并入「存客宝」。
|
||||
</p>
|
||||
</div>
|
||||
<div class="sa-page enterprise-hub">
|
||||
<SaPageHeader
|
||||
title="企业管理"
|
||||
subtitle="维护企业档案与平台用户池,对应各企业管理员在「普通管理后台」可见的数据范围。个人侧无企业归属的测试用户在统计与列表中并入「存客宝」。"
|
||||
/>
|
||||
|
||||
<div class="custom-tabs-container tabs-scroll">
|
||||
<div class="custom-tabs tabs-many">
|
||||
<div
|
||||
v-for="t in innerTabs"
|
||||
:key="t.value"
|
||||
:class="['tab-item', { active: activeTab === t.value }]"
|
||||
@click="selectTab(t.value)"
|
||||
>
|
||||
{{ t.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SaTabs v-model="activeTab" :items="innerTabs" @change="onTabChange" />
|
||||
|
||||
<el-alert
|
||||
v-if="activeTab === 'users'"
|
||||
@@ -39,6 +26,9 @@
|
||||
<div class="hub-body">
|
||||
<Enterprises v-if="activeTab === 'companies'" embedded />
|
||||
<Users v-if="activeTab === 'users'" :key="usersRefreshKey" embedded />
|
||||
<SoulArticles v-if="activeTab === 'soulArticles'" />
|
||||
<MpTabBar v-if="activeTab === 'mpTabbar'" />
|
||||
<ProfitRules v-if="activeTab === 'profitRules'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -50,8 +40,13 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { request } from '@/utils/request'
|
||||
import Enterprises from './Enterprises.vue'
|
||||
import Users from './Users.vue'
|
||||
import SoulArticles from './SoulArticles.vue'
|
||||
import MpTabBar from './MpTabBar.vue'
|
||||
import ProfitRules from './ProfitRules.vue'
|
||||
import SaPageHeader from '@/components/superadmin/SaPageHeader.vue'
|
||||
import SaTabs from '@/components/superadmin/SaTabs.vue'
|
||||
|
||||
const TAB_IDS = ['companies', 'users'] as const
|
||||
const TAB_IDS = ['companies', 'users', 'soulArticles', 'mpTabbar', 'profitRules'] as const
|
||||
type TabId = (typeof TAB_IDS)[number]
|
||||
|
||||
function isTabId(s: string): s is TabId {
|
||||
@@ -101,7 +96,10 @@ async function runOrphanMigrate() {
|
||||
|
||||
const innerTabs: { label: string; value: TabId }[] = [
|
||||
{ label: '企业列表', value: 'companies' },
|
||||
{ label: '用户总览', value: 'users' }
|
||||
{ label: '用户总览', value: 'users' },
|
||||
{ label: '引流文章', value: 'soulArticles' },
|
||||
{ label: '底部菜单', value: 'mpTabbar' },
|
||||
{ label: '分账规则', value: 'profitRules' }
|
||||
]
|
||||
|
||||
const applyRouteTab = () => {
|
||||
@@ -113,108 +111,25 @@ const applyRouteTab = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const selectTab = (tab: TabId) => {
|
||||
activeTab.value = tab
|
||||
const onTabChange = (tab: TabId) => {
|
||||
const q: Record<string, string> = {}
|
||||
Object.entries(route.query).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && k !== 'tab') {
|
||||
q[k] = Array.isArray(v) ? String(v[0]) : String(v)
|
||||
}
|
||||
})
|
||||
if (tab !== 'companies') {
|
||||
q.tab = tab
|
||||
}
|
||||
if (tab !== 'companies') q.tab = tab
|
||||
router.replace({ path: '/superadmin/enterprises', 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">
|
||||
.enterprise-hub {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.hub-subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-tabs-container {
|
||||
background-color: #f3f4f6;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
|
||||
&.tabs-scroll {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.custom-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
min-width: min-content;
|
||||
|
||||
&.tabs-many .tab-item {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
padding: 8px 18px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #fff;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hub-body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
<style scoped>
|
||||
.hub-body { width: 100%; }
|
||||
.migrate-alert {
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.migrate-text {
|
||||
margin: 0 0 10px;
|
||||
|
||||
@@ -35,21 +35,114 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="card-block" shadow="never">
|
||||
<template #header>
|
||||
<span>分享漏斗(结果页→分享→登录→付费→分润)</span>
|
||||
</template>
|
||||
<div class="funnel-wrap" v-loading="loadingFunnel">
|
||||
<VChart v-if="funnelData.length" class="funnel-chart" :option="funnelOption" autoresize />
|
||||
<div v-else class="empty-hint">暂无漏斗数据</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="card-block" shadow="never">
|
||||
<template #header>
|
||||
<span>事件汇总(共 {{ summaryTotal }} 条记录)</span>
|
||||
</template>
|
||||
<div class="filter-row">
|
||||
<el-select
|
||||
v-model="filterEvent"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="按事件筛选(中文名)"
|
||||
style="width: 320px"
|
||||
@change="onFilterChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in labelOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
:label="opt.label"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="summaryList"
|
||||
stripe
|
||||
:empty-text="tableMissing ? '表未创建,见上方说明' : '暂无数据(小程序有访问后会出现统计)'"
|
||||
>
|
||||
<el-table-column prop="eventName" label="事件名" min-width="200" />
|
||||
<el-table-column prop="eventNameCn" label="事件(中文)" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<strong>{{ row.eventNameCn || row.eventName }}</strong>
|
||||
<div class="props-preview">{{ row.eventName }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="cnt" label="次数" width="120" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-card class="card-block" shadow="never">
|
||||
<template #header>
|
||||
<span>分享 & 邀请 · 用户榜 TOP {{ shareRows.length }}</span>
|
||||
</template>
|
||||
<el-table v-loading="loadingShare" :data="shareRows" stripe empty-text="暂无分享记录(需小程序分享或分销绑定后)">
|
||||
<el-table-column label="#" width="60">
|
||||
<template #default="{ $index }">{{ $index + 1 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="用户" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<el-avatar :src="row.avatar" :size="28" v-if="row.avatar" />
|
||||
<div>
|
||||
<div style="font-weight:600;">{{ row.nickname || '未命名' }}</div>
|
||||
<div style="font-size:12px;color:#94a3b8;">ID {{ row.userId }} · {{ row.phone || '无手机号' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="shareCount" label="分享次数" width="110" align="center" />
|
||||
<el-table-column prop="inviteBound" label="邀请人数" width="110" align="center" />
|
||||
<el-table-column label="累计分润(元)" width="130" align="center">
|
||||
<template #default="{ row }">¥{{ (Number(row.totalCommissionFen || 0) / 100).toFixed(2) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openUserJourney(row.userId, row.nickname)">用户旅程</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
v-model:current-page="sharePage"
|
||||
v-model:page-size="sharePageSize"
|
||||
:total="shareTotal"
|
||||
:page-sizes="[20, 30, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="loadShareStats"
|
||||
@size-change="loadShareStats"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="journeyVisible" :title="journeyTitle" width="720px">
|
||||
<el-table :data="journeyRows" v-loading="journeyLoading" stripe empty-text="暂无记录" max-height="520">
|
||||
<el-table-column prop="createdAt" label="时间" width="170" />
|
||||
<el-table-column label="事件" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<strong>{{ row.eventNameCn || row.eventName }}</strong>
|
||||
<div class="props-preview">{{ row.eventName }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="pagePath" label="页面" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="附加" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="props-preview">{{ formatProps(row.props) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
|
||||
<el-card class="card-block" shadow="never">
|
||||
<template #header>
|
||||
<span>最近明细</span>
|
||||
@@ -57,7 +150,12 @@
|
||||
<el-table v-loading="loadingEvents" :data="eventRows" stripe empty-text="暂无明细">
|
||||
<el-table-column prop="id" label="ID" width="90" />
|
||||
<el-table-column prop="createdAt" label="时间" width="170" />
|
||||
<el-table-column prop="eventName" label="事件" min-width="140" />
|
||||
<el-table-column label="事件" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<strong>{{ row.eventNameCn || row.eventName }}</strong>
|
||||
<div class="props-preview">{{ row.eventName }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="pagePath" label="页面" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="userId" label="用户ID" width="100" />
|
||||
<el-table-column label="附加" min-width="200">
|
||||
@@ -82,8 +180,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { request } from '@/utils/request'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { FunnelChart } from 'echarts/charts'
|
||||
import { TooltipComponent, LegendComponent, TitleComponent } from 'echarts/components'
|
||||
import VChart from 'vue-echarts'
|
||||
|
||||
use([CanvasRenderer, FunnelChart, TooltipComponent, LegendComponent, TitleComponent])
|
||||
|
||||
withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
|
||||
|
||||
@@ -91,12 +196,66 @@ const days = ref(7)
|
||||
const tableMissing = ref(false)
|
||||
const loading = ref(false)
|
||||
const loadingEvents = ref(false)
|
||||
const summaryList = ref<{ eventName: string; cnt: number }[]>([])
|
||||
const summaryList = ref<{ eventName: string; eventNameCn?: string; cnt: number }[]>([])
|
||||
const summaryTotal = ref(0)
|
||||
const eventRows = ref<any[]>([])
|
||||
const eventTotal = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(50)
|
||||
const filterEvent = ref('')
|
||||
const labelOptions = ref<{ value: string; label: string }[]>([])
|
||||
|
||||
const loadingShare = ref(false)
|
||||
const shareRows = ref<any[]>([])
|
||||
const shareTotal = ref(0)
|
||||
const sharePage = ref(1)
|
||||
const sharePageSize = ref(30)
|
||||
|
||||
const journeyVisible = ref(false)
|
||||
const journeyLoading = ref(false)
|
||||
const journeyRows = ref<any[]>([])
|
||||
const journeyTitle = ref('')
|
||||
|
||||
const loadingFunnel = ref(false)
|
||||
const funnelData = ref<{ stage: string; value: number }[]>([])
|
||||
|
||||
const funnelOption = computed(() => {
|
||||
const palette = ['#6366f1', '#8b5cf6', '#ec4899', '#f97316', '#14b8a6']
|
||||
return {
|
||||
tooltip: { trigger: 'item' as const },
|
||||
legend: { bottom: 0 },
|
||||
series: [
|
||||
{
|
||||
name: '分享漏斗',
|
||||
type: 'funnel',
|
||||
left: '6%',
|
||||
right: '6%',
|
||||
top: 20,
|
||||
bottom: 40,
|
||||
width: '88%',
|
||||
sort: 'none',
|
||||
gap: 4,
|
||||
labelLine: { length: 18 },
|
||||
itemStyle: {
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'inside',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
formatter: (p: any) => `${p.name}\n${p.value}`
|
||||
},
|
||||
data: funnelData.value.map((item, i) => ({
|
||||
name: item.stage,
|
||||
value: Number(item.value || 0),
|
||||
itemStyle: { color: palette[i % palette.length] }
|
||||
}))
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
function formatProps(p: unknown): string {
|
||||
if (p == null) return '—'
|
||||
@@ -111,12 +270,20 @@ function formatProps(p: unknown): string {
|
||||
async function loadSummary() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/analytics/summary', {
|
||||
params: { days: days.value }
|
||||
})
|
||||
const params: Record<string, string | number> = { days: days.value }
|
||||
if (filterEvent.value) params.eventName = filterEvent.value
|
||||
const res: any = await request.get('/superadmin/analytics/summary', { params })
|
||||
summaryList.value = res.data?.list || []
|
||||
summaryTotal.value = res.data?.total ?? 0
|
||||
tableMissing.value = !!res.data?.tableMissing
|
||||
|
||||
const labels = res.data?.labels
|
||||
if (labels && typeof labels === 'object') {
|
||||
labelOptions.value = Object.entries(labels).map(([k, v]) => ({
|
||||
value: k,
|
||||
label: `${v as string}(${k})`
|
||||
}))
|
||||
}
|
||||
} catch {
|
||||
summaryList.value = []
|
||||
summaryTotal.value = 0
|
||||
@@ -129,13 +296,13 @@ async function loadSummary() {
|
||||
async function loadEvents() {
|
||||
loadingEvents.value = true
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/analytics/events', {
|
||||
params: {
|
||||
days: days.value,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value
|
||||
}
|
||||
})
|
||||
const params: Record<string, string | number> = {
|
||||
days: days.value,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value
|
||||
}
|
||||
if (filterEvent.value) params.eventName = filterEvent.value
|
||||
const res: any = await request.get('/superadmin/analytics/events', { params })
|
||||
eventRows.value = res.data?.list || []
|
||||
eventTotal.value = res.data?.total ?? 0
|
||||
if (res.data?.tableMissing) {
|
||||
@@ -150,10 +317,64 @@ async function loadEvents() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadShareStats() {
|
||||
loadingShare.value = true
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/analytics/share-stats', {
|
||||
params: { days: days.value, page: sharePage.value, pageSize: sharePageSize.value }
|
||||
})
|
||||
shareRows.value = res.data?.list || []
|
||||
shareTotal.value = res.data?.total ?? 0
|
||||
} catch {
|
||||
shareRows.value = []
|
||||
shareTotal.value = 0
|
||||
} finally {
|
||||
loadingShare.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openUserJourney(userId: number, nickname: string) {
|
||||
journeyTitle.value = `用户旅程 · ${nickname || '未命名'}(ID ${userId})`
|
||||
journeyVisible.value = true
|
||||
journeyLoading.value = true
|
||||
journeyRows.value = []
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/analytics/user-journey', {
|
||||
params: { userId, days: days.value }
|
||||
})
|
||||
journeyRows.value = res.data?.list || []
|
||||
} finally {
|
||||
journeyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadShareFunnel() {
|
||||
loadingFunnel.value = true
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/analytics/share-funnel', {
|
||||
params: { days: days.value }
|
||||
})
|
||||
funnelData.value = res.data?.funnel || []
|
||||
} catch {
|
||||
funnelData.value = []
|
||||
} finally {
|
||||
loadingFunnel.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
page.value = 1
|
||||
sharePage.value = 1
|
||||
await loadSummary()
|
||||
await loadEvents()
|
||||
await loadShareStats()
|
||||
await loadShareFunnel()
|
||||
}
|
||||
|
||||
function onFilterChange() {
|
||||
page.value = 1
|
||||
void loadSummary()
|
||||
void loadEvents()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -165,6 +386,31 @@ onMounted(() => {
|
||||
.setup-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.filter-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.props-preview {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.funnel-wrap {
|
||||
width: 100%;
|
||||
min-height: 340px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.funnel-chart {
|
||||
width: 100%;
|
||||
height: 360px;
|
||||
}
|
||||
.empty-hint {
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
padding: 48px 0;
|
||||
}
|
||||
.mp-analytics {
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
|
||||
351
admin/src/views/superadmin/MpTabBar.vue
Normal file
351
admin/src/views/superadmin/MpTabBar.vue
Normal file
@@ -0,0 +1,351 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>小程序底部菜单</h2>
|
||||
<p class="subtitle">
|
||||
拖拽排序、开关显隐、修改文案/图标,保存后小程序 60 秒内自动生效。
|
||||
至少保留 2 个可见 Tab,最多 5 个。
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button @click="fetchList">
|
||||
<el-icon><Refresh /></el-icon>刷新
|
||||
</el-button>
|
||||
<el-button type="primary" @click="onSave" :loading="saving">
|
||||
<el-icon><Check /></el-icon>保存
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>Tab 列表(拖拽行首"☰"排序)</span>
|
||||
<el-button size="small" @click="onAdd" :disabled="list.length >= 5">
|
||||
<el-icon><Plus /></el-icon>新增 Tab
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="list" row-key="tempId" border stripe>
|
||||
<el-table-column width="60" align="center" label="排序">
|
||||
<template #default="{ row, $index }">
|
||||
<div class="drag-handle">
|
||||
<el-icon><Operation /></el-icon>
|
||||
<span style="margin-left: 4px">{{ $index + 1 }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="SVG图标" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.iconKey" size="small" style="width: 100%">
|
||||
<el-option label="首页" value="home" />
|
||||
<el-option label="拍摄" value="camera" />
|
||||
<el-option label="AI" value="ai" />
|
||||
<el-option label="我" value="profile" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="图片URL(优先)" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-input
|
||||
v-model="row.iconUrl"
|
||||
size="small"
|
||||
placeholder="/images/xxx.png 或 https://..."
|
||||
clearable
|
||||
>
|
||||
<template #append v-if="row.iconUrl">
|
||||
<img :src="row.iconUrl" style="width: 22px; height: 22px; border-radius: 4px;" />
|
||||
</template>
|
||||
</el-input>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="文案" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.text" size="small" maxlength="6" show-word-limit />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="页面路径" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.pagePath" size="small" placeholder="pages/xxx/xxx">
|
||||
<template #prepend>/</template>
|
||||
</el-input>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="中间浮钮" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.highlight" :active-value="1" :inactive-value="0" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="显示" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.visible" :active-value="1" :inactive-value="0" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="150" align="center">
|
||||
<template #default="{ row, $index }">
|
||||
<el-button size="small" link @click="onMove($index, -1)" :disabled="$index === 0">↑</el-button>
|
||||
<el-button size="small" link @click="onMove($index, 1)" :disabled="$index === list.length - 1">↓</el-button>
|
||||
<el-button size="small" type="danger" link @click="onRemove($index)" :disabled="list.length <= 2">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="preview">
|
||||
<div class="preview-title">📱 小程序底部预览</div>
|
||||
<div class="mp-bar">
|
||||
<template v-for="(it, idx) in visibleItems" :key="idx">
|
||||
<div v-if="it.highlight" class="mp-item mp-item--fab">
|
||||
<div class="mp-fab" :class="'mp-icon--' + (it.iconKey || 'home')"></div>
|
||||
<div class="mp-text">{{ it.text }}</div>
|
||||
</div>
|
||||
<div v-else class="mp-item">
|
||||
<div class="mp-icon" :class="'mp-icon--' + (it.iconKey || 'home')"></div>
|
||||
<div class="mp-text">{{ it.text }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="preview-hint">仅为示意,实际样式以小程序端为准。</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh, Check, Plus, Operation } from '@element-plus/icons-vue'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
interface TabItem {
|
||||
id?: number
|
||||
tempId?: string
|
||||
sortOrder: number
|
||||
pagePath: string
|
||||
text: string
|
||||
iconKey: string
|
||||
iconUrl?: string | null
|
||||
visible: number
|
||||
highlight: number
|
||||
badgeKey?: string | null
|
||||
}
|
||||
|
||||
const list = ref<TabItem[]>([])
|
||||
const saving = ref(false)
|
||||
|
||||
const visibleItems = computed(() => list.value.filter(x => x.visible === 1))
|
||||
|
||||
const tmpId = () => 't' + Date.now() + '-' + Math.floor(Math.random() * 1000)
|
||||
|
||||
const fetchList = async () => {
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/tabbar')
|
||||
if (res && res.code === 200) {
|
||||
const raw = (res.data && res.data.list) || []
|
||||
list.value = raw.map((r: any) => ({
|
||||
id: r.id,
|
||||
tempId: 'db-' + r.id,
|
||||
sortOrder: r.sortOrder,
|
||||
pagePath: r.pagePath,
|
||||
text: r.text,
|
||||
iconKey: r.iconKey || 'home',
|
||||
iconUrl: r.iconUrl || '',
|
||||
visible: Number(r.visible ?? 1),
|
||||
highlight: Number(r.highlight ?? 0),
|
||||
badgeKey: r.badgeKey || null,
|
||||
}))
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onAdd = () => {
|
||||
if (list.value.length >= 5) return
|
||||
list.value.push({
|
||||
tempId: tmpId(),
|
||||
sortOrder: (list.value.length + 1) * 10,
|
||||
pagePath: 'pages/',
|
||||
text: '新菜单',
|
||||
iconKey: 'home',
|
||||
iconUrl: '',
|
||||
visible: 1,
|
||||
highlight: 0,
|
||||
})
|
||||
}
|
||||
|
||||
const onRemove = async (idx: number) => {
|
||||
const it = list.value[idx]
|
||||
const r = await ElMessageBox.confirm(`删除「${it.text}」?`, '确认', { type: 'warning' }).catch(() => 'cancel')
|
||||
if (r === 'cancel') return
|
||||
list.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
const onMove = (idx: number, dir: -1 | 1) => {
|
||||
const target = idx + dir
|
||||
if (target < 0 || target >= list.value.length) return
|
||||
const arr = list.value.slice()
|
||||
const [moved] = arr.splice(idx, 1)
|
||||
arr.splice(target, 0, moved)
|
||||
list.value = arr
|
||||
}
|
||||
|
||||
const onSave = async () => {
|
||||
if (list.value.length < 2) return ElMessage.warning('至少 2 个 Tab')
|
||||
if (list.value.length > 5) return ElMessage.warning('最多 5 个 Tab')
|
||||
const v = list.value.filter(x => x.visible === 1).length
|
||||
if (v < 2) return ElMessage.warning('至少 2 个可见 Tab')
|
||||
|
||||
for (const [idx, it] of list.value.entries()) {
|
||||
if (!it.text || !it.text.trim()) return ElMessage.warning(`第 ${idx + 1} 行文案不能为空`)
|
||||
if (!it.pagePath || !it.pagePath.trim()) return ElMessage.warning(`第 ${idx + 1} 行页面路径不能为空`)
|
||||
it.sortOrder = (idx + 1) * 10
|
||||
it.pagePath = it.pagePath.replace(/^\//, '')
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const res: any = await request.post('/superadmin/tabbar/save', {
|
||||
items: list.value.map(it => ({
|
||||
id: it.id,
|
||||
sortOrder: it.sortOrder,
|
||||
pagePath: it.pagePath,
|
||||
text: it.text,
|
||||
iconKey: it.iconKey,
|
||||
iconUrl: it.iconUrl,
|
||||
visible: it.visible,
|
||||
highlight: it.highlight,
|
||||
badgeKey: it.badgeKey,
|
||||
}))
|
||||
})
|
||||
if (res && res.code === 200) {
|
||||
ElMessage.success(res.message || '保存成功')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error((res && res.message) || '保存失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container { padding: 24px; }
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.header-left h2 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 22px;
|
||||
color: #1F1B4D;
|
||||
}
|
||||
.subtitle {
|
||||
color: #6B6894;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
max-width: 720px;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #9CA3AF;
|
||||
}
|
||||
|
||||
.preview {
|
||||
margin-top: 24px;
|
||||
border: 1px dashed #E5E7EB;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background: #FAFAFA;
|
||||
}
|
||||
.preview-title {
|
||||
font-size: 13px;
|
||||
color: #6B6894;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.mp-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 10px 16px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
}
|
||||
.mp-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
.mp-item--fab {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
.mp-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-size: 22px 22px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
.mp-fab {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
|
||||
box-shadow: 0 4px 12px rgba(124,58,237,0.4);
|
||||
background-size: 24px 24px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.mp-text {
|
||||
font-size: 11px;
|
||||
color: #6B6894;
|
||||
}
|
||||
.mp-icon--home { background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236B6894' stroke-width='2' stroke-linejoin='round'><path d='M3 12l9-9 9 9M5 10v10h14V10'/></svg>"); }
|
||||
.mp-icon--camera { background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236B6894' stroke-width='2' stroke-linejoin='round'><path d='M4 7h3l2-3h6l2 3h3v13H4z'/><circle cx='12' cy='13' r='4'/></svg>"); }
|
||||
.mp-icon--ai { background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236B6894' stroke-width='2' stroke-linecap='round'><path d='M12 3v3M12 18v3M3 12h3M18 12h3'/><circle cx='12' cy='12' r='3'/></svg>"); }
|
||||
.mp-icon--profile{ background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236B6894' stroke-width='2' stroke-linejoin='round'><circle cx='12' cy='8' r='4'/><path d='M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8'/></svg>"); }
|
||||
.mp-fab.mp-icon--camera,
|
||||
.mp-fab.mp-icon--ai,
|
||||
.mp-fab.mp-icon--home,
|
||||
.mp-fab.mp-icon--profile {
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linejoin='round'><path d='M4 7h3l2-3h6l2 3h3v13H4z'/><circle cx='12' cy='13' r='4'/></svg>"),
|
||||
linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
|
||||
}
|
||||
.preview-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #9CA3AF;
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,10 @@
|
||||
<template>
|
||||
<div class="ops-hub">
|
||||
<div class="hub-header">
|
||||
<h2>总览</h2>
|
||||
<p class="hub-subtitle">
|
||||
平台级数据总览,用于对照各企业在「普通管理后台」的经营情况。分销与埋点在侧栏「分销管理」;订单与定价在「订单和财务」;模型与算力在「智能算力」。
|
||||
</p>
|
||||
</div>
|
||||
<div class="sa-page ops-hub">
|
||||
<SaPageHeader
|
||||
title="平台总览"
|
||||
subtitle="平台级数据对照各企业在「普通管理后台」的经营情况。分销与埋点在侧栏「分销管理」;订单与定价在「订单和财务」;模型与算力在「智能算力」。"
|
||||
badge="SuperAdmin"
|
||||
/>
|
||||
<div class="hub-body">
|
||||
<Overview />
|
||||
</div>
|
||||
@@ -14,33 +13,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Overview from './Overview.vue'
|
||||
import SaPageHeader from '@/components/superadmin/SaPageHeader.vue'
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ops-hub {
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.hub-subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
.hub-body {
|
||||
width: 100%;
|
||||
}
|
||||
<style scoped>
|
||||
.hub-body { width: 100%; }
|
||||
</style>
|
||||
|
||||
@@ -655,18 +655,18 @@ onMounted(async () => {
|
||||
flex-shrink: 0;
|
||||
|
||||
&.purple {
|
||||
background-color: #f3e8ff;
|
||||
color: #a855f7;
|
||||
background-color: #E0E7FF;
|
||||
color: #1E40AF;
|
||||
}
|
||||
|
||||
&.green {
|
||||
background-color: #dcfce7;
|
||||
color: #22c55e;
|
||||
background-color: #D1FAE5;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
&.orange {
|
||||
background-color: #fed7aa;
|
||||
color: #f97316;
|
||||
background-color: #FEF3C7;
|
||||
color: #D97706;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -719,23 +719,23 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
&.blue .action-icon {
|
||||
background-color: #3b82f6;
|
||||
background-image: linear-gradient(135deg, #1E40AF 0%, #3730A3 100%);
|
||||
}
|
||||
|
||||
&.green .action-icon {
|
||||
background-color: #22c55e;
|
||||
background-image: linear-gradient(135deg, #059669 0%, #047857 100%);
|
||||
}
|
||||
|
||||
&.orange .action-icon {
|
||||
background-color: #f97316;
|
||||
background-image: linear-gradient(135deg, #D97706 0%, #B45309 100%);
|
||||
}
|
||||
|
||||
&.purple .action-icon {
|
||||
background-color: #a855f7;
|
||||
background-image: linear-gradient(135deg, #4338CA 0%, #312E81 100%);
|
||||
}
|
||||
|
||||
&.gray .action-icon {
|
||||
background-color: #6b7280;
|
||||
background-color: #475569;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -848,7 +848,7 @@ onMounted(async () => {
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
color: #ef4444;
|
||||
color: #1E40AF;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
199
admin/src/views/superadmin/ProfitRules.vue
Normal file
199
admin/src/views/superadmin/ProfitRules.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>分账规则</h2>
|
||||
<p class="subtitle">按产品类型配置分账接收人与比例,支付成功后自动执行。比例合计须等于 100%。</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="openEdit()">
|
||||
<el-icon><Plus /></el-icon>新增规则
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" border stripe v-loading="loading">
|
||||
<el-table-column prop="productType" label="产品类型" width="200" />
|
||||
<el-table-column prop="name" label="名称" width="240" />
|
||||
<el-table-column label="分账明细" min-width="320">
|
||||
<template #default="{ row }">
|
||||
<div v-for="(r, i) in row.receivers" :key="i" class="receiver-line">
|
||||
<el-tag size="small">{{ labelOf(r.type) }}</el-tag>
|
||||
<span style="margin-left: 6px;">{{ r.name }}</span>
|
||||
<span class="ratio">{{ (Number(r.ratio) * 100).toFixed(1) }}%</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'info'">
|
||||
{{ row.status === 'active' ? '生效中' : '已禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" link @click="onToggle(row)">
|
||||
{{ row.status === 'active' ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="showDialog" :title="form.id ? '编辑规则' : '新增规则'" width="680px">
|
||||
<el-form :model="form" label-width="110px">
|
||||
<el-form-item label="产品类型">
|
||||
<el-input v-model="form.productType" :disabled="!!form.id" placeholder="如 ai_deep_report" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="form.name" placeholder="如 AI 深度画像报告" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-switch v-model="form.status" active-value="active" inactive-value="disabled" active-text="生效" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分账接收人">
|
||||
<div>
|
||||
<div v-for="(r, i) in form.receivers" :key="i" class="receiver-row">
|
||||
<el-select v-model="r.type" style="width: 160px">
|
||||
<el-option label="平台" value="platform" />
|
||||
<el-option label="一级分销" value="distributor_l1" />
|
||||
<el-option label="二级分销" value="distributor_l2" />
|
||||
<el-option label="顾问" value="consultant" />
|
||||
<el-option label="自定义" value="custom" />
|
||||
</el-select>
|
||||
<el-input v-model="r.name" placeholder="显示名" style="width: 180px; margin-left: 8px" />
|
||||
<el-input-number v-model="r.ratio" :min="0" :max="1" :step="0.05" :precision="3" style="margin-left: 8px" />
|
||||
<span style="margin-left: 8px">比例(0~1)</span>
|
||||
<el-button link type="danger" @click="form.receivers.splice(i, 1)">删除</el-button>
|
||||
</div>
|
||||
<div style="margin-top: 10px">
|
||||
<el-button size="small" @click="form.receivers.push({ type: 'platform', name: '平台', ratio: 0 })">
|
||||
<el-icon><Plus /></el-icon>添加接收人
|
||||
</el-button>
|
||||
<span class="sum-tip" :class="{ err: !sumOk }">
|
||||
当前合计:{{ sumPercent.toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" :disabled="!sumOk" @click="onSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
interface Receiver { type: string; name: string; ratio: number }
|
||||
interface Rule {
|
||||
id?: number
|
||||
productType: string
|
||||
name: string
|
||||
receivers: Receiver[]
|
||||
status: 'active' | 'disabled'
|
||||
}
|
||||
|
||||
const list = ref<Rule[]>([])
|
||||
const loading = ref(false)
|
||||
const showDialog = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const emptyForm = (): Rule => ({
|
||||
productType: '', name: '', receivers: [{ type: 'platform', name: '平台', ratio: 1 }], status: 'active'
|
||||
})
|
||||
const form = ref<Rule>(emptyForm())
|
||||
|
||||
const sumPercent = computed(() => {
|
||||
return form.value.receivers.reduce((s, r) => s + (Number(r.ratio) || 0), 0) * 100
|
||||
})
|
||||
const sumOk = computed(() => Math.abs(sumPercent.value - 100) < 0.5)
|
||||
|
||||
const labelOf = (t: string) => ({
|
||||
platform: '平台',
|
||||
distributor_l1: '一级分销',
|
||||
distributor_l2: '二级分销',
|
||||
consultant: '顾问',
|
||||
custom: '自定义',
|
||||
}[t] || t)
|
||||
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/profit-rules')
|
||||
if (res && res.code === 200) {
|
||||
list.value = (res.data?.list || []).map((r: any) => ({
|
||||
id: r.id,
|
||||
productType: r.productType,
|
||||
name: r.name,
|
||||
receivers: Array.isArray(r.receivers) ? r.receivers : [],
|
||||
status: r.status
|
||||
}))
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openEdit = (row?: Rule) => {
|
||||
if (row) {
|
||||
form.value = JSON.parse(JSON.stringify(row))
|
||||
} else {
|
||||
form.value = emptyForm()
|
||||
}
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
const onSave = async () => {
|
||||
if (!form.value.productType || !form.value.name) return ElMessage.warning('请填写产品类型与名称')
|
||||
if (!sumOk.value) return ElMessage.warning('分账比例合计必须 = 100%')
|
||||
saving.value = true
|
||||
try {
|
||||
const res: any = await request.post('/superadmin/profit-rules/save', form.value)
|
||||
if (res && res.code === 200) {
|
||||
ElMessage.success('已保存')
|
||||
showDialog.value = false
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res?.message || '保存失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onToggle = async (row: Rule) => {
|
||||
try {
|
||||
const res: any = await request.post(`/superadmin/profit-rules/${row.id}/toggle`)
|
||||
if (res && res.code === 200) {
|
||||
ElMessage.success('已切换')
|
||||
fetchList()
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container { padding: 24px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; }
|
||||
.page-header h2 { margin: 0 0 6px; color: #1F1B4D; }
|
||||
.subtitle { color: #6B6894; font-size: 13px; max-width: 800px; }
|
||||
.receiver-line { display: flex; align-items: center; margin-bottom: 4px; }
|
||||
.ratio { margin-left: auto; color: #7c3aed; font-weight: 600; }
|
||||
.receiver-row { display: flex; align-items: center; margin-bottom: 10px; }
|
||||
.sum-tip { margin-left: 16px; color: #059669; font-weight: 600; }
|
||||
.sum-tip.err { color: #EF4444; }
|
||||
</style>
|
||||
740
admin/src/views/superadmin/SoulArticles.vue
Normal file
740
admin/src/views/superadmin/SoulArticles.vue
Normal file
@@ -0,0 +1,740 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>Soul 引流文章</h2>
|
||||
<p class="subtitle">
|
||||
采集由服务端<strong>仅通过 HTTPS</strong>请求「一场 soul 创业实验」开放接口,<strong>不使用 SSH</strong>。
|
||||
「当前推荐」至多 3 篇;需在下方开启<strong>神仙 AI 页</strong>或<strong>我的页推荐条</strong>后,小程序对应位置才会展示;排序第 1 篇用于「我的」底部灰字链接。
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索一场创业实验内容关键词"
|
||||
clearable
|
||||
style="width: 260px"
|
||||
@keyup.enter="onSearchImport"
|
||||
/>
|
||||
<el-button @click="onSearchImport" :loading="searching">
|
||||
搜索并添加
|
||||
</el-button>
|
||||
<el-button type="primary" @click="onSync" :loading="syncing">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
采集最新 10 篇
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 神仙 AI 健康小条 -->
|
||||
<el-card v-if="health.loaded" class="health-card" shadow="never" :class="{ 'health-card--warn': health.hasAlert }">
|
||||
<div class="health-bar">
|
||||
<div class="health-title">
|
||||
<span class="dot" :class="health.hasAlert ? 'dot-warn' : 'dot-ok'"></span>
|
||||
神仙 AI 服务状态
|
||||
<el-tag v-if="health.hasAlert" type="danger" size="small">有告警</el-tag>
|
||||
<el-tag v-else type="success" size="small">正常</el-tag>
|
||||
</div>
|
||||
<div class="health-providers">
|
||||
<span v-for="p in health.providers" :key="p.providerId" class="provider-chip" :class="{ 'chip-warn': p.status === 'low-balance' || p.status === 'no-key' || p.status === 'disabled' }">
|
||||
{{ p.name }} · {{ p.currency === 'USD' ? '$' : '¥' }}{{ p.balance == null ? '—' : Number(p.balance).toFixed(2) }}
|
||||
<template v-if="p.status === 'low-balance'">(欠费)</template>
|
||||
<template v-else-if="p.status === 'disabled'">(已停用)</template>
|
||||
<template v-else-if="p.status === 'no-key'">(缺 key)</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="health-actions">
|
||||
<el-button size="small" :loading="checking" @click="onBalanceCheck">立即扫描余额</el-button>
|
||||
<el-button size="small" type="primary" plain @click="fetchHealth">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="health.lastAlert" class="health-alert">
|
||||
最近告警:{{ health.lastAlert.providerId }} · 余额 {{ health.lastAlert.balance }} / 阈值 {{ health.lastAlert.threshold }}({{ health.lastAlert.dateStr }})
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 小程序 · 推荐文章展示(神仙 AI / 我的) -->
|
||||
<el-card class="ai-chat-display-card" shadow="never">
|
||||
<template #header>
|
||||
<span>小程序 · 推荐文章展示</span>
|
||||
</template>
|
||||
<el-form label-width="160px" class="ai-chat-display-form">
|
||||
<el-form-item label="神仙 AI 页展示">
|
||||
<el-switch v-model="aiChatDisplay.enabled" />
|
||||
<span class="form-hint-inline">开启后神仙 AI 页显示「精选推荐」列表</span>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="aiChatDisplay.enabled" label="最多展示条数">
|
||||
<el-radio-group v-model="aiChatDisplay.maxShow">
|
||||
<el-radio :label="1">1 篇</el-radio>
|
||||
<el-radio :label="2">2 篇</el-radio>
|
||||
<el-radio :label="3">3 篇</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="aiChatDisplay.enabled" label="默认展开列表">
|
||||
<el-switch v-model="aiChatDisplay.sectionExpandedDefault" />
|
||||
<span class="form-hint-inline">关闭则默认收起,用户点击「精选推荐」后展开</span>
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">我的 · 底部推荐条</el-divider>
|
||||
<el-form-item label="我的页展示首条">
|
||||
<el-switch v-model="aiChatDisplay.profileRecoEnabled" />
|
||||
<span class="form-hint-inline">在「我的」快捷入口下方展示<strong>当前推荐排序第 1 篇</strong>(灰色字可点)</span>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="aiChatDisplay.profileRecoEnabled" label="区块标题 / 标签">
|
||||
<el-input
|
||||
v-model="aiChatDisplay.profileSectionLabel"
|
||||
maxlength="32"
|
||||
show-word-limit
|
||||
placeholder="如:一场创业实验、精选阅读"
|
||||
style="max-width: 360px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="savingAcDisplay" @click="saveAiChatDisplay">
|
||||
保存展示设置
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 推荐位概览 -->
|
||||
<el-card class="reco-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>当前推荐(至多 3 篇 · 需在上方开启「小程序展示」后按条数展示)</span>
|
||||
<div class="reco-head-actions">
|
||||
<span class="reco-count">{{ recommended.length }} / 3</span>
|
||||
<el-button size="small" @click="onNormalizeOrder">一键归一排序</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-if="recommended.length === 0" description="还没有推荐文章,去下方列表勾选一篇吧" />
|
||||
<div v-else class="reco-list">
|
||||
<div v-for="(a, idx) in recommended" :key="a.id" class="reco-item">
|
||||
<div class="reco-rank">{{ idx + 1 }}</div>
|
||||
<img v-if="a.cover" :src="a.cover" class="reco-cover" />
|
||||
<div v-else class="reco-cover reco-cover--placeholder">MBTI</div>
|
||||
<div class="reco-body">
|
||||
<div class="reco-title">{{ a.title }}</div>
|
||||
<div class="reco-meta">
|
||||
<el-tag size="small" type="primary">{{ a.tag || 'MBTI' }}</el-tag>
|
||||
<span class="meta-text">{{ formatDate(a.publishedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-button size="small" @click="onToggleRecommend(a)">取消推荐</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 候选文章列表 -->
|
||||
<el-card class="list-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header card-header-col">
|
||||
<span>候选文章池</span>
|
||||
<div class="filter-row">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="按标题关键词筛选"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
@keyup.enter="onSearch"
|
||||
/>
|
||||
<el-input
|
||||
v-model="tagFilter"
|
||||
placeholder="标签(如 MBTI)"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
@keyup.enter="onSearch"
|
||||
/>
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
unlink-panels
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 320px"
|
||||
/>
|
||||
<el-radio-group v-model="filter" size="small" @change="fetchList">
|
||||
<el-radio-button label="">全部</el-radio-button>
|
||||
<el-radio-button label="0">未推荐</el-radio-button>
|
||||
<el-radio-button label="1">已推荐</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button type="primary" plain @click="onSearch">筛选</el-button>
|
||||
<el-button @click="onReset">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="list" v-loading="loading" stripe style="width: 100%">
|
||||
<el-table-column label="封面" width="100">
|
||||
<template #default="{ row }">
|
||||
<img v-if="row.cover" :src="row.cover" style="width: 80px; height: 50px; object-fit: cover; border-radius: 6px;" />
|
||||
<div v-else style="width: 80px; height: 50px; background: #a78bfa; color: white; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 12px;">MBTI</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="标题" min-width="320" show-overflow-tooltip />
|
||||
<el-table-column prop="tag" label="标签" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ row.tag || 'MBTI' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="author" label="作者" width="120" />
|
||||
<el-table-column label="发布时间" width="160">
|
||||
<template #default="{ row }">{{ formatDate(row.publishedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="阅读" width="80">
|
||||
<template #default="{ row }">{{ row.viewCount || 0 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.isRecommended" type="success" size="small">推荐中</el-tag>
|
||||
<el-tag v-else type="info" size="small">未推荐</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="置顶权重" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.isRecommended" class="order-cell">
|
||||
<el-input-number
|
||||
v-model="row.recommendedOrder"
|
||||
:min="0"
|
||||
:max="999"
|
||||
:controls="false"
|
||||
size="small"
|
||||
style="width: 72px"
|
||||
/>
|
||||
<el-button size="small" type="primary" link @click="onUpdateOrder(row)">保存</el-button>
|
||||
</div>
|
||||
<span v-else class="order-empty">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" link @click="preview(row)">预览</el-button>
|
||||
<el-button size="small" :type="row.isRecommended ? 'warning' : 'success'" @click="onToggleRecommend(row)">
|
||||
{{ row.isRecommended ? '取消' : '推荐' }}
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" link @click="onDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="fetchList"
|
||||
@size-change="fetchList"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
const loading = ref(false)
|
||||
const syncing = ref(false)
|
||||
const searching = ref(false)
|
||||
const checking = ref(false)
|
||||
const list = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const filter = ref<string>('')
|
||||
const searchKeyword = ref('')
|
||||
const keyword = ref('')
|
||||
const tagFilter = ref('')
|
||||
const dateRange = ref<string[]>([])
|
||||
const health = ref<any>({ loaded: false, providers: [], lastAlert: null, hasAlert: false })
|
||||
|
||||
const aiChatDisplay = ref({
|
||||
enabled: false,
|
||||
maxShow: 1,
|
||||
sectionExpandedDefault: false,
|
||||
profileRecoEnabled: false,
|
||||
profileSectionLabel: '推荐阅读'
|
||||
})
|
||||
const savingAcDisplay = ref(false)
|
||||
|
||||
const recommended = computed(() => list.value.filter(x => x.isRecommended))
|
||||
|
||||
const formatDate = (ts: number) => {
|
||||
if (!ts) return '—'
|
||||
const d = new Date(ts * 1000)
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
}
|
||||
const pad = (n: number) => n < 10 ? '0' + n : '' + n
|
||||
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/soul-articles', {
|
||||
params: {
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
...(filter.value !== '' ? { isRecommended: filter.value } : {}),
|
||||
...(keyword.value ? { keyword: keyword.value } : {}),
|
||||
...(tagFilter.value ? { tag: tagFilter.value } : {}),
|
||||
...(dateRange.value && dateRange.value.length === 2 ? { dateRange: dateRange.value } : {})
|
||||
}
|
||||
})
|
||||
if (res && res.code === 200) {
|
||||
list.value = (res.data && res.data.list) || []
|
||||
total.value = (res.data && res.data.total) || 0
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onSearch = () => {
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
keyword.value = ''
|
||||
tagFilter.value = ''
|
||||
dateRange.value = []
|
||||
filter.value = ''
|
||||
page.value = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const onSync = async () => {
|
||||
syncing.value = true
|
||||
try {
|
||||
const res: any = await request.post('/superadmin/soul-articles/sync', { limit: 10, tag: 'MBTI' })
|
||||
if (res && res.code === 200) {
|
||||
const d = res.data || {}
|
||||
ElMessage.success(`采集完成:新增 ${d.created || 0} 篇,更新 ${d.updated || 0} 篇`)
|
||||
page.value = 1
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error((res && res.message) || '采集失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '采集失败,请稍后再试')
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onSearchImport = async () => {
|
||||
const keyword = (searchKeyword.value || '').trim()
|
||||
if (!keyword) {
|
||||
ElMessage.warning('请先输入关键词')
|
||||
return
|
||||
}
|
||||
searching.value = true
|
||||
try {
|
||||
const res: any = await request.post('/superadmin/soul-articles/sync', {
|
||||
limit: 10,
|
||||
tag: 'MBTI',
|
||||
keyword
|
||||
})
|
||||
if (res && res.code === 200) {
|
||||
const d = res.data || {}
|
||||
ElMessage.success(`已搜索并添加:新增 ${d.created || 0} 篇,更新 ${d.updated || 0} 篇`)
|
||||
page.value = 1
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error((res && res.message) || '搜索添加失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '搜索添加失败')
|
||||
} finally {
|
||||
searching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onToggleRecommend = async (row: any) => {
|
||||
const current = list.value.filter(x => x.isRecommended).length
|
||||
if (!row.isRecommended && current >= 3) {
|
||||
const r = await ElMessageBox.confirm(
|
||||
'当前已有 3 篇推荐,继续推荐将自动顶掉最老的一篇,确认继续?',
|
||||
'推荐已满',
|
||||
{ type: 'warning' }
|
||||
).catch(() => 'cancel')
|
||||
if (r === 'cancel') return
|
||||
}
|
||||
try {
|
||||
const res: any = await request.post(`/superadmin/soul-articles/${row.id}/recommend`)
|
||||
if (res && res.code === 200) {
|
||||
ElMessage.success(res.message || '操作成功')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error((res && res.message) || '操作失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onDelete = async (row: any) => {
|
||||
const r = await ElMessageBox.confirm(`确认删除「${row.title}」?`, '确认', { type: 'warning' }).catch(() => 'cancel')
|
||||
if (r === 'cancel') return
|
||||
try {
|
||||
const res: any = await request.post(`/superadmin/soul-articles/${row.id}/delete`)
|
||||
if (res && res.code === 200) {
|
||||
ElMessage.success('已删除')
|
||||
fetchList()
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onUpdateOrder = async (row: any) => {
|
||||
try {
|
||||
const res: any = await request.post(`/superadmin/soul-articles/${row.id}/order`, {
|
||||
recommendedOrder: Number(row.recommendedOrder || 0),
|
||||
})
|
||||
if (res && res.code === 200) {
|
||||
ElMessage.success('置顶权重已更新')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error((res && res.message) || '更新失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
const onNormalizeOrder = async () => {
|
||||
try {
|
||||
const res: any = await request.post('/superadmin/soul-articles/reorder-normalize')
|
||||
if (res && res.code === 200) {
|
||||
ElMessage.success('推荐权重已归一')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error((res && res.message) || '归一失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '归一失败')
|
||||
}
|
||||
}
|
||||
|
||||
const preview = (row: any) => {
|
||||
if (!row.url) {
|
||||
ElMessage.warning('该文章无 URL')
|
||||
return
|
||||
}
|
||||
window.open(row.url, '_blank')
|
||||
}
|
||||
|
||||
const fetchAiChatDisplay = async () => {
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/soul-articles/ai-chat-display')
|
||||
if (res && res.code === 200 && res.data) {
|
||||
const d = res.data
|
||||
aiChatDisplay.value = {
|
||||
enabled: !!d.enabled,
|
||||
maxShow: Math.max(1, Math.min(3, Number(d.maxShow) || 1)),
|
||||
sectionExpandedDefault: !!d.sectionExpandedDefault,
|
||||
profileRecoEnabled: !!d.profileRecoEnabled,
|
||||
profileSectionLabel: String(d.profileSectionLabel || '推荐阅读').slice(0, 32)
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const saveAiChatDisplay = async () => {
|
||||
savingAcDisplay.value = true
|
||||
try {
|
||||
const res: any = await request.post('/superadmin/soul-articles/ai-chat-display', {
|
||||
enabled: aiChatDisplay.value.enabled,
|
||||
maxShow: aiChatDisplay.value.maxShow,
|
||||
sectionExpandedDefault: aiChatDisplay.value.sectionExpandedDefault,
|
||||
profileRecoEnabled: aiChatDisplay.value.profileRecoEnabled,
|
||||
profileSectionLabel: aiChatDisplay.value.profileSectionLabel
|
||||
})
|
||||
if (res && res.code === 200) {
|
||||
ElMessage.success('展示设置已保存')
|
||||
await fetchAiChatDisplay()
|
||||
} else {
|
||||
ElMessage.error((res && res.message) || '保存失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
savingAcDisplay.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchHealth = async () => {
|
||||
try {
|
||||
const res: any = await request.get('/superadmin/ai/health')
|
||||
if (res && res.code === 200) {
|
||||
const d = res.data || {}
|
||||
const providers = Array.isArray(d.providers) ? d.providers : []
|
||||
const hasAlert = providers.some((p: any) => p.status === 'low-balance' || p.status === 'no-key') || !!d.lastAlert
|
||||
health.value = { loaded: true, providers, lastAlert: d.lastAlert || null, hasAlert }
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const onBalanceCheck = async () => {
|
||||
checking.value = true
|
||||
try {
|
||||
const res: any = await request.post('/superadmin/ai/balance-check')
|
||||
if (res && res.code === 200) {
|
||||
const d = res.data || {}
|
||||
ElMessage.success(`扫描完成:推送 ${d.alerted || 0} 条告警,跳过 ${d.skipped || 0} 条(当日去重)`)
|
||||
fetchHealth()
|
||||
} else {
|
||||
ElMessage.error((res && res.message) || '扫描失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '扫描失败')
|
||||
} finally {
|
||||
checking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
fetchHealth()
|
||||
fetchAiChatDisplay()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header-left h2 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 22px;
|
||||
color: #1F1B4D;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #6B6894;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.reco-card,
|
||||
.list-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.ai-chat-display-card {
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #E9D5FF;
|
||||
background: #FAF5FF;
|
||||
}
|
||||
|
||||
.ai-chat-display-form {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.form-hint-inline {
|
||||
margin-left: 12px;
|
||||
font-size: 12px;
|
||||
color: #6B6894;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.health-card {
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #E5E7EB;
|
||||
background: #F9FAFB;
|
||||
}
|
||||
|
||||
.health-card--warn {
|
||||
background: #FEF2F2;
|
||||
border-color: #FECACA;
|
||||
}
|
||||
|
||||
.health-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.health-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
color: #1F1B4D;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.dot-ok { background: #10B981; }
|
||||
.dot-warn { background: #EF4444; }
|
||||
|
||||
.health-providers {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.provider-chip {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: #EEF2FF;
|
||||
color: #4338CA;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chip-warn {
|
||||
background: #FEE2E2;
|
||||
color: #B91C1C;
|
||||
}
|
||||
|
||||
.health-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.health-alert {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #B91C1C;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.card-header-col {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.order-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.order-empty {
|
||||
color: #9CA3AF;
|
||||
}
|
||||
|
||||
.reco-count {
|
||||
color: #7c3aed;
|
||||
font-weight: 600;
|
||||
}
|
||||
.reco-head-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.reco-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.reco-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: #F5F3FF;
|
||||
border: 1px solid #EDE9FE;
|
||||
}
|
||||
|
||||
.reco-rank {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #a78bfa 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reco-cover {
|
||||
width: 120px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reco-cover--placeholder {
|
||||
background: linear-gradient(135deg, #a78bfa 0%, #7c3aed 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.reco-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.reco-title {
|
||||
font-size: 15px;
|
||||
color: #1F1B4D;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.reco-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #6B6894;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user