feat(admin): add country dashboard game metrics table

This commit is contained in:
zhx 2026-05-13 13:51:39 +08:00
parent 2e816ed681
commit 6eb24bdd99
2 changed files with 299 additions and 6 deletions

View File

@ -65,6 +65,25 @@ export interface CountryDashboardRechargeDetail {
userNickname: string; userNickname: string;
} }
export interface CountryDashboardGameMetric {
consumeAmount: number | string;
countryCode: string;
countryName: string;
gameId: string;
gameName: string;
gameProvider: string;
orderCount: number;
payoutAmount: number | string;
payoutRate: number | string;
periodKey: string;
periodName: string;
profitAmount: number | string;
profitRate: number | string;
refreshedAt: string;
statTimezone: string;
userCount: number;
}
export async function countryDashboard(params: Record<string, any> = {}) { export async function countryDashboard(params: Record<string, any> = {}) {
return requestClient.get<CountryDashboardResult>('/datav/country-dashboard', { return requestClient.get<CountryDashboardResult>('/datav/country-dashboard', {
params, params,
@ -84,6 +103,19 @@ export async function countryDashboardRechargeDetails(
}); });
} }
export async function countryDashboardGameMetrics(
params: Record<string, any> = {},
) {
return requestClient.get<{
current: number;
records: CountryDashboardGameMetric[];
size: number;
total: number;
}>('/datav/country-dashboard/game-metrics', {
params,
});
}
export async function latestActiveUserCountryCode(params: Record<string, any> = {}) { export async function latestActiveUserCountryCode(params: Record<string, any> = {}) {
return requestClient.get<Array<Record<string, any>>>( return requestClient.get<Array<Record<string, any>>>(
'/datav/active/user-country-code', '/datav/active/user-country-code',

View File

@ -2,6 +2,7 @@
import type { EchartsUIType } from '@vben/plugins/echarts'; import type { EchartsUIType } from '@vben/plugins/echarts';
import type { import type {
CountryDashboardGameMetric,
CountryDashboardMetric, CountryDashboardMetric,
CountryDashboardRechargeDetail, CountryDashboardRechargeDetail,
CountryDashboardResult, CountryDashboardResult,
@ -24,13 +25,14 @@ import {
Button, Button,
DateRangePicker, DateRangePicker,
Empty, Empty,
Input,
message, message,
Modal, Modal,
Select, Select,
Space, Space,
Spin, Spin,
TabPane,
Table, Table,
TabPane,
Tabs, Tabs,
Tooltip, Tooltip,
} from 'antdv-next'; } from 'antdv-next';
@ -40,6 +42,7 @@ import utc from 'dayjs/plugin/utc';
import { import {
countryDashboard, countryDashboard,
countryDashboardGameMetrics,
countryDashboardRechargeDetails, countryDashboardRechargeDetails,
} from '#/api/legacy/datav'; } from '#/api/legacy/datav';
import SysOriginSelect from '#/components/sys-origin-select.vue'; import SysOriginSelect from '#/components/sys-origin-select.vue';
@ -62,7 +65,7 @@ const fullscreen = ref(false);
const visualMode = ref(false); const visualMode = ref(false);
const loading = ref(false); const loading = ref(false);
const dashboard = ref<CountryDashboardResult | null>(null); const dashboard = ref<CountryDashboardResult | null>(null);
const activeTableTab = ref<'overview' | 'recharge'>('overview'); const activeTableTab = ref<'game' | 'overview' | 'recharge'>('overview');
const arpuDashboards = ref<{ const arpuDashboards = ref<{
day: CountryDashboardResult | null; day: CountryDashboardResult | null;
threeDay: CountryDashboardResult | null; threeDay: CountryDashboardResult | null;
@ -86,6 +89,27 @@ const rechargeDetailPagination = reactive({
pageSize: 100, pageSize: 100,
total: 0, total: 0,
}); });
const gameMetricLoading = ref(false);
const gameMetricRecords = ref<CountryDashboardGameMetric[]>([]);
const gameMetricPagination = reactive({
current: 1,
pageSize: 50,
total: 0,
});
const gameMetricSorter = reactive<{
field?: string;
order?: 'ascend' | 'descend';
}>({
field: 'consumeAmount',
order: 'descend',
});
const gameQuery = reactive<{
gameKeyword: string;
gameProvider?: string;
}>({
gameKeyword: '',
gameProvider: undefined,
});
const query = reactive({ const query = reactive({
periodType: 'DAY', periodType: 'DAY',
@ -125,6 +149,18 @@ const statTimezoneOptions = [
{ label: '北京', value: 'Asia/Shanghai' }, { label: '北京', value: 'Asia/Shanghai' },
]; ];
const gameProviderOptions = [
{ label: '全部游戏厂商', value: '' },
{ label: '站内游戏', value: 'INTERNAL' },
{ label: '百顺', value: 'BAISHUN' },
{ label: 'Game Open', value: 'GAME_OPEN' },
{ label: '灵犀', value: 'LINGXIAN' },
{ label: 'Yomi', value: 'YOMI' },
{ label: 'HKYS', value: 'HKYS' },
{ label: 'Hot', value: 'HOT' },
{ label: '未识别', value: 'UNKNOWN' },
];
const metricColumns = new Set([ const metricColumns = new Set([
'countryNewUser', 'countryNewUser',
'd1RetentionRate', 'd1RetentionRate',
@ -132,7 +168,6 @@ const metricColumns = new Set([
'd30RetentionRate', 'd30RetentionRate',
'dailyActiveUser', 'dailyActiveUser',
'dealerRecharge', 'dealerRecharge',
'googleRecharge',
'gamePayout', 'gamePayout',
'gamePayoutRate', 'gamePayoutRate',
'gameProfit', 'gameProfit',
@ -140,6 +175,7 @@ const metricColumns = new Set([
'gameTotalFlow', 'gameTotalFlow',
'gameUser', 'gameUser',
'giftConsume', 'giftConsume',
'googleRecharge',
'luckyGiftPayout', 'luckyGiftPayout',
'luckyGiftPayoutRate', 'luckyGiftPayoutRate',
'luckyGiftProfit', 'luckyGiftProfit',
@ -288,6 +324,79 @@ const rechargeColumns: any[] = [
sorter: compareTableColumn(String(column.dataIndex)), sorter: compareTableColumn(String(column.dataIndex)),
})); }));
const gameMetricNumberColumns = new Set([
'consumeAmount',
'orderCount',
'payoutAmount',
'profitAmount',
'userCount',
]);
const gameMetricIntegerColumns = new Set(['orderCount', 'userCount']);
const gameMetricPercentColumns = new Set(['payoutRate', 'profitRate']);
const gameMetricColumns: any[] = [
{ dataIndex: 'periodName', fixed: 'left' as const, key: 'periodName', title: '日期/周期', width: 120 },
{ dataIndex: 'countryName', key: 'countryName', title: '国家', width: 150 },
{ dataIndex: 'countryCode', key: 'countryCode', title: 'Code', width: 86 },
{ dataIndex: 'gameProvider', key: 'gameProvider', title: '游戏厂商', width: 120 },
{ dataIndex: 'gameId', key: 'gameId', title: '游戏 ID', width: 130 },
{ dataIndex: 'gameName', key: 'gameName', title: '游戏名称', width: 180 },
{
align: 'right' as const,
dataIndex: 'consumeAmount',
key: 'consumeAmount',
sortDirections: ['descend', 'ascend'],
sorter: true,
title: '消耗',
width: 130,
},
{
align: 'right' as const,
dataIndex: 'payoutAmount',
key: 'payoutAmount',
sortDirections: ['descend', 'ascend'],
sorter: true,
title: '返奖',
width: 130,
},
{
align: 'right' as const,
dataIndex: 'profitAmount',
key: 'profitAmount',
sortDirections: ['descend', 'ascend'],
sorter: true,
title: '收益',
width: 130,
},
{ align: 'right' as const, dataIndex: 'payoutRate', key: 'payoutRate', title: '返奖率', width: 110 },
{ align: 'right' as const, dataIndex: 'profitRate', key: 'profitRate', title: '收益率', width: 110 },
{
align: 'right' as const,
dataIndex: 'userCount',
key: 'userCount',
sortDirections: ['descend', 'ascend'],
sorter: true,
title: '游戏用户数',
width: 130,
},
{
align: 'right' as const,
dataIndex: 'orderCount',
key: 'orderCount',
sortDirections: ['descend', 'ascend'],
sorter: true,
title: '流水笔数',
width: 120,
},
{ dataIndex: 'refreshedAt', key: 'refreshedAt', title: '更新时间', width: 170 },
];
const gameTableScrollX = gameMetricColumns.reduce(
(totalWidth, item) => totalWidth + Number(item.width || 0),
0,
);
const rechargeDetailColumns: any[] = [ const rechargeDetailColumns: any[] = [
{ dataIndex: 'createTime', key: 'createTime', title: '时间', width: 170 }, { dataIndex: 'createTime', key: 'createTime', title: '时间', width: 170 },
{ dataIndex: 'sourceType', key: 'sourceType', title: '来源', width: 120 }, { dataIndex: 'sourceType', key: 'sourceType', title: '来源', width: 120 },
@ -478,6 +587,9 @@ async function loadDashboard() {
threeDay: threeDayArpuDashboard, threeDay: threeDayArpuDashboard,
week: weekArpuDashboard, week: weekArpuDashboard,
}; };
if (activeTableTab.value === 'game') {
await loadGameMetrics();
}
} catch (error) { } catch (error) {
dashboard.value = null; dashboard.value = null;
arpuDashboards.value = { arpuDashboards.value = {
@ -493,6 +605,66 @@ async function loadDashboard() {
renderVisualCharts(); renderVisualCharts();
} }
function buildGameMetricParams() {
return {
...buildParams(),
countryCodes: selectedCountryCodes.value.join(',') || undefined,
cursor: gameMetricPagination.current,
gameKeyword: gameQuery.gameKeyword.trim() || undefined,
gameProvider: gameQuery.gameProvider || undefined,
limit: gameMetricPagination.pageSize,
sortField: gameMetricSorter.field || undefined,
sortOrder: gameMetricSorter.order || undefined,
};
}
async function loadGameMetrics() {
if (!canQuery.value) {
return;
}
gameMetricLoading.value = true;
try {
const result = await countryDashboardGameMetrics(buildGameMetricParams());
gameMetricRecords.value = result.records || [];
gameMetricPagination.total = Number(result.total || 0);
gameMetricPagination.current = Number(result.current || gameMetricPagination.current || 1);
const resultPageSize = Number(result.size);
gameMetricPagination.pageSize =
Number.isFinite(resultPageSize) && resultPageSize > 0
? resultPageSize
: gameMetricPagination.pageSize;
} catch {
gameMetricRecords.value = [];
gameMetricPagination.total = 0;
message.error('游戏数据加载失败');
} finally {
gameMetricLoading.value = false;
}
}
async function handleGameMetricTableChange(
pagination: Record<string, any>,
_filters: Record<string, any>,
sorter: Record<string, any> | Record<string, any>[],
) {
const activeSorter = Array.isArray(sorter) ? sorter[0] : sorter;
gameMetricPagination.current = Number(pagination.current || 1);
gameMetricPagination.pageSize = Number(pagination.pageSize || gameMetricPagination.pageSize || 50);
gameMetricSorter.field = activeSorter?.field ? String(activeSorter.field) : undefined;
gameMetricSorter.order =
activeSorter?.order === 'ascend' || activeSorter?.order === 'descend'
? activeSorter.order
: undefined;
await loadGameMetrics();
}
async function handleGameMetricFilterChange() {
gameMetricPagination.current = 1;
if (activeTableTab.value === 'game') {
await loadGameMetrics();
}
}
async function loadRechargeDetails() { async function loadRechargeDetails() {
rechargeDetailLoading.value = true; rechargeDetailLoading.value = true;
try { try {
@ -524,6 +696,16 @@ function rechargeDetailRowKey(record: Record<string, any>) {
return `${record.sourceType}-${record.recordId}`; return `${record.sourceType}-${record.recordId}`;
} }
function gameMetricRowKey(record: Record<string, any>) {
return [
record.periodKey,
record.statTimezone,
record.countryCode,
record.gameProvider,
record.gameId,
].join('-');
}
function rechargeSourceName(sourceType: string) { function rechargeSourceName(sourceType: string) {
return rechargeSourceNameMap[sourceType] || sourceType || '-'; return rechargeSourceNameMap[sourceType] || sourceType || '-';
} }
@ -541,6 +723,10 @@ function showRechargeDetailTotal(total: number) {
return `${total}`; return `${total}`;
} }
function showGameMetricTotal(total: number) {
return `${total}`;
}
function toAmount(value?: null | number | string) { function toAmount(value?: null | number | string) {
const number = Number(value || 0); const number = Number(value || 0);
return Number.isFinite(number) ? number : 0; return Number.isFinite(number) ? number : 0;
@ -730,6 +916,19 @@ function tableCellText(record: Record<string, any>, key: string) {
return formatAmount(record[key] as number | string); return formatAmount(record[key] as number | string);
} }
function gameMetricCellText(record: Record<string, any>, key: string) {
if (gameMetricIntegerColumns.has(key)) {
return formatInteger(record[key] as number | string);
}
if (gameMetricPercentColumns.has(key)) {
return formatPercent(record[key] as number | string);
}
if (gameMetricNumberColumns.has(key)) {
return formatAmount(record[key] as number | string);
}
return record[key] || '-';
}
function compareTableColumn(key: string) { function compareTableColumn(key: string) {
return (current: Record<string, any>, next: Record<string, any>) => { return (current: Record<string, any>, next: Record<string, any>) => {
if (metricColumns.has(key)) { if (metricColumns.has(key)) {
@ -921,6 +1120,20 @@ watch(countryOptions, (options) => {
); );
}); });
watch(activeTableTab, (value) => {
if (value === 'game') {
gameMetricPagination.current = 1;
void loadGameMetrics();
}
});
watch(selectedCountryCodes, () => {
if (activeTableTab.value === 'game') {
gameMetricPagination.current = 1;
void loadGameMetrics();
}
});
onMounted(() => { onMounted(() => {
document.addEventListener('fullscreenchange', syncFullscreenState); document.addEventListener('fullscreenchange', syncFullscreenState);
loadDashboard(); loadDashboard();
@ -1004,7 +1217,24 @@ onBeforeUnmount(() => {
show-search show-search
style="width: 300px" style="width: 300px"
/> />
<Button :loading="loading" type="primary" @click="loadDashboard"> <Select
v-if="activeTableTab === 'game'"
v-model:value="gameQuery.gameProvider"
:options="gameProviderOptions"
allow-clear
placeholder="游戏厂商"
style="width: 150px"
@change="handleGameMetricFilterChange"
/>
<Input
v-if="activeTableTab === 'game'"
v-model:value="gameQuery.gameKeyword"
allow-clear
placeholder="游戏 ID / 名称"
style="width: 220px"
@press-enter="handleGameMetricFilterChange"
/>
<Button :loading="loading || gameMetricLoading" type="primary" @click="loadDashboard">
查询 查询
</Button> </Button>
</Space> </Space>
@ -1105,12 +1335,43 @@ onBeforeUnmount(() => {
<Empty v-else /> <Empty v-else />
</template> </template>
<template v-else> <template v-else>
<Tabs v-model:activeKey="activeTableTab" class="dashboard-tabs" size="small"> <Tabs v-model:active-key="activeTableTab" class="dashboard-tabs" size="small">
<TabPane key="overview" tab="指标总览" /> <TabPane key="overview" tab="指标总览" />
<TabPane key="recharge" tab="充值相关数据" /> <TabPane key="recharge" tab="充值相关数据" />
<TabPane key="game" tab="游戏数据" />
</Tabs> </Tabs>
<Table <Table
v-if="records.length > 0" v-if="activeTableTab === 'game'"
:columns="gameMetricColumns"
:data-source="gameMetricRecords"
:loading="gameMetricLoading"
:pagination="{
current: gameMetricPagination.current,
pageSize: gameMetricPagination.pageSize,
showSizeChanger: true,
showTotal: showGameMetricTotal,
total: gameMetricPagination.total,
}"
:row-key="gameMetricRowKey"
:scroll="{ x: gameTableScrollX }"
size="small"
@change="handleGameMetricTableChange"
>
<template #bodyCell="{ column, record }">
<template
v-if="
gameMetricNumberColumns.has(String(column.dataIndex)) ||
gameMetricPercentColumns.has(String(column.dataIndex))
"
>
<span class="number-cell">
{{ gameMetricCellText(record, String(column.dataIndex)) }}
</span>
</template>
</template>
</Table>
<Table
v-else-if="records.length > 0"
:columns="activeColumns" :columns="activeColumns"
:data-source="records" :data-source="records"
:pagination="{ pageSize: 20, showSizeChanger: true }" :pagination="{ pageSize: 20, showSizeChanger: true }"