132 lines
14 KiB
Vue
132 lines
14 KiB
Vue
<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>
|