Files
kra-new/web/src/view/sw/components/ReportAnalytics.vue
T

132 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="sw-page">
<div class="kra-search-box">
<el-form :model="query" :inline="true" @submit.prevent="search">
<el-form-item label="所属平台"><el-select v-model="sources" multiple collapse-tags clearable placeholder="选择平台" style="width: 210px" @change="sourceChanged"><el-option v-for="(label, value) in platforms" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item label="游戏名称">
<el-select v-if="isChannel" v-model="games" multiple collapse-tags clearable filterable remote reserve-keyword :remote-method="loadGames" :loading="gameLoading" :disabled="!sources.length" placeholder="全部游戏" style="width: 190px"><el-option v-for="name in gameOptions" :key="name" :label="name" :value="name" /></el-select>
<el-select v-else v-model="selectedGame" clearable filterable :loading="gameLoading" :disabled="!sources.length" placeholder="全部游戏" style="width: 190px" @change="gameChanged"><el-option label="全部游戏" value="" /><el-option v-for="name in gameOptions" :key="name" :label="name" :value="name" /></el-select>
</el-form-item>
<el-form-item v-if="isChannel" label="渠道类型"><el-select v-model="query.promoteType" clearable placeholder="全部渠道类型" style="width: 150px"><el-option label="公会" :value="1" /><el-option label="个人" :value="0" /></el-select></el-form-item>
<el-form-item v-if="isChannel" label="渠道名称"><el-input v-model="query.promoteName" clearable placeholder="渠道名称或账号" /></el-form-item>
<el-form-item v-if="isChannel" label="是否有注册"><el-select v-model="query.hasRegister" clearable placeholder="全部" style="width:130px" @change="search"><el-option label="有注册" :value="true" /><el-option label="无注册" :value="false" /></el-select></el-form-item>
<el-form-item v-if="isChannel" label="是否有充值"><el-select v-model="query.hasPay" clearable placeholder="全部" style="width:130px" @change="search"><el-option label="有充值" :value="true" /><el-option label="无充值" :value="false" /></el-select></el-form-item>
<el-form-item v-if="isChannel" label="统计时间"><div class="report-date-range"><el-date-picker v-model="dates[0]" type="date" value-format="YYYY-MM-DD" placeholder="统计开始时间" :shortcuts="startShortcuts" clearable /><span>至</span><el-date-picker v-model="dates[1]" type="date" value-format="YYYY-MM-DD" placeholder="统计结束时间" :shortcuts="endShortcuts" clearable /></div></el-form-item>
<el-form-item v-else label="统计时间"><el-date-picker v-model="dates" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" range-separator="至" :clearable="false" :shortcuts="shortcuts" style="width: 300px" /></el-form-item>
<el-form-item><el-button type="primary" icon="search" native-type="submit">{{ isChannel ? '搜索' : '查询' }}</el-button></el-form-item>
</el-form>
</div>
<div class="kra-table-box">
<div v-if="!isChannel" class="report-heading"><strong>核心指标</strong><el-text type="info">统计周期:{{ period }}</el-text></div>
<el-alert v-if="error" class="mb-4" type="error" :closable="false" :title="error" />
<el-alert v-else-if="!available" class="mb-4" type="warning" :closable="false" title="统计所需业务汇总尚未接入。" />
<div class="sw-table-toolbar"><span class="sw-sort-label" role="status">{{ currentSort }}</span><el-button v-if="isChannel" :loading="exporting" :disabled="loading || !loadedQuery || !available" @click="download">导出</el-button></div>
<el-table scrollbar-always-on v-loading="loading" :data="rows" :row-key="row => `${row.sourceDb}:${isChannel ? row.promoteId : row.gameId}`" :default-sort="isChannel ? undefined : { prop: 'totalPay', order: 'descending' }" show-summary :summary-method="summaries" @sort-change="sortChanged">
<el-table-column prop="sourceDbLabel" label="所属平台" width="104"><template #default="{ row }"><platform-tag :code="row.sourceDb" :label="row.sourceDbLabel" /></template></el-table-column>
<el-table-column :prop="isChannel ? 'promoteName' : 'gameName'" :label="isChannel ? '渠道名称' : '游戏名称'" min-width="190" show-overflow-tooltip class-name="sw-col-identity"><template #default="{ row }"><el-button v-if="isChannel && row.promoteId > 0" link type="primary" @click="openChannel(row)">{{ row.promoteName }}</el-button><span v-else>{{ isChannel ? row.promoteName : row.gameName }}</span></template></el-table-column>
<el-table-column v-if="isChannel" prop="busierName" label="归属商务" min-width="140" show-overflow-tooltip />
<el-table-column v-if="isChannel" label="渠道类型" min-width="100"><template #default="{ row }"><el-tag v-if="row.promoteType === 0 || row.promoteType === 1" size="small" :type="row.promoteType === 1 ? 'warning' : 'info'" effect="plain">{{ row.promoteType === 1 ? '公会' : '个人' }}</el-tag><span v-else class="sw-empty-value">—</span></template></el-table-column>
<el-table-column v-else prop="gameId" label="游戏ID" min-width="100" sortable="custom" class-name="sw-col-meta" />
<el-table-column v-for="column in columns" :key="column.key" :prop="column.key" :label="column.label" :min-width="column.width || 110" sortable="custom" align="right" class-name="sw-col-number"><template #header><el-tooltip :content="metricTips[column.key]" placement="top"><span>{{ column.label }}</span></el-tooltip></template><template #default="{ row }"><metric-value :kind="column.type" :value="row[column.key]" /></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, 100]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" /></div>
</div>
<channel-profile-drawer v-if="isChannel" v-model="profileOpen" :channel="profileChannel" />
</div>
</template>
<script setup>
import { computed, h, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { listAnalytics, exportChannelAnalytics } from '@/api/analytics'
import { listLedgerGames } from '@/api/ledger'
import { channelCount } from '../channel-display'
import { createGameOptionsLoader } from '../game-options-loader'
import PlatformTag from './PlatformTag.vue'
import MetricValue from './MetricValue.vue'
import ChannelProfileDrawer from './ChannelProfileDrawer.vue'
import { metricTips, remoteSort, sortDescription } from '../table-display'
import { analysisGameSelection } from '../analysis-game-selection'
import { normalizeReportDates, reportDateShortcuts as shortcuts } from '../report-dates'
const fetchGameOptions = createGameOptionsLoader(listLedgerGames)
const props = defineProps({ kind: { type: String, required: true } })
const isChannel = computed(() => props.kind === 'channel')
const platforms = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
const dates = ref(normalizeReportDates())
const startShortcuts = shortcuts.map(item => ({ text: item.text, value: () => item.value()[0] }))
const endShortcuts = shortcuts.map(item => ({ text: item.text, value: () => item.value()[1] }))
const sources = ref(Object.keys(platforms)), games = ref([]), gameOptions = ref([]), gameLoading = ref(false)
const selectedGame = computed({ get: () => games.value[0] || '', set: value => { games.value = value ? [value] : [] } })
const query = reactive({ pageNum: 1, pageSize: 10, promoteType: undefined, promoteName: '', hasRegister: undefined, hasPay: undefined, sortField: isChannel.value ? '' : 'totalPay', sortOrder: 'desc' })
const rows = ref([]), total = ref(0), summary = ref(null), result = ref(null), loading = ref(false), available = ref(true), error = ref('')
const loadedQuery = ref(null), exporting = ref(false)
let version = 0, gameVersion = 0
const period = computed(() => result.value?.beginTime && result.value?.endTime ? `${result.value.beginTime} 至 ${result.value.endTime}` : dates.value.join(' 至 '))
const fields = { newRegisterCount: { key: 'newRegisterCount', label: '新增注册', type: 'count' }, activeCount: { key: 'activeCount', label: '活跃玩家', type: 'count' }, payUserCount: { key: 'payUserCount', label: '付费玩家', type: 'count' }, newPayUserCount: { key: 'newPayUserCount', label: '新增付费', type: 'count' }, totalPay: { key: 'totalPay', label: '充值金额', type: 'money', width: 145 }, payRate: { key: 'payRate', label: '付费率', type: 'percent' }, arpu: { key: 'arpu', label: 'ARPU', type: 'money' }, arppu: { key: 'arppu', label: 'ARPPU', type: 'money' } }
const columns = computed(() => (isChannel.value ? ['newRegisterCount', 'activeCount', 'totalPay', 'payUserCount', 'newPayUserCount', 'payRate'] : ['newRegisterCount', 'activeCount', 'payUserCount', 'totalPay', 'payRate', 'arpu', 'arppu', 'newPayUserCount']).map(key => fields[key]))
const summaries = ({ columns: tableColumns }) => tableColumns.map((column, index) => {
if (index === 0) return isChannel.value ? '全部' : '全部游戏'
if (index === 1) return isChannel.value ? h('span', { class: 'report-summary-title', title: '合计覆盖当前筛选结果的全部页;有效端按所选区间内达标的渠道数统计。' }, [h('strong', '合计'), h('span', '有效端 '), h('strong', { class: 'report-effective-count' }, result.value?.effectiveCount == null ? '—' : channelCount(result.value.effectiveCount))]) : ''
const field = fields[column.property]
return field ? summary.value ? h(MetricValue, { kind: field.type, value: summary.value[column.property] }) : '—' : isChannel.value ? '—' : ''
})
const profileOpen = ref(false), profileChannel = ref(null)
const openChannel = row => { profileChannel.value = { id: row.promoteId, sourceDb: row.sourceDb, account: row.promoteName }; profileOpen.value = true }
const clear = () => { rows.value = []; total.value = 0; summary.value = null; result.value = null; loadedQuery.value = null }
const load = async () => {
dates.value = normalizeReportDates(dates.value)
if (dates.value[0] > dates.value[1]) { ElMessage.warning('统计开始时间不能晚于结束时间'); return }
const current = ++version
if (!sources.value.length) { clear(); loading.value = false; available.value = true; return }
loading.value = true; error.value = ''
try {
const requested = { ...query, promoteName: query.promoteName.trim(), sourceDb: sources.value.join(','), gameName: games.value.join(','), beginTime: dates.value[0] || '', endTime: dates.value[1] || '' }
const res = await listAnalytics(props.kind, requested)
if (current !== version) return
if (res.code === 0) { result.value = res.data; rows.value = res.data.rows; total.value = res.data.total; summary.value = res.data.summary; available.value = res.data.available !== false; loadedQuery.value = requested }
else { clear(); error.value = res.msg || '统计查询失败' }
} catch { if (current === version) { clear(); error.value = '统计查询失败,请重试' } } finally { if (current === version) loading.value = false }
}
const download = async () => {
if (exporting.value || loading.value || !loadedQuery.value) return
exporting.value = true
try {
const response = await exportChannelAnalytics(loadedQuery.value), blob = response instanceof Blob ? response : response.data
if (!(blob instanceof Blob)) return
if (blob.type.includes('json')) { const data = JSON.parse(await blob.text()); ElMessage.error(data.msg || '导出失败'); return }
const url = URL.createObjectURL(blob), link = document.createElement('a')
link.href = url; link.download = '渠道数据统计.xlsx'; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
} catch { ElMessage.error('导出失败,请重试') } finally { exporting.value = false }
}
const search = () => { query.pageNum = 1; if (!sources.value.length) ElMessage.warning('请选择平台'); return load() }
const sortChanged = event => { Object.assign(query, remoteSort(event, isChannel.value ? '' : 'totalPay')); return search() }
const currentSort = computed(() => query.sortField ? sortDescription(query, { ...Object.fromEntries(Object.values(fields).map(f => [f.key, f.label])), gameId: '游戏ID' }) : '排序:默认顺序')
const gameChanged = () => { analysisGameSelection.value = selectedGame.value; return search() }
const sourceChanged = async () => {
++version; ++gameVersion; games.value = []; gameOptions.value = []; gameLoading.value = false; loading.value = false; error.value = ''; clear()
if (!isChannel.value) { analysisGameSelection.value = ''; if (sources.value.length) await loadGames(); await search() }
}
const loadGames = async (keyword = '') => {
const current = ++gameVersion; if (!sources.value.length) return
gameLoading.value = true
try {
const res = await fetchGameOptions({ sourceDb: sources.value.join(','), keyword, limit: isChannel.value ? 50 : 10000 })
if (current !== gameVersion) return
if (res.code === 0) gameOptions.value = [...new Set(res.data.map(row => row.gameName).filter(Boolean))]
else { gameOptions.value = []; ElMessage.error(res.msg || '游戏选项读取失败') }
} catch { if (current === gameVersion) { gameOptions.value = []; ElMessage.error('游戏选项读取失败,请重试') } } finally { if (current === gameVersion) gameLoading.value = false }
}
onMounted(async () => {
if (!isChannel.value) { await loadGames(); if (gameOptions.value.includes(analysisGameSelection.value)) selectedGame.value = analysisGameSelection.value }
await load()
})
</script>
<style scoped>
.report-date-range { display: flex; align-items: center; gap: 8px; }
.report-date-range :deep(.el-date-editor) { width: 165px; }
:deep(.report-summary-title) { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
:deep(.report-effective-count) { color: var(--el-color-success); }
.report-heading { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 18px; flex-wrap: wrap; }
</style>