feat: 小程序与管理端迭代(神仙AI、了解自己CRM、AI测试入口、报表与分润等)

Made-with: Cursor
This commit is contained in:
卡若
2026-04-17 19:46:48 +08:00
parent cf835ea585
commit 7108f28280
239 changed files with 21061 additions and 2175 deletions

View File

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

View File

@@ -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;
}
}

View 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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View 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>

View File

@@ -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>

View File

@@ -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;
}
}
}

View 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>

View 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>