feat: 迁移渠道画像累计指标和玩家付费分层
This commit is contained in:
@@ -27,11 +27,13 @@
|
||||
<el-table-column label="玩家数量" min-width="130"><template #default="{ row }">{{ channelCount(row.playerCount) }}</template></el-table-column>
|
||||
<el-table-column label="累计充值" min-width="160"><template #default="{ row }">{{ channelMoney(row.totalPayAmount) }}</template></el-table-column>
|
||||
<el-table-column label="状态" min-width="110"><template #default="{ row }"><el-tag :type="{ 0: 'info', '-1': 'danger', '-2': 'warning' }[row.lockStatus] || 'info'" effect="plain">{{ statuses[row.lockStatus] ?? row.lockStatus ?? '—' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openProfile(row)">画像</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<div class="kra-pagination"><el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" /></div>
|
||||
</div>
|
||||
<add-channel-drawer v-model="adding" @success="search" />
|
||||
<channel-game-drawer v-model="gamesOpen" :channel="selected" />
|
||||
<channel-profile-drawer v-model="profileOpen" :channel="profileChannel" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -44,6 +46,7 @@ import { listChannels } from '@/api/channel'
|
||||
import { channelCount, channelMoney } from './channel-display'
|
||||
import AddChannelDrawer from './components/AddChannelDrawer.vue'
|
||||
import ChannelGameDrawer from './components/ChannelGameDrawer.vue'
|
||||
import ChannelProfileDrawer from './components/ChannelProfileDrawer.vue'
|
||||
|
||||
defineOptions({ name: 'SwChannelList' })
|
||||
const user = useUserStore()
|
||||
@@ -61,6 +64,9 @@ const loading = ref(false)
|
||||
const adding = ref(false)
|
||||
const gamesOpen = ref(false)
|
||||
const selected = ref(null)
|
||||
const profileOpen = ref(false)
|
||||
const profileChannel = ref(null)
|
||||
const openProfile = row => { profileChannel.value = row; profileOpen.value = true }
|
||||
let requestVersion = 0
|
||||
const load = async () => {
|
||||
const version = ++requestVersion
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<el-drawer v-model="visible" append-to-body destroy-on-close :title="title" size="min(900px, 100vw)" @closed="reset">
|
||||
<div v-loading="loading">
|
||||
<el-alert v-if="error" :title="error" type="warning" :closable="false" />
|
||||
<template v-if="profile">
|
||||
<h3 class="profile-heading">基本信息</h3>
|
||||
<dl class="profile-info">
|
||||
<div><dt>渠道名称</dt><dd>{{ basic.nickname || '—' }}</dd></div>
|
||||
<div><dt>渠道ID</dt><dd>{{ basic.promoteId ?? '—' }}</dd></div>
|
||||
<div><dt>归属商务</dt><dd>{{ basic.busierDisplayName || '—' }}</dd></div>
|
||||
<div><dt>渠道类型</dt><dd>{{ basic.registerTypeLabel || '—' }}</dd></div>
|
||||
<div><dt>渠道层级</dt><dd>{{ basic.promoteLevelLabel || '—' }}</dd></div>
|
||||
<div><dt>上级渠道</dt><dd>{{ basic.parentPromoteName || '—' }}</dd></div>
|
||||
<div><dt>状态</dt><dd><el-tag :type="basic.statusLabel === '正常' ? 'success' : 'info'" effect="plain">{{ basic.statusLabel || '—' }}</el-tag></dd></div>
|
||||
<div><dt>推广游戏</dt><dd><el-button v-if="basic.gameCount != null" type="primary" link @click="gamesOpen = true">{{ channelCount(basic.gameCount) }} 款</el-button><span v-else>未接入</span></dd></div>
|
||||
<div><dt>注册时间</dt><dd>{{ basic.createTime || '—' }}</dd></div>
|
||||
<div><dt>最后登录时间</dt><dd>{{ basic.lastLoginTime || '—' }}</dd></div>
|
||||
</dl>
|
||||
<div class="profile-section-title"><h3 class="profile-heading">核心指标</h3><el-radio-group v-model="period" size="small" :disabled="loading" @change="loadProfile"><el-radio-button v-for="item in periods" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></div>
|
||||
<el-alert v-if="profile.available === false" type="warning" :closable="false" :title="profile.unavailableReason || '渠道画像数据尚未接入'" />
|
||||
<template v-else-if="profile.metrics">
|
||||
<div class="profile-cards"><el-card v-for="card in cards" :key="card.key" shadow="never"><div class="metric-label">{{ card.label }}</div><strong class="metric-value">{{ card.value }}</strong><div v-if="card.comparison" class="metric-compare" :class="card.tone">{{ card.comparison }}</div></el-card></div>
|
||||
<h3 class="profile-heading">数据趋势</h3>
|
||||
<div class="profile-two-columns">
|
||||
<el-card shadow="never"><template #header>注册量趋势</template><business-trend :series="profile.registerTrend" title="渠道注册量趋势" /></el-card>
|
||||
<el-card shadow="never"><template #header>充值金额趋势</template><business-trend :series="profile.payTrend" title="渠道充值金额趋势" money color="var(--el-color-success)" /></el-card>
|
||||
</div>
|
||||
<h3 class="profile-heading">玩家付费分层</h3>
|
||||
<div class="pay-tier-list">
|
||||
<div v-for="tier in tiers" :key="tier.tier" class="pay-tier-item">
|
||||
<div><strong>{{ tier.label }}</strong><span class="tier-range">{{ tier.range }}</span></div>
|
||||
<el-progress :percentage="tier.percent" :show-text="false" :stroke-width="10" :color="tier.color" />
|
||||
<div class="tier-value">{{ channelCount(tier.count) }}人 / {{ tier.ratio }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<h3 class="profile-heading">关联预警</h3>
|
||||
<el-alert v-if="alertError" :title="alertError" type="warning" :closable="false" />
|
||||
<template v-else>
|
||||
<el-table v-loading="alertsLoading" :data="alerts" size="small">
|
||||
<el-table-column label="时间" width="120"><template #default="{ row }">{{ row.createdAt?.slice(5, 16) || '—' }}</template></el-table-column>
|
||||
<el-table-column label="预警类型" min-width="130"><template #default="{ row }">{{ alertType(row) }}</template></el-table-column>
|
||||
<el-table-column prop="alertContent" label="详情" min-width="240" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="row.status === 5 ? 'success' : 'warning'">{{ row.statusLabel || alertStatuses[row.status] }}</el-tag></template></el-table-column>
|
||||
</el-table>
|
||||
<div v-if="alertTotal" class="kra-pagination"><el-pagination v-model:current-page="alertPage" :page-size="10" :total="alertTotal" layout="total, prev, pager, next" @current-change="loadAlerts" /></div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer><el-button @click="visible = false">关闭</el-button></template>
|
||||
</el-drawer>
|
||||
<channel-game-drawer v-model="gamesOpen" :channel="gameChannel" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { channelPortrait } from '@/api/channel'
|
||||
import { listAlertRecords } from '@/api/alert'
|
||||
import { channelCount, channelMoney } from '../channel-display'
|
||||
import { alertStatuses } from '../alert-display'
|
||||
import BusinessTrend from './BusinessTrend.vue'
|
||||
import ChannelGameDrawer from './ChannelGameDrawer.vue'
|
||||
const props = defineProps({ modelValue: Boolean, channel: { type: Object, default: null } })
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const visible = computed({ get: () => props.modelValue, set: value => emit('update:modelValue', value) })
|
||||
const periods = [{ label: '今日', value: 'today' }, { label: '昨日', value: 'yesterday' }, { label: '近7天', value: 'last7' }, { label: '近30天', value: 'last30' }, { label: '累计', value: 'total' }]
|
||||
const profile = ref(null), loading = ref(false), error = ref(''), period = ref('today'), gamesOpen = ref(false)
|
||||
const alerts = ref([]), alertsLoading = ref(false), alertError = ref(''), alertPage = ref(1), alertTotal = ref(0)
|
||||
let profileVersion = 0, alertVersion = 0
|
||||
const basic = computed(() => profile.value?.basic || {})
|
||||
const title = computed(() => `渠道画像 — ${props.channel?.nickname || basic.value.nickname || props.channel?.account || ''}`)
|
||||
const gameChannel = computed(() => props.channel ? { ...props.channel, nickname: basic.value.nickname || props.channel.nickname, account: basic.value.account || props.channel.account } : null)
|
||||
const number = value => value == null ? '—' : channelCount(String(value).replace(/\.0+$/, ''))
|
||||
const cards = computed(() => [
|
||||
{ key: 'register', label: '注册玩家', format: number }, { key: 'payAmount', label: '充值金额', format: channelMoney },
|
||||
{ key: 'payRate', label: '付费率', format: value => value == null ? '—' : `${value}%` }, { key: 'active', label: '活跃玩家', format: number }
|
||||
].map(card => {
|
||||
const metric = profile.value?.metrics?.[card.key]
|
||||
const change = metric?.changeRate ?? metric?.changeDiff
|
||||
const n = change == null ? null : Number(change)
|
||||
const comparison = period.value === 'total' ? '' : change == null ? `${metric?.compareLabel || ''} ${metric?.compareValue == null ? '—' : card.format(metric.compareValue)}` : `${metric?.compareLabel || ''} ${n > 0 ? '↑ ' : n < 0 ? '↓ ' : ''}${Math.abs(n).toFixed(1)}${metric?.changeRate != null ? '%' : ' 个百分点'}`
|
||||
return { ...card, value: card.format(metric?.value), comparison, tone: n == null || n === 0 ? '' : n > 0 ? 'positive' : 'negative' }
|
||||
}))
|
||||
const tierMeta = {
|
||||
whale: { label: '大R玩家', range: '≥ ¥10,000', color: 'var(--el-color-success)' },
|
||||
mid: { label: '中R玩家', range: '¥1,000 ≤ 金额 < ¥10,000', color: 'var(--el-color-warning)' },
|
||||
small: { label: '小R玩家', range: '¥0 < 金额 < ¥1,000', color: 'var(--el-color-primary)' },
|
||||
free: { label: '免费玩家', range: '≤ ¥0', color: 'var(--el-text-color-placeholder)' }
|
||||
}
|
||||
const tiers = computed(() => (profile.value?.payTiers || []).map(tier => ({ ...tier, ...tierMeta[tier.tier], percent: Math.max(0, Math.min(100, Number(tier.ratio) || 0)) })))
|
||||
const alertType = row => ({ register_anomaly: '注册显著暴跌', recharge_anomaly: '充值额暴涨', login_anomaly: '渠道长期未登录' })[row.ruleType] || row.ruleName || row.ruleType || '—'
|
||||
const reset = () => { ++profileVersion; ++alertVersion; profile.value = null; loading.value = false; error.value = ''; alerts.value = []; alertError.value = ''; alertsLoading.value = false; alertPage.value = 1; alertTotal.value = 0; gamesOpen.value = false }
|
||||
const loadProfile = async () => {
|
||||
const version = ++profileVersion
|
||||
if (!props.channel) return
|
||||
loading.value = true; error.value = ''
|
||||
try {
|
||||
const res = await channelPortrait({ sourceDb: props.channel.sourceDb, promoteId: props.channel.id, period: period.value })
|
||||
if (version !== profileVersion) return
|
||||
if (res.code === 0) profile.value = res.data
|
||||
else { profile.value = null; error.value = res.msg || '渠道画像读取失败' }
|
||||
} catch { if (version === profileVersion) { profile.value = null; error.value = '渠道画像读取失败,请重试' } } finally { if (version === profileVersion) loading.value = false }
|
||||
}
|
||||
const loadAlerts = async () => {
|
||||
const version = ++alertVersion
|
||||
if (!props.channel) return
|
||||
alertsLoading.value = true; alertError.value = ''
|
||||
try {
|
||||
// 渠道关联预警使用渠道 ID;不能把它误传成 SW 商务用户 ID。
|
||||
const res = await listAlertRecords({ platformCodes: props.channel.sourceDb, targetPromoteId: props.channel.id, pageNum: alertPage.value, pageSize: 10 })
|
||||
if (version !== alertVersion) return
|
||||
if (res.code === 0 && res.data.available !== false) { alerts.value = res.data.pageData.rows; alertTotal.value = res.data.pageData.total }
|
||||
else { alerts.value = []; alertTotal.value = 0; alertError.value = res.code === 0 ? '渠道数据尚未接入,关联预警暂不可用。' : res.msg || '关联预警读取失败' }
|
||||
} catch { if (version === alertVersion) { alerts.value = []; alertError.value = '关联预警读取失败,请重试' } } finally { if (version === alertVersion) alertsLoading.value = false }
|
||||
}
|
||||
watch(() => [props.modelValue, props.channel], ([open]) => { if (open && props.channel) { reset(); period.value = 'today'; void loadProfile(); void loadAlerts() } })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-heading { margin: 20px 0 14px; font-size: 16px; font-weight: 600; }
|
||||
.profile-info { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px; margin: 0; }
|
||||
.profile-info dt, .metric-label, .metric-compare, .tier-range { color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.profile-info dd { margin: 6px 0 0; overflow-wrap: anywhere; }
|
||||
.profile-section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-top: 10px; }
|
||||
.profile-cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
|
||||
.metric-value { display: block; font-size: 23px; margin: 12px 0; overflow-wrap: anywhere; }
|
||||
.metric-compare.positive { color: var(--el-color-success); }
|
||||
.metric-compare.negative { color: var(--el-color-danger); }
|
||||
.profile-two-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
.pay-tier-list { padding: 8px 16px; border: 1px solid var(--el-border-color-light); border-radius: 6px; }
|
||||
.pay-tier-item { display: grid; grid-template-columns: 200px minmax(100px, 1fr) 140px; gap: 18px; align-items: center; margin: 16px 0; }
|
||||
.tier-range { display: block; margin-top: 5px; }
|
||||
.tier-value { text-align: right; font-size: 13px; }
|
||||
@media (max-width: 700px) { .profile-info, .profile-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } .profile-two-columns { grid-template-columns: 1fr; } .pay-tier-item { grid-template-columns: 1fr 1fr; gap: 8px; } .tier-value { grid-column: 2; } }
|
||||
</style>
|
||||
Reference in New Issue
Block a user