1790 lines
55 KiB
Vue
1790 lines
55 KiB
Vue
<script lang="ts" setup>
|
|
import type { EchartsUIType } from '@vben/plugins/echarts';
|
|
|
|
import type {
|
|
CountryDashboardGameMetric,
|
|
CountryDashboardMetric,
|
|
CountryDashboardRechargeDetail,
|
|
CountryDashboardResult,
|
|
} from '#/api/legacy/datav';
|
|
|
|
import {
|
|
computed,
|
|
nextTick,
|
|
onBeforeUnmount,
|
|
onMounted,
|
|
reactive,
|
|
ref,
|
|
watch,
|
|
} from 'vue';
|
|
|
|
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
|
import { useAccessStore } from '@vben/stores';
|
|
|
|
import {
|
|
Button,
|
|
DateRangePicker,
|
|
Empty,
|
|
Input,
|
|
message,
|
|
Modal,
|
|
Select,
|
|
Space,
|
|
Spin,
|
|
Table,
|
|
TabPane,
|
|
Tabs,
|
|
Tooltip,
|
|
} from 'antdv-next';
|
|
import dayjs from 'dayjs';
|
|
import timezone from 'dayjs/plugin/timezone';
|
|
import utc from 'dayjs/plugin/utc';
|
|
|
|
import {
|
|
countryDashboard,
|
|
countryDashboardGameCountryRank,
|
|
countryDashboardGameSummaries,
|
|
countryDashboardRechargeDetails,
|
|
} from '#/api/legacy/datav';
|
|
import SysOriginSelect from '#/components/sys-origin-select.vue';
|
|
import { getAllowedSysOrigins } from '#/views/system/shared';
|
|
|
|
import LatestPurchaseList from './components/latest-purchase-list.vue';
|
|
import PurchaseCount from './components/purchase-count.vue';
|
|
|
|
defineOptions({ name: 'StatisticsDatav' });
|
|
|
|
dayjs.extend(utc);
|
|
dayjs.extend(timezone);
|
|
|
|
const DEFAULT_STAT_TIMEZONE = 'Asia/Riyadh';
|
|
|
|
const accessStore = useAccessStore();
|
|
|
|
const datavFullscreen = ref<HTMLElement>();
|
|
const fullscreen = ref(false);
|
|
const visualMode = ref(false);
|
|
const loading = ref(false);
|
|
const dashboard = ref<CountryDashboardResult | null>(null);
|
|
const activeTableTab = ref<'game' | 'overview' | 'recharge'>('overview');
|
|
const arpuDashboards = ref<{
|
|
day: CountryDashboardResult | null;
|
|
threeDay: CountryDashboardResult | null;
|
|
week: CountryDashboardResult | null;
|
|
}>({
|
|
day: null,
|
|
threeDay: null,
|
|
week: null,
|
|
});
|
|
const initialDate = dayjs().tz(DEFAULT_STAT_TIMEZONE);
|
|
const dateRange = ref<[string, string] | null>([
|
|
initialDate.subtract(6, 'day').format('YYYY-MM-DD'),
|
|
initialDate.format('YYYY-MM-DD'),
|
|
]);
|
|
const selectedCountryCodes = ref<string[]>([]);
|
|
const rechargeDetailOpen = ref(false);
|
|
const rechargeDetailLoading = ref(false);
|
|
const rechargeDetailRecords = ref<CountryDashboardRechargeDetail[]>([]);
|
|
const rechargeDetailPagination = reactive({
|
|
current: 1,
|
|
pageSize: 100,
|
|
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 gameCountryRankOpen = ref(false);
|
|
const gameCountryRankLoading = ref(false);
|
|
const gameCountryRankRecords = ref<CountryDashboardGameMetric[]>([]);
|
|
const selectedGameMetric = ref<null | Partial<CountryDashboardGameMetric>>(null);
|
|
const gameQuery = reactive<{
|
|
gameKeyword: string;
|
|
gameProvider?: string;
|
|
}>({
|
|
gameKeyword: '',
|
|
gameProvider: undefined,
|
|
});
|
|
|
|
const query = reactive({
|
|
periodType: 'DAY',
|
|
statTimezone: DEFAULT_STAT_TIMEZONE,
|
|
sysOrigin: '',
|
|
});
|
|
|
|
const userRechargeChartRef = ref<EchartsUIType>();
|
|
const flowChartRef = ref<EchartsUIType>();
|
|
const profitChartRef = ref<EchartsUIType>();
|
|
const luckyChartRef = ref<EchartsUIType>();
|
|
const { renderEcharts: renderUserRechargeChart } = useEcharts(userRechargeChartRef);
|
|
const { renderEcharts: renderFlowChart } = useEcharts(flowChartRef);
|
|
const { renderEcharts: renderProfitChart } = useEcharts(profitChartRef);
|
|
const { renderEcharts: renderLuckyChart } = useEcharts(luckyChartRef);
|
|
type ChartOption = Parameters<typeof renderUserRechargeChart>[0];
|
|
|
|
const accessCodes = computed(() => accessStore.accessCodes || []);
|
|
const gameCountryRankTitle = computed(() => {
|
|
const game = selectedGameMetric.value;
|
|
if (!game) {
|
|
return '国家消耗排名';
|
|
}
|
|
return `${gameDisplayText(game)} 国家消耗排名`;
|
|
});
|
|
const sysOriginOptions = computed(() => {
|
|
const options = getAllowedSysOrigins(accessCodes.value);
|
|
return options.length > 0 ? options : getAllowedSysOrigins([]);
|
|
});
|
|
const permissionsSysOriginCode = computed(() =>
|
|
sysOriginOptions.value.map((item) => String(item.value || '')),
|
|
);
|
|
|
|
const periodTypeOptions = [
|
|
{ label: '日', value: 'DAY' },
|
|
{ label: '周', value: 'WEEK' },
|
|
{ label: '月', value: 'MONTH' },
|
|
{ label: '全部', value: 'ALL' },
|
|
];
|
|
|
|
const statTimezoneOptions = [
|
|
{ label: '利雅得(服务器)', value: 'Asia/Riyadh' },
|
|
{ label: 'UTC', value: 'UTC' },
|
|
{ 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([
|
|
'countryNewUser',
|
|
'd1RetentionRate',
|
|
'd7RetentionRate',
|
|
'd30RetentionRate',
|
|
'dailyActiveUser',
|
|
'dealerRecharge',
|
|
'gamePayout',
|
|
'gamePayoutRate',
|
|
'gameProfit',
|
|
'gameProfitRate',
|
|
'gameTotalFlow',
|
|
'gameUser',
|
|
'giftConsume',
|
|
'googleRecharge',
|
|
'luckyGiftPayout',
|
|
'luckyGiftPayoutRate',
|
|
'luckyGiftProfit',
|
|
'luckyGiftProfitRate',
|
|
'luckyGiftTotalFlow',
|
|
'luckyGiftUser',
|
|
'luckyGiftUserRate',
|
|
'mifapayRecharge',
|
|
'newUserRecharge',
|
|
'salaryExchange',
|
|
'totalRecharge',
|
|
]);
|
|
|
|
const integerMetricColumns = new Set([
|
|
'countryNewUser',
|
|
'dailyActiveUser',
|
|
'gameUser',
|
|
'luckyGiftUser',
|
|
]);
|
|
|
|
const percentMetricColumns = new Set([
|
|
'd1RetentionRate',
|
|
'd7RetentionRate',
|
|
'd30RetentionRate',
|
|
'gamePayoutRate',
|
|
'gameProfitRate',
|
|
'luckyGiftPayoutRate',
|
|
'luckyGiftProfitRate',
|
|
'luckyGiftUserRate',
|
|
]);
|
|
|
|
const summaryMetricKeys = [
|
|
'countryNewUser',
|
|
'dailyActiveUser',
|
|
'newUserRecharge',
|
|
'dealerRecharge',
|
|
'mifapayRecharge',
|
|
'googleRecharge',
|
|
'salaryExchange',
|
|
'totalRecharge',
|
|
'giftConsume',
|
|
'luckyGiftTotalFlow',
|
|
'luckyGiftUser',
|
|
'luckyGiftPayout',
|
|
'luckyGiftProfit',
|
|
'gameTotalFlow',
|
|
'gameUser',
|
|
'gamePayout',
|
|
'gameProfit',
|
|
'd1RetentionUser',
|
|
'd1RetentionBaseUser',
|
|
'd7RetentionUser',
|
|
'd7RetentionBaseUser',
|
|
'd30RetentionUser',
|
|
'd30RetentionBaseUser',
|
|
] as const;
|
|
|
|
const summaryMetricDefinitions = [
|
|
{ key: 'countryNewUser', label: '新增' },
|
|
{ key: 'dailyActiveUser', label: '当日活跃' },
|
|
{ key: 'newUserRecharge', label: '新增充值' },
|
|
{ key: 'dealerRecharge', label: '币商充值' },
|
|
{ key: 'mifapayRecharge', label: 'MifaPay 充值' },
|
|
{ key: 'googleRecharge', label: 'Google 充值' },
|
|
{ key: 'salaryExchange', label: '工资兑换' },
|
|
{ key: 'totalRecharge', label: '总充值' },
|
|
{ key: 'giftConsume', label: '礼物消耗' },
|
|
{ key: 'luckyGiftTotalFlow', label: '幸运礼物流水' },
|
|
{ key: 'luckyGiftUser', label: '幸运礼物参与人数' },
|
|
{ key: 'luckyGiftUserRate', label: '幸运礼物付费率' },
|
|
{ key: 'luckyGiftPayout', label: '幸运礼物返奖' },
|
|
{ key: 'luckyGiftPayoutRate', label: '幸运礼物返奖率' },
|
|
{ key: 'luckyGiftProfit', label: '幸运礼物利润' },
|
|
{ key: 'luckyGiftProfitRate', label: '幸运礼物利润率' },
|
|
{ key: 'gameTotalFlow', label: '游戏流水' },
|
|
{ key: 'gameUser', label: '游戏参与人数' },
|
|
{ key: 'gamePayout', label: '游戏返奖' },
|
|
{ key: 'gamePayoutRate', label: '游戏返奖率' },
|
|
{ key: 'gameProfit', label: '游戏利润' },
|
|
{ key: 'gameProfitRate', label: '游戏利润率' },
|
|
{ key: 'd1RetentionRate', label: '次日留存率' },
|
|
{ key: 'd7RetentionRate', label: '七日留存率' },
|
|
{ key: 'd30RetentionRate', label: '30 日留存率' },
|
|
] as const;
|
|
|
|
type SummaryMetricKey = (typeof summaryMetricDefinitions)[number]['key'];
|
|
type SummaryMetric = Record<string, number>;
|
|
|
|
const summaryColumns = [
|
|
...summaryMetricDefinitions.map((item) => ({
|
|
align: 'right' as const,
|
|
dataIndex: item.key,
|
|
key: item.key,
|
|
title: item.label,
|
|
width: item.key === 'countryNewUser' ? 110 : (String(item.key).includes('Rate') ? 160 : 150),
|
|
})),
|
|
];
|
|
const summaryScrollX = summaryColumns.reduce((totalWidth, item) => totalWidth + item.width, 0);
|
|
|
|
const overviewColumns: any[] = [
|
|
{ dataIndex: 'countryName', fixed: 'left' as const, key: 'countryName', title: '国家', width: 150 },
|
|
{ dataIndex: 'countryCode', key: 'countryCode', title: 'Code', width: 86 },
|
|
{ dataIndex: 'periodName', key: 'periodName', title: '周期', width: 110 },
|
|
{ align: 'right', dataIndex: 'countryNewUser', key: 'countryNewUser', title: '新增', width: 110 },
|
|
{ align: 'right', dataIndex: 'dailyActiveUser', key: 'dailyActiveUser', title: '当日活跃', width: 120 },
|
|
{ align: 'right', dataIndex: 'd1RetentionRate', key: 'd1RetentionRate', title: '次日留存率', width: 140 },
|
|
{ align: 'right', dataIndex: 'd7RetentionRate', key: 'd7RetentionRate', title: '七日留存率', width: 140 },
|
|
{ align: 'right', dataIndex: 'd30RetentionRate', key: 'd30RetentionRate', title: '30 日留存率', width: 150 },
|
|
{ align: 'right', dataIndex: 'newUserRecharge', key: 'newUserRecharge', title: '新增充值', width: 130 },
|
|
{ align: 'right', dataIndex: 'dealerRecharge', key: 'dealerRecharge', title: '币商充值', width: 130 },
|
|
{ align: 'right', dataIndex: 'mifapayRecharge', key: 'mifapayRecharge', title: 'MifaPay 充值', width: 150 },
|
|
{ align: 'right', dataIndex: 'googleRecharge', key: 'googleRecharge', title: 'Google 充值', width: 140 },
|
|
{ align: 'right', dataIndex: 'salaryExchange', key: 'salaryExchange', title: '工资兑换', width: 130 },
|
|
{ align: 'right', dataIndex: 'totalRecharge', key: 'totalRecharge', title: '总充值', width: 130 },
|
|
{ align: 'right', dataIndex: 'giftConsume', key: 'giftConsume', title: '礼物消耗', width: 130 },
|
|
{ align: 'right', dataIndex: 'luckyGiftTotalFlow', key: 'luckyGiftTotalFlow', title: '幸运礼物流水', width: 150 },
|
|
{ align: 'right', dataIndex: 'luckyGiftUser', key: 'luckyGiftUser', title: '幸运礼物参与人数', width: 170 },
|
|
{ align: 'right', dataIndex: 'luckyGiftUserRate', key: 'luckyGiftUserRate', title: '幸运礼物付费率', width: 160 },
|
|
{ align: 'right', dataIndex: 'luckyGiftPayout', key: 'luckyGiftPayout', title: '幸运礼物返奖', width: 150 },
|
|
{ align: 'right', dataIndex: 'luckyGiftPayoutRate', key: 'luckyGiftPayoutRate', title: '幸运礼物返奖率', width: 160 },
|
|
{ align: 'right', dataIndex: 'luckyGiftProfit', key: 'luckyGiftProfit', title: '幸运礼物利润', width: 150 },
|
|
{ align: 'right', dataIndex: 'luckyGiftProfitRate', key: 'luckyGiftProfitRate', title: '幸运礼物利润率', width: 160 },
|
|
{ align: 'right', dataIndex: 'gameTotalFlow', key: 'gameTotalFlow', title: '游戏流水', width: 130 },
|
|
{ align: 'right', dataIndex: 'gameUser', key: 'gameUser', title: '游戏参与人数', width: 140 },
|
|
{ align: 'right', dataIndex: 'gamePayout', key: 'gamePayout', title: '游戏返奖', width: 130 },
|
|
{ align: 'right', dataIndex: 'gamePayoutRate', key: 'gamePayoutRate', title: '游戏返奖率', width: 140 },
|
|
{ align: 'right', dataIndex: 'gameProfit', key: 'gameProfit', title: '游戏利润', width: 130 },
|
|
{ align: 'right', dataIndex: 'gameProfitRate', key: 'gameProfitRate', title: '游戏利润率', width: 140 },
|
|
].map((column) => ({
|
|
...column,
|
|
sortDirections: ['ascend', 'descend'],
|
|
sorter: compareTableColumn(String(column.dataIndex)),
|
|
}));
|
|
|
|
const rechargeColumns: any[] = [
|
|
{ dataIndex: 'countryName', fixed: 'left' as const, key: 'countryName', title: '国家', width: 150 },
|
|
{ dataIndex: 'countryCode', key: 'countryCode', title: 'Code', width: 86 },
|
|
{ dataIndex: 'periodName', key: 'periodName', title: '周期', width: 110 },
|
|
{ align: 'right', dataIndex: 'dealerRecharge', key: 'dealerRecharge', title: '币商充值', width: 140 },
|
|
{ align: 'right', dataIndex: 'mifapayRecharge', key: 'mifapayRecharge', title: 'MifaPay 充值', width: 160 },
|
|
{ align: 'right', dataIndex: 'googleRecharge', key: 'googleRecharge', title: 'Google 充值', width: 150 },
|
|
{ align: 'right', dataIndex: 'totalRecharge', key: 'totalRecharge', title: '总充值', width: 140 },
|
|
].map((column) => ({
|
|
...column,
|
|
sortDirections: ['ascend', 'descend'],
|
|
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[] = [
|
|
{ align: 'right' as const, dataIndex: 'rank', fixed: 'left' as const, key: 'rank', title: '排名', width: 72 },
|
|
{ dataIndex: 'gameName', fixed: 'left' as const, key: 'gameName', title: '游戏', width: 240 },
|
|
{ dataIndex: 'gameProvider', key: 'gameProvider', title: '游戏厂商', width: 130 },
|
|
{ dataIndex: 'gameId', key: 'gameId', title: '游戏 ID', width: 150 },
|
|
{
|
|
align: 'right' as const,
|
|
dataIndex: 'consumeAmount',
|
|
key: 'consumeAmount',
|
|
sortDirections: ['descend', 'ascend'],
|
|
sorter: true,
|
|
title: '消耗',
|
|
width: 130,
|
|
},
|
|
{ dataIndex: 'refreshedAt', key: 'refreshedAt', title: '更新时间', width: 170 },
|
|
];
|
|
const gameTableScrollX = gameMetricColumns.reduce(
|
|
(totalWidth, item) => totalWidth + Number(item.width || 0),
|
|
0,
|
|
);
|
|
|
|
const gameCountryRankColumns: any[] = [
|
|
{ align: 'right' as const, dataIndex: 'rank', key: 'rank', title: '排名', width: 72 },
|
|
{ dataIndex: 'countryName', key: 'countryName', title: '国家', width: 180 },
|
|
{ dataIndex: 'countryCode', key: 'countryCode', title: 'Code', width: 100 },
|
|
{ align: 'right' as const, dataIndex: 'consumeAmount', key: 'consumeAmount', title: '消耗', width: 150 },
|
|
{ dataIndex: 'refreshedAt', key: 'refreshedAt', title: '更新时间', width: 170 },
|
|
];
|
|
const gameCountryRankScrollX = gameCountryRankColumns.reduce(
|
|
(totalWidth, item) => totalWidth + Number(item.width || 0),
|
|
0,
|
|
);
|
|
|
|
const rechargeDetailColumns: any[] = [
|
|
{ dataIndex: 'createTime', key: 'createTime', title: '时间', width: 170 },
|
|
{ dataIndex: 'sourceType', key: 'sourceType', title: '来源', width: 120 },
|
|
{ dataIndex: 'user', key: 'user', title: '用户', width: 240 },
|
|
{ dataIndex: 'country', key: 'country', title: '国家', width: 160 },
|
|
{ align: 'right', dataIndex: 'amount', key: 'amount', title: '充值金额', width: 120 },
|
|
{ align: 'right', dataIndex: 'coinQuantity', key: 'coinQuantity', title: '金币', width: 130 },
|
|
{ dataIndex: 'sourceChannel', key: 'sourceChannel', title: '渠道/类型', width: 150 },
|
|
{ dataIndex: 'remark', key: 'remark', title: '备注', width: 220 },
|
|
];
|
|
const rechargeDetailScrollX = rechargeDetailColumns.reduce(
|
|
(totalWidth, item) => totalWidth + Number(item.width || 0),
|
|
0,
|
|
);
|
|
const rechargeSourceNameMap: Record<string, string> = {
|
|
DEALER: '币商充值',
|
|
MIFAPAY: 'MifaPay 充值',
|
|
OFFICIAL: '官方充值',
|
|
};
|
|
|
|
const activeColumns = computed(() =>
|
|
activeTableTab.value === 'recharge' ? rechargeColumns : overviewColumns,
|
|
);
|
|
const tableScrollX = computed(() =>
|
|
activeColumns.value.reduce((totalWidth, item) => totalWidth + Number(item.width || 0), 0),
|
|
);
|
|
|
|
const rawRecords = computed(() => dashboard.value?.records || []);
|
|
const countryOptions = computed(() => {
|
|
const optionMap = new Map<string, string>();
|
|
for (const item of rawRecords.value) {
|
|
const code = String(item.countryCode || '');
|
|
if (!code || code === 'ALL') {
|
|
continue;
|
|
}
|
|
optionMap.set(code, `${item.countryName || code} / ${code}`);
|
|
}
|
|
return [...optionMap.entries()]
|
|
.map(([value, label]) => ({ label, value }))
|
|
.toSorted((current, next) => current.label.localeCompare(next.label));
|
|
});
|
|
const records = computed(() => {
|
|
return filterRecordsBySelectedCountries(rawRecords.value);
|
|
});
|
|
const chartRecords = computed(() => records.value.filter((item) => item.countryCode !== 'ALL'));
|
|
const visibleSummary = computed(() => buildVisibleSummary(records.value));
|
|
const summaryDataSource = computed(() => {
|
|
const row: Record<string, string> = { key: 'summary' };
|
|
for (const item of summaryMetricDefinitions) {
|
|
row[item.key] = formatSummaryValue(item.key, visibleSummary.value);
|
|
}
|
|
return [row];
|
|
});
|
|
const luckyGameCards = computed(() => {
|
|
const summary = visibleSummary.value;
|
|
return [
|
|
{ label: '幸运礼物参与人数', value: formatInteger(summary.luckyGiftUser) },
|
|
{ label: '幸运礼物付费率', value: formatPercent(summary.luckyGiftUserRate) },
|
|
{ label: '幸运礼物返奖率', value: formatPercent(summary.luckyGiftPayoutRate) },
|
|
{ label: '游戏参与人数', value: formatInteger(summary.gameUser) },
|
|
{ label: '游戏返奖率', value: formatPercent(summary.gamePayoutRate) },
|
|
{ label: '游戏利润率', value: formatPercent(summary.gameProfitRate) },
|
|
];
|
|
});
|
|
const appDataCards = computed(() => {
|
|
const summary = visibleSummary.value;
|
|
return [
|
|
{ label: '当日活跃数', value: formatInteger(summary.dailyActiveUser) },
|
|
{ label: '次日留存率', value: formatPercent(summary.d1RetentionRate) },
|
|
{ label: '七日留存率', value: formatPercent(summary.d7RetentionRate) },
|
|
{ label: '30 日留存率', value: formatPercent(summary.d30RetentionRate) },
|
|
];
|
|
});
|
|
const arpuCards = computed(() => {
|
|
return [
|
|
{ label: '日 ARPU', value: formatArpu(arpuDashboards.value.day) },
|
|
{ label: '3 日 ARPU', value: formatArpu(arpuDashboards.value.threeDay) },
|
|
{ label: '周 ARPU', value: formatArpu(arpuDashboards.value.week) },
|
|
{ label: '筛选 ARPU', value: formatAmount(calcArpu(visibleSummary.value)) },
|
|
];
|
|
});
|
|
const totalRechargeCard = computed(() => ({
|
|
label: '总充值',
|
|
value: formatAmount(visibleSummary.value.totalRecharge),
|
|
}));
|
|
|
|
function hasPermission(code: string) {
|
|
const codes = accessCodes.value;
|
|
return codes.length === 0 || codes.includes(code);
|
|
}
|
|
|
|
const canQuery = computed(() => hasPermission('datav:query'));
|
|
const canPaidPreview = computed(() => hasPermission('datav:paid:preview'));
|
|
const currentTimezoneLabel = computed(() => {
|
|
return statTimezoneOptions.find((item) => item.value === query.statTimezone)?.label || query.statTimezone;
|
|
});
|
|
|
|
function syncFullscreenState() {
|
|
fullscreen.value = document.fullscreenElement === datavFullscreen.value;
|
|
}
|
|
|
|
async function screen() {
|
|
const element = datavFullscreen.value;
|
|
if (!element) {
|
|
return;
|
|
}
|
|
if (document.fullscreenElement === element) {
|
|
await document.exitFullscreen();
|
|
return;
|
|
}
|
|
if (document.fullscreenElement) {
|
|
await document.exitFullscreen();
|
|
}
|
|
await element.requestFullscreen();
|
|
}
|
|
|
|
async function toggleVisualMode() {
|
|
visualMode.value = !visualMode.value;
|
|
await renderVisualCharts();
|
|
}
|
|
|
|
function defaultRange(periodType: string): [string, string] | null {
|
|
const now = dayjs().tz(query.statTimezone || DEFAULT_STAT_TIMEZONE);
|
|
if (periodType === 'ALL') {
|
|
return null;
|
|
}
|
|
if (periodType === 'MONTH') {
|
|
return [now.startOf('month').format('YYYY-MM-DD'), now.format('YYYY-MM-DD')];
|
|
}
|
|
if (periodType === 'WEEK') {
|
|
const daysSinceMonday = now.day() === 0 ? 6 : now.day() - 1;
|
|
return [now.subtract(daysSinceMonday, 'day').format('YYYY-MM-DD'), now.format('YYYY-MM-DD')];
|
|
}
|
|
return [now.subtract(6, 'day').format('YYYY-MM-DD'), now.format('YYYY-MM-DD')];
|
|
}
|
|
|
|
function handlePeriodChange(value: string) {
|
|
dateRange.value = defaultRange(value);
|
|
loadDashboard();
|
|
}
|
|
|
|
function handleTimezoneChange() {
|
|
dateRange.value = defaultRange(query.periodType);
|
|
loadDashboard();
|
|
}
|
|
|
|
function buildParams() {
|
|
const params: Record<string, any> = {
|
|
periodType: query.periodType,
|
|
statTimezone: query.statTimezone || DEFAULT_STAT_TIMEZONE,
|
|
sysOrigin: query.sysOrigin || undefined,
|
|
};
|
|
if (dateRange.value?.[0] && dateRange.value?.[1]) {
|
|
params.startDate = dateRange.value[0];
|
|
params.endDate = dateRange.value[1];
|
|
}
|
|
return params;
|
|
}
|
|
|
|
function buildRecentDayParams(dayCount: number) {
|
|
const end = dayjs().tz(query.statTimezone || DEFAULT_STAT_TIMEZONE);
|
|
const start = end.subtract(dayCount - 1, 'day');
|
|
return {
|
|
periodType: 'DAY',
|
|
startDate: start.format('YYYY-MM-DD'),
|
|
endDate: end.format('YYYY-MM-DD'),
|
|
statTimezone: query.statTimezone || DEFAULT_STAT_TIMEZONE,
|
|
sysOrigin: query.sysOrigin || undefined,
|
|
};
|
|
}
|
|
|
|
async function loadDashboard() {
|
|
if (!canQuery.value) {
|
|
return;
|
|
}
|
|
loading.value = true;
|
|
try {
|
|
const [mainDashboard, dayArpuDashboard, threeDayArpuDashboard, weekArpuDashboard] =
|
|
await Promise.all([
|
|
countryDashboard(buildParams()),
|
|
countryDashboard(buildRecentDayParams(1)),
|
|
countryDashboard(buildRecentDayParams(3)),
|
|
countryDashboard(buildRecentDayParams(7)),
|
|
]);
|
|
dashboard.value = mainDashboard;
|
|
arpuDashboards.value = {
|
|
day: dayArpuDashboard,
|
|
threeDay: threeDayArpuDashboard,
|
|
week: weekArpuDashboard,
|
|
};
|
|
if (activeTableTab.value === 'game') {
|
|
await loadGameMetrics();
|
|
if (gameCountryRankOpen.value) {
|
|
await loadGameCountryRank();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
dashboard.value = null;
|
|
arpuDashboards.value = {
|
|
day: null,
|
|
threeDay: null,
|
|
week: null,
|
|
};
|
|
message.error('数据大屏加载失败');
|
|
throw error;
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
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 countryDashboardGameSummaries(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;
|
|
}
|
|
}
|
|
|
|
function buildGameCountryRankParams(record: Partial<CountryDashboardGameMetric>) {
|
|
return {
|
|
...buildParams(),
|
|
countryCodes: selectedCountryCodes.value.join(',') || undefined,
|
|
gameId: record.gameId,
|
|
gameProvider: record.gameProvider,
|
|
rankLimit: 200,
|
|
};
|
|
}
|
|
|
|
async function loadGameCountryRank(record = selectedGameMetric.value) {
|
|
if (!record?.gameId || !record.gameProvider || !canQuery.value) {
|
|
return;
|
|
}
|
|
gameCountryRankLoading.value = true;
|
|
try {
|
|
gameCountryRankRecords.value = await countryDashboardGameCountryRank(
|
|
buildGameCountryRankParams(record),
|
|
);
|
|
} catch {
|
|
gameCountryRankRecords.value = [];
|
|
message.error('游戏国家排名加载失败');
|
|
} finally {
|
|
gameCountryRankLoading.value = false;
|
|
}
|
|
}
|
|
|
|
async function openGameCountryRank(record: Partial<CountryDashboardGameMetric>) {
|
|
selectedGameMetric.value = record;
|
|
gameCountryRankOpen.value = true;
|
|
await loadGameCountryRank(record);
|
|
}
|
|
|
|
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() {
|
|
rechargeDetailLoading.value = true;
|
|
try {
|
|
const result = await countryDashboardRechargeDetails({
|
|
...buildParams(),
|
|
countryCodes: selectedCountryCodes.value.join(',') || undefined,
|
|
cursor: rechargeDetailPagination.current,
|
|
limit: rechargeDetailPagination.pageSize,
|
|
});
|
|
rechargeDetailRecords.value = result.records || [];
|
|
rechargeDetailPagination.total = Number(result.total || 0);
|
|
} finally {
|
|
rechargeDetailLoading.value = false;
|
|
}
|
|
}
|
|
|
|
async function openRechargeDetails() {
|
|
rechargeDetailOpen.value = true;
|
|
rechargeDetailPagination.current = 1;
|
|
await loadRechargeDetails();
|
|
}
|
|
|
|
async function handleRechargeDetailTableChange(pagination: Record<string, any>) {
|
|
rechargeDetailPagination.current = Number(pagination.current || 1);
|
|
await loadRechargeDetails();
|
|
}
|
|
|
|
function rechargeDetailRowKey(record: Record<string, any>) {
|
|
return `${record.sourceType}-${record.recordId}`;
|
|
}
|
|
|
|
function gameMetricRowKey(record: Record<string, any>) {
|
|
return [record.gameProvider, record.gameId].join('-');
|
|
}
|
|
|
|
function gameCountryRankRowKey(record: Record<string, any>) {
|
|
return [record.countryCode, record.gameProvider, record.gameId].join('-');
|
|
}
|
|
|
|
function gameMetricRank(index: number) {
|
|
return (gameMetricPagination.current - 1) * gameMetricPagination.pageSize + index + 1;
|
|
}
|
|
|
|
function gameDisplayText(record: Partial<CountryDashboardGameMetric>) {
|
|
return record.gameName || record.gameId || '-';
|
|
}
|
|
|
|
function rechargeSourceName(sourceType: string) {
|
|
return rechargeSourceNameMap[sourceType] || sourceType || '-';
|
|
}
|
|
|
|
function rechargeDetailUserText(record: Record<string, any>) {
|
|
const account = record.userAccount || record.userId;
|
|
return [account, record.userNickname].filter(Boolean).join(' / ') || '-';
|
|
}
|
|
|
|
function rechargeDetailCountryText(record: Record<string, any>) {
|
|
return [record.countryName, record.countryCode].filter(Boolean).join(' / ') || '-';
|
|
}
|
|
|
|
function showRechargeDetailTotal(total: number) {
|
|
return `共 ${total} 条`;
|
|
}
|
|
|
|
function showGameMetricTotal(total: number) {
|
|
return `共 ${total} 条`;
|
|
}
|
|
|
|
function toAmount(value?: null | number | string) {
|
|
const number = Number(value || 0);
|
|
return Number.isFinite(number) ? number : 0;
|
|
}
|
|
|
|
function calcPercent(numerator?: null | number | string, denominator?: null | number | string) {
|
|
const fixedDenominator = toAmount(denominator);
|
|
if (fixedDenominator === 0) {
|
|
return 0;
|
|
}
|
|
return (toAmount(numerator) / fixedDenominator) * 100;
|
|
}
|
|
|
|
function formatInteger(value?: null | number | string) {
|
|
const number = Number(value || 0);
|
|
return Number.isFinite(number) ? number.toLocaleString('zh-CN', { maximumFractionDigits: 0 }) : '0';
|
|
}
|
|
|
|
function formatAmount(value?: null | number | string) {
|
|
const number = Number(value || 0);
|
|
return Number.isFinite(number)
|
|
? number.toLocaleString('zh-CN', {
|
|
maximumFractionDigits: 2,
|
|
minimumFractionDigits: 0,
|
|
})
|
|
: '0';
|
|
}
|
|
|
|
function formatPercent(value?: null | number | string) {
|
|
const number = Number(value || 0);
|
|
return Number.isFinite(number)
|
|
? `${number.toLocaleString('zh-CN', {
|
|
maximumFractionDigits: 2,
|
|
minimumFractionDigits: 2,
|
|
})}%`
|
|
: '0.00%';
|
|
}
|
|
|
|
function resolveLuckyGiftProfitRate(record?: null | {
|
|
luckyGiftProfit?: number | string;
|
|
luckyGiftProfitRate?: number | string;
|
|
luckyGiftTotalFlow?: number | string;
|
|
}) {
|
|
if (!record) {
|
|
return 0;
|
|
}
|
|
if (
|
|
record.luckyGiftProfitRate !== undefined &&
|
|
record.luckyGiftProfitRate !== null &&
|
|
record.luckyGiftProfitRate !== ''
|
|
) {
|
|
const rate = Number(record.luckyGiftProfitRate);
|
|
if (Number.isFinite(rate)) {
|
|
return rate;
|
|
}
|
|
}
|
|
const flow = Number(record.luckyGiftTotalFlow || 0);
|
|
if (!Number.isFinite(flow) || flow === 0) {
|
|
return 0;
|
|
}
|
|
return (Number(record.luckyGiftProfit || 0) / flow) * 100;
|
|
}
|
|
|
|
function resolveMetricRate(record: Record<string, any>, key: string) {
|
|
if (key === 'luckyGiftProfitRate') {
|
|
return resolveLuckyGiftProfitRate(record);
|
|
}
|
|
const rate = Number(record[key]);
|
|
if (Number.isFinite(rate) && record[key] !== undefined && record[key] !== null && record[key] !== '') {
|
|
return rate;
|
|
}
|
|
if (key === 'luckyGiftUserRate') {
|
|
return calcPercent(record.luckyGiftUser, record.dailyActiveUser);
|
|
}
|
|
if (key === 'luckyGiftPayoutRate') {
|
|
return calcPercent(record.luckyGiftPayout, record.luckyGiftTotalFlow);
|
|
}
|
|
if (key === 'gamePayoutRate') {
|
|
return calcPercent(record.gamePayout, record.gameTotalFlow);
|
|
}
|
|
if (key === 'gameProfitRate') {
|
|
return calcPercent(record.gameProfit, record.gameTotalFlow);
|
|
}
|
|
if (key === 'd1RetentionRate') {
|
|
return calcPercent(record.d1RetentionUser, record.d1RetentionBaseUser);
|
|
}
|
|
if (key === 'd7RetentionRate') {
|
|
return calcPercent(record.d7RetentionUser, record.d7RetentionBaseUser);
|
|
}
|
|
if (key === 'd30RetentionRate') {
|
|
return calcPercent(record.d30RetentionUser, record.d30RetentionBaseUser);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function calcArpu(summary: Record<string, any>) {
|
|
const activeUser = toAmount(summary.dailyActiveUser);
|
|
if (activeUser === 0) {
|
|
return 0;
|
|
}
|
|
return toAmount(summary.totalRecharge) / activeUser;
|
|
}
|
|
|
|
function formatArpu(result?: CountryDashboardResult | null) {
|
|
const summary = buildVisibleSummary(filterRecordsBySelectedCountries(result?.records || []));
|
|
return formatAmount(calcArpu(summary));
|
|
}
|
|
|
|
function emptySummary(): SummaryMetric {
|
|
return {
|
|
countryNewUser: 0,
|
|
d1RetentionBaseUser: 0,
|
|
d1RetentionRate: 0,
|
|
d1RetentionUser: 0,
|
|
d7RetentionBaseUser: 0,
|
|
d7RetentionRate: 0,
|
|
d7RetentionUser: 0,
|
|
d30RetentionBaseUser: 0,
|
|
d30RetentionRate: 0,
|
|
d30RetentionUser: 0,
|
|
dailyActiveUser: 0,
|
|
dealerRecharge: 0,
|
|
googleRecharge: 0,
|
|
gamePayout: 0,
|
|
gamePayoutRate: 0,
|
|
gameProfit: 0,
|
|
gameProfitRate: 0,
|
|
gameTotalFlow: 0,
|
|
gameUser: 0,
|
|
giftConsume: 0,
|
|
luckyGiftPayout: 0,
|
|
luckyGiftPayoutRate: 0,
|
|
luckyGiftProfit: 0,
|
|
luckyGiftProfitRate: 0,
|
|
luckyGiftTotalFlow: 0,
|
|
luckyGiftUser: 0,
|
|
luckyGiftUserRate: 0,
|
|
mifapayRecharge: 0,
|
|
newUserRecharge: 0,
|
|
salaryExchange: 0,
|
|
totalRecharge: 0,
|
|
};
|
|
}
|
|
|
|
function buildVisibleSummary(list: CountryDashboardMetric[]) {
|
|
const summary = emptySummary();
|
|
for (const item of list) {
|
|
for (const key of summaryMetricKeys) {
|
|
summary[key] = toAmount(summary[key]) + toAmount(item[key] as number | string);
|
|
}
|
|
}
|
|
summary.luckyGiftUserRate = calcPercent(summary.luckyGiftUser, summary.dailyActiveUser);
|
|
summary.luckyGiftPayoutRate = calcPercent(summary.luckyGiftPayout, summary.luckyGiftTotalFlow);
|
|
summary.luckyGiftProfitRate = resolveLuckyGiftProfitRate(summary);
|
|
summary.gamePayoutRate = calcPercent(summary.gamePayout, summary.gameTotalFlow);
|
|
summary.gameProfitRate = calcPercent(summary.gameProfit, summary.gameTotalFlow);
|
|
summary.d1RetentionRate = calcPercent(summary.d1RetentionUser, summary.d1RetentionBaseUser);
|
|
summary.d7RetentionRate = calcPercent(summary.d7RetentionUser, summary.d7RetentionBaseUser);
|
|
summary.d30RetentionRate = calcPercent(summary.d30RetentionUser, summary.d30RetentionBaseUser);
|
|
return summary;
|
|
}
|
|
|
|
function formatSummaryValue(key: SummaryMetricKey, summary: SummaryMetric) {
|
|
if (integerMetricColumns.has(key)) {
|
|
return formatInteger(summary[key]);
|
|
}
|
|
if (percentMetricColumns.has(key)) {
|
|
return formatPercent(resolveMetricRate(summary, key));
|
|
}
|
|
return formatAmount(summary[key]);
|
|
}
|
|
|
|
function filterCountry(input: string, option?: Record<string, any>) {
|
|
const keyword = input.toLowerCase();
|
|
const label = String(option?.label || '').toLowerCase();
|
|
const value = String(option?.value || '').toLowerCase();
|
|
return label.includes(keyword) || value.includes(keyword);
|
|
}
|
|
|
|
function tableCellText(record: Record<string, any>, key: string) {
|
|
if (integerMetricColumns.has(key)) {
|
|
return formatInteger(record[key] as number | string);
|
|
}
|
|
if (percentMetricColumns.has(key)) {
|
|
return formatPercent(resolveMetricRate(record, key));
|
|
}
|
|
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) {
|
|
return (current: Record<string, any>, next: Record<string, any>) => {
|
|
if (metricColumns.has(key)) {
|
|
const currentValue = percentMetricColumns.has(key)
|
|
? resolveMetricRate(current, key)
|
|
: toAmount(current[key]);
|
|
const nextValue = percentMetricColumns.has(key)
|
|
? resolveMetricRate(next, key)
|
|
: toAmount(next[key]);
|
|
return currentValue - nextValue;
|
|
}
|
|
return String(current[key] || '').localeCompare(String(next[key] || ''));
|
|
};
|
|
}
|
|
|
|
function filterRecordsBySelectedCountries(list: CountryDashboardMetric[]) {
|
|
const selected = new Set(selectedCountryCodes.value);
|
|
if (selected.size === 0) {
|
|
return list;
|
|
}
|
|
return list.filter((item) => selected.has(String(item.countryCode || '')));
|
|
}
|
|
|
|
function rowKey(record: Record<string, any>) {
|
|
return `${record.countryCode}-${record.periodKey}`;
|
|
}
|
|
|
|
function chartCategory(record: CountryDashboardMetric) {
|
|
return `${record.periodName || '-'}\n${record.countryName || record.countryCode || '-'}`;
|
|
}
|
|
|
|
function amountSeries(name: string, dataIndex: keyof CountryDashboardMetric, list: CountryDashboardMetric[]) {
|
|
return {
|
|
data: list.map((item) => toAmount(item[dataIndex] as number | string)),
|
|
emphasis: { focus: 'series' },
|
|
name,
|
|
type: 'bar',
|
|
};
|
|
}
|
|
|
|
function lineSeries(name: string, dataIndex: keyof CountryDashboardMetric, list: CountryDashboardMetric[]) {
|
|
return {
|
|
data: list.map((item) => toAmount(item[dataIndex] as number | string)),
|
|
emphasis: { focus: 'series' },
|
|
name,
|
|
smooth: true,
|
|
type: 'line',
|
|
};
|
|
}
|
|
|
|
function valueYAxis(labelFormatter?: string) {
|
|
return {
|
|
axisLabel: {
|
|
color: '#94a3b8',
|
|
formatter: labelFormatter,
|
|
},
|
|
splitLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.16)' } },
|
|
type: 'value' as const,
|
|
};
|
|
}
|
|
|
|
function baseChartOption(title: string, list: CountryDashboardMetric[], series: any[]): ChartOption {
|
|
return {
|
|
backgroundColor: 'transparent',
|
|
dataZoom: [
|
|
{ bottom: 6, height: 18, type: 'slider' },
|
|
{ type: 'inside' },
|
|
],
|
|
grid: {
|
|
bottom: 58,
|
|
containLabel: true,
|
|
left: 16,
|
|
right: 20,
|
|
top: 74,
|
|
},
|
|
legend: {
|
|
textStyle: { color: '#cbd5e1' },
|
|
top: 30,
|
|
type: 'scroll',
|
|
},
|
|
series,
|
|
textStyle: {
|
|
color: '#e2e8f0',
|
|
},
|
|
title: {
|
|
left: 16,
|
|
text: title,
|
|
textStyle: {
|
|
color: '#f8fafc',
|
|
fontSize: 15,
|
|
fontWeight: 700,
|
|
},
|
|
},
|
|
tooltip: {
|
|
trigger: 'axis',
|
|
},
|
|
xAxis: {
|
|
axisLabel: {
|
|
color: '#94a3b8',
|
|
interval: 0,
|
|
},
|
|
axisLine: { lineStyle: { color: '#334155' } },
|
|
data: list.map((item) => chartCategory(item)),
|
|
type: 'category',
|
|
},
|
|
yAxis: valueYAxis(),
|
|
};
|
|
}
|
|
|
|
function luckyChartOption(list: CountryDashboardMetric[]): ChartOption {
|
|
const option = baseChartOption('幸运礼物结构与利润率', list, [
|
|
amountSeries('幸运礼物流水', 'luckyGiftTotalFlow', list),
|
|
amountSeries('幸运礼物返奖', 'luckyGiftPayout', list),
|
|
amountSeries('幸运礼物利润', 'luckyGiftProfit', list),
|
|
{
|
|
...lineSeries('幸运礼物利润率', 'luckyGiftProfitRate', list),
|
|
yAxisIndex: 1,
|
|
},
|
|
]);
|
|
return {
|
|
...option,
|
|
yAxis: [
|
|
valueYAxis(),
|
|
{
|
|
axisLabel: {
|
|
color: '#94a3b8',
|
|
formatter: '{value}%',
|
|
},
|
|
splitLine: { show: false },
|
|
type: 'value',
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
async function renderVisualCharts() {
|
|
if (!visualMode.value || loading.value) {
|
|
return;
|
|
}
|
|
await nextTick();
|
|
await new Promise<void>((resolve) => {
|
|
requestAnimationFrame(() => resolve());
|
|
});
|
|
const list = chartRecords.value;
|
|
if (list.length === 0) {
|
|
return;
|
|
}
|
|
renderUserRechargeChart(baseChartOption('新增用户与充值', list, [
|
|
amountSeries('新增用户', 'countryNewUser', list),
|
|
amountSeries('新增充值', 'newUserRecharge', list),
|
|
amountSeries('币商充值', 'dealerRecharge', list),
|
|
amountSeries('MifaPay 充值', 'mifapayRecharge', list),
|
|
amountSeries('Google 充值', 'googleRecharge', list),
|
|
amountSeries('工资兑换', 'salaryExchange', list),
|
|
amountSeries('总充值', 'totalRecharge', list),
|
|
]));
|
|
renderFlowChart(baseChartOption('核心流水', list, [
|
|
amountSeries('礼物消耗', 'giftConsume', list),
|
|
amountSeries('幸运礼物流水', 'luckyGiftTotalFlow', list),
|
|
amountSeries('游戏流水', 'gameTotalFlow', list),
|
|
]));
|
|
renderProfitChart(baseChartOption('返奖与利润', list, [
|
|
amountSeries('幸运礼物返奖', 'luckyGiftPayout', list),
|
|
amountSeries('幸运礼物利润', 'luckyGiftProfit', list),
|
|
amountSeries('游戏返奖', 'gamePayout', list),
|
|
amountSeries('游戏利润', 'gameProfit', list),
|
|
]));
|
|
renderLuckyChart(luckyChartOption(list));
|
|
}
|
|
|
|
watch(
|
|
sysOriginOptions,
|
|
(options) => {
|
|
if (!query.sysOrigin && options[0]?.value) {
|
|
query.sysOrigin = String(options[0].value);
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
watch([records, visualMode], () => {
|
|
renderVisualCharts();
|
|
});
|
|
|
|
watch(countryOptions, (options) => {
|
|
const optionValues = new Set(options.map((item) => item.value));
|
|
selectedCountryCodes.value = selectedCountryCodes.value.filter((code) =>
|
|
optionValues.has(code),
|
|
);
|
|
});
|
|
|
|
watch(activeTableTab, (value) => {
|
|
if (value === 'game') {
|
|
gameMetricPagination.current = 1;
|
|
void loadGameMetrics();
|
|
}
|
|
});
|
|
|
|
watch(selectedCountryCodes, () => {
|
|
if (activeTableTab.value === 'game') {
|
|
gameMetricPagination.current = 1;
|
|
void loadGameMetrics();
|
|
if (gameCountryRankOpen.value) {
|
|
void loadGameCountryRank();
|
|
}
|
|
}
|
|
});
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('fullscreenchange', syncFullscreenState);
|
|
loadDashboard();
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
document.removeEventListener('fullscreenchange', syncFullscreenState);
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="app-container-datav">
|
|
<div
|
|
ref="datavFullscreen"
|
|
class="datav-shell" :class="[
|
|
fullscreen ? 'datav-shell--fullscreen' : '',
|
|
]"
|
|
@dblclick="screen"
|
|
>
|
|
<div class="datav-header">
|
|
<div>
|
|
<div class="datav-title">数据大屏</div>
|
|
<div class="datav-subtitle">
|
|
国家维度 · {{ currentTimezoneLabel }} · {{ dashboard?.computedAt || '-' }}
|
|
</div>
|
|
</div>
|
|
<div class="datav-header__center">
|
|
<Button
|
|
:type="visualMode ? 'primary' : 'default'"
|
|
@click="toggleVisualMode"
|
|
>
|
|
{{ visualMode ? '表格数据' : '可视化图表' }}
|
|
</Button>
|
|
</div>
|
|
<Space wrap>
|
|
<Button ghost @click="screen">
|
|
{{ fullscreen ? '退出全屏' : '全屏' }}
|
|
</Button>
|
|
</Space>
|
|
</div>
|
|
|
|
<div v-if="canQuery" class="datav-content">
|
|
<section class="panel filter-panel">
|
|
<Space wrap>
|
|
<SysOriginSelect
|
|
v-model:value="query.sysOrigin"
|
|
:options="sysOriginOptions"
|
|
allow-clear
|
|
placeholder="系统"
|
|
style="width: 150px"
|
|
@change="loadDashboard"
|
|
/>
|
|
<Select
|
|
v-model:value="query.statTimezone"
|
|
:options="statTimezoneOptions"
|
|
style="width: 170px"
|
|
@change="handleTimezoneChange"
|
|
/>
|
|
<Select
|
|
v-model:value="query.periodType"
|
|
:options="periodTypeOptions"
|
|
style="width: 110px"
|
|
@change="handlePeriodChange"
|
|
/>
|
|
<DateRangePicker
|
|
v-model:value="dateRange"
|
|
allow-clear
|
|
style="width: 260px"
|
|
value-format="YYYY-MM-DD"
|
|
@change="loadDashboard"
|
|
/>
|
|
<Select
|
|
v-model:value="selectedCountryCodes"
|
|
:filter-option="filterCountry"
|
|
:max-tag-count="2"
|
|
:options="countryOptions"
|
|
allow-clear
|
|
mode="multiple"
|
|
option-label-prop="label"
|
|
placeholder="国家多选"
|
|
show-search
|
|
style="width: 300px"
|
|
/>
|
|
<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>
|
|
</Space>
|
|
</section>
|
|
|
|
<section class="panel metric-section recharge-total-panel">
|
|
<div class="metric-section__title">充值</div>
|
|
<div class="metric-card-grid metric-card-grid--recharge">
|
|
<div
|
|
class="metric-card metric-card--clickable"
|
|
role="button"
|
|
tabindex="0"
|
|
@click="openRechargeDetails"
|
|
@keydown.enter="openRechargeDetails"
|
|
>
|
|
<div class="metric-card__label">{{ totalRechargeCard.label }}</div>
|
|
<div class="metric-card__value">{{ totalRechargeCard.value }}</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="metric-section-grid">
|
|
<section class="panel metric-section">
|
|
<div class="metric-section__title">幸运礼物 / 游戏</div>
|
|
<div class="metric-card-grid">
|
|
<div
|
|
v-for="item in luckyGameCards"
|
|
:key="item.label"
|
|
class="metric-card"
|
|
>
|
|
<div class="metric-card__label">{{ item.label }}</div>
|
|
<div class="metric-card__value">{{ item.value }}</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<section class="panel metric-section">
|
|
<div class="metric-section__title">APP 数据</div>
|
|
<div class="metric-card-grid metric-card-grid--app">
|
|
<div
|
|
v-for="item in appDataCards"
|
|
:key="item.label"
|
|
class="metric-card"
|
|
>
|
|
<div class="metric-card__label">{{ item.label }}</div>
|
|
<div class="metric-card__value">{{ item.value }}</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<section class="panel metric-section">
|
|
<div class="metric-section__title">ARPU</div>
|
|
<div class="metric-card-grid metric-card-grid--arpu">
|
|
<div
|
|
v-for="item in arpuCards"
|
|
:key="item.label"
|
|
class="metric-card"
|
|
>
|
|
<div class="metric-card__label">{{ item.label }}</div>
|
|
<div class="metric-card__value">{{ item.value }}</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<section class="panel summary-table-panel">
|
|
<Table
|
|
:columns="summaryColumns"
|
|
:data-source="summaryDataSource"
|
|
:pagination="false"
|
|
row-key="key"
|
|
:scroll="{ x: summaryScrollX }"
|
|
size="small"
|
|
>
|
|
<template #bodyCell="{ column, record }">
|
|
<span class="number-cell summary-value-cell">
|
|
{{ record[String(column.dataIndex)] }}
|
|
</span>
|
|
</template>
|
|
</Table>
|
|
</section>
|
|
|
|
<section class="panel dashboard-panel">
|
|
<Spin :spinning="loading">
|
|
<template v-if="visualMode">
|
|
<div v-if="chartRecords.length > 0" class="chart-grid">
|
|
<section class="chart-card">
|
|
<EchartsUI ref="userRechargeChartRef" style="height: 340px; width: 100%" />
|
|
</section>
|
|
<section class="chart-card">
|
|
<EchartsUI ref="flowChartRef" style="height: 340px; width: 100%" />
|
|
</section>
|
|
<section class="chart-card">
|
|
<EchartsUI ref="profitChartRef" style="height: 340px; width: 100%" />
|
|
</section>
|
|
<section class="chart-card">
|
|
<EchartsUI ref="luckyChartRef" style="height: 340px; width: 100%" />
|
|
</section>
|
|
</div>
|
|
<Empty v-else />
|
|
</template>
|
|
<template v-else>
|
|
<Tabs v-model:active-key="activeTableTab" class="dashboard-tabs" size="small">
|
|
<TabPane key="overview" tab="指标总览" />
|
|
<TabPane key="recharge" tab="充值相关数据" />
|
|
<TabPane key="game" tab="游戏数据" />
|
|
</Tabs>
|
|
<Table
|
|
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, index, record }">
|
|
<template v-if="column.key === 'rank'">
|
|
<span class="number-cell">{{ gameMetricRank(index) }}</span>
|
|
</template>
|
|
<template v-else-if="column.key === 'gameName'">
|
|
<Button
|
|
class="game-link-button"
|
|
size="small"
|
|
type="link"
|
|
@click="openGameCountryRank(record)"
|
|
>
|
|
{{ gameDisplayText(record) }}
|
|
</Button>
|
|
</template>
|
|
<template
|
|
v-else-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"
|
|
:data-source="records"
|
|
:pagination="{ pageSize: 20, showSizeChanger: true }"
|
|
:row-key="rowKey"
|
|
:scroll="{ x: tableScrollX }"
|
|
size="small"
|
|
>
|
|
<template #bodyCell="{ column, record }">
|
|
<template v-if="metricColumns.has(String(column.dataIndex))">
|
|
<Tooltip v-if="column.dataIndex === 'luckyGiftProfit'">
|
|
<template #title>
|
|
主播分成:{{ formatAmount(record.luckyGiftAnchorShare) }}
|
|
</template>
|
|
<span class="number-cell">
|
|
{{ tableCellText(record, String(column.dataIndex)) }}
|
|
</span>
|
|
</Tooltip>
|
|
<span v-else class="number-cell">
|
|
{{ tableCellText(record, String(column.dataIndex)) }}
|
|
</span>
|
|
</template>
|
|
</template>
|
|
</Table>
|
|
<Empty v-else />
|
|
</template>
|
|
</Spin>
|
|
</section>
|
|
|
|
<div class="legacy-grid">
|
|
<section class="panel">
|
|
<div class="panel-title">购买用户</div>
|
|
<LatestPurchaseList :sys-origin-codes="permissionsSysOriginCode" />
|
|
</section>
|
|
<section v-if="canPaidPreview" class="panel">
|
|
<div class="panel-title">今日付费预览</div>
|
|
<PurchaseCount />
|
|
</section>
|
|
</div>
|
|
</div>
|
|
|
|
<Modal
|
|
v-model:open="rechargeDetailOpen"
|
|
:footer="null"
|
|
title="总充值明细"
|
|
width="1180px"
|
|
>
|
|
<Table
|
|
:columns="rechargeDetailColumns"
|
|
:data-source="rechargeDetailRecords"
|
|
:loading="rechargeDetailLoading"
|
|
:pagination="{
|
|
current: rechargeDetailPagination.current,
|
|
pageSize: rechargeDetailPagination.pageSize,
|
|
showSizeChanger: false,
|
|
showTotal: showRechargeDetailTotal,
|
|
total: rechargeDetailPagination.total,
|
|
}"
|
|
:row-key="rechargeDetailRowKey"
|
|
:scroll="{ x: rechargeDetailScrollX }"
|
|
size="small"
|
|
@change="handleRechargeDetailTableChange"
|
|
>
|
|
<template #bodyCell="{ column, record }">
|
|
<template v-if="column.key === 'sourceType'">
|
|
{{ rechargeSourceName(record.sourceType) }}
|
|
</template>
|
|
<template v-else-if="column.key === 'user'">
|
|
{{ rechargeDetailUserText(record) }}
|
|
</template>
|
|
<template v-else-if="column.key === 'country'">
|
|
{{ rechargeDetailCountryText(record) }}
|
|
</template>
|
|
<template v-else-if="column.key === 'amount'">
|
|
<span class="number-cell">{{ formatAmount(record.amount) }}</span>
|
|
</template>
|
|
<template v-else-if="column.key === 'coinQuantity'">
|
|
<span class="number-cell">{{ formatAmount(record.coinQuantity) }}</span>
|
|
</template>
|
|
</template>
|
|
</Table>
|
|
</Modal>
|
|
<Modal
|
|
v-model:open="gameCountryRankOpen"
|
|
:footer="null"
|
|
:title="gameCountryRankTitle"
|
|
width="860px"
|
|
>
|
|
<Table
|
|
:columns="gameCountryRankColumns"
|
|
:data-source="gameCountryRankRecords"
|
|
:loading="gameCountryRankLoading"
|
|
:pagination="false"
|
|
:row-key="gameCountryRankRowKey"
|
|
:scroll="{ x: gameCountryRankScrollX, y: 520 }"
|
|
size="small"
|
|
>
|
|
<template #bodyCell="{ column, index, record }">
|
|
<template v-if="column.key === 'rank'">
|
|
<span class="number-cell">{{ index + 1 }}</span>
|
|
</template>
|
|
<template v-else-if="column.key === 'consumeAmount'">
|
|
<span class="number-cell">{{ formatAmount(record.consumeAmount) }}</span>
|
|
</template>
|
|
<template v-else-if="column.key === 'countryName'">
|
|
{{ record.countryName || record.countryCode || '-' }}
|
|
</template>
|
|
</template>
|
|
</Table>
|
|
</Modal>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.app-container-datav {
|
|
background: #111827;
|
|
min-height: calc(100vh - 120px);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.datav-shell {
|
|
background:
|
|
linear-gradient(180deg, rgba(15, 23, 42, 0.96), rgba(17, 24, 39, 0.98)),
|
|
#111827;
|
|
color: #f8fafc;
|
|
min-height: inherit;
|
|
padding: 20px;
|
|
}
|
|
|
|
.datav-shell--fullscreen {
|
|
min-height: 100vh;
|
|
padding: 24px;
|
|
}
|
|
|
|
.datav-header {
|
|
align-items: center;
|
|
display: flex;
|
|
gap: 16px;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.datav-header__center {
|
|
display: flex;
|
|
flex: 1;
|
|
justify-content: center;
|
|
}
|
|
|
|
.datav-title {
|
|
color: #f8fafc;
|
|
font-size: 28px;
|
|
font-weight: 700;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.datav-subtitle {
|
|
color: #94a3b8;
|
|
font-size: 13px;
|
|
line-height: 1.8;
|
|
}
|
|
|
|
.datav-content {
|
|
display: grid;
|
|
gap: 16px;
|
|
margin-top: 18px;
|
|
}
|
|
|
|
.panel {
|
|
background: rgba(30, 41, 59, 0.82);
|
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.filter-panel {
|
|
padding: 14px;
|
|
}
|
|
|
|
.metric-section-grid {
|
|
display: grid;
|
|
gap: 16px;
|
|
grid-template-columns: minmax(0, 3fr) minmax(260px, 2fr) minmax(260px, 2fr);
|
|
}
|
|
|
|
.metric-section {
|
|
padding: 14px;
|
|
}
|
|
|
|
.metric-section__title {
|
|
color: #2dd4bf;
|
|
font-size: 15px;
|
|
font-weight: 700;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.metric-card-grid {
|
|
display: grid;
|
|
gap: 10px;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
}
|
|
|
|
.metric-card-grid--app {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
|
|
.metric-card-grid--arpu {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
|
|
.metric-card-grid--recharge {
|
|
grid-template-columns: minmax(180px, 260px);
|
|
}
|
|
|
|
.metric-card {
|
|
background: #111827;
|
|
border: 1px solid rgba(45, 212, 191, 0.18);
|
|
border-radius: 8px;
|
|
min-height: 76px;
|
|
padding: 12px;
|
|
}
|
|
|
|
.metric-card--clickable {
|
|
cursor: pointer;
|
|
transition: border-color 0.16s ease, transform 0.16s ease;
|
|
}
|
|
|
|
.metric-card--clickable:focus-visible,
|
|
.metric-card--clickable:hover {
|
|
border-color: rgba(45, 212, 191, 0.72);
|
|
outline: none;
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.metric-card__label {
|
|
color: #cbd5e1;
|
|
font-size: 12px;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.metric-card__value {
|
|
color: #f8fafc;
|
|
font-size: 20px;
|
|
font-weight: 700;
|
|
line-height: 1.6;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.summary-table-panel {
|
|
width: 100%;
|
|
}
|
|
|
|
.dashboard-panel {
|
|
padding: 12px;
|
|
}
|
|
|
|
.dashboard-tabs {
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.dashboard-tabs :deep(.ant-tabs-nav) {
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.dashboard-tabs :deep(.ant-tabs-tab) {
|
|
color: #cbd5e1;
|
|
}
|
|
|
|
.dashboard-tabs :deep(.ant-tabs-tab-active .ant-tabs-tab-btn) {
|
|
color: #2dd4bf;
|
|
}
|
|
|
|
.chart-grid {
|
|
display: grid;
|
|
gap: 12px;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
|
|
.chart-card {
|
|
background: #111827;
|
|
border: 1px solid rgba(148, 163, 184, 0.14);
|
|
border-radius: 8px;
|
|
min-height: 360px;
|
|
padding: 10px;
|
|
}
|
|
|
|
.number-cell {
|
|
color: #e2e8f0;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.game-link-button {
|
|
height: auto;
|
|
padding: 0;
|
|
white-space: normal;
|
|
}
|
|
|
|
.game-link-button :deep(span) {
|
|
overflow-wrap: anywhere;
|
|
text-align: left;
|
|
}
|
|
|
|
.summary-value-cell {
|
|
display: block;
|
|
font-weight: 700;
|
|
text-align: right;
|
|
}
|
|
|
|
.legacy-grid {
|
|
display: grid;
|
|
gap: 16px;
|
|
grid-template-columns: minmax(300px, 1fr) minmax(300px, 1fr);
|
|
}
|
|
|
|
.panel-title {
|
|
color: #2dd4bf;
|
|
font-size: 18px;
|
|
font-weight: 700;
|
|
padding: 16px 16px 8px;
|
|
}
|
|
|
|
.panel :deep(.ant-table) {
|
|
background: transparent;
|
|
}
|
|
|
|
.panel :deep(.ant-table-cell) {
|
|
background: #111827;
|
|
color: #e2e8f0;
|
|
}
|
|
|
|
.panel :deep(.ant-table-thead > tr > th) {
|
|
background: #1f2937;
|
|
color: #f8fafc;
|
|
}
|
|
|
|
.panel :deep(.ant-empty-description) {
|
|
color: #cbd5e1;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.app-container-datav {
|
|
min-height: calc(100vh - 88px);
|
|
}
|
|
|
|
.datav-shell {
|
|
padding: 14px;
|
|
}
|
|
|
|
.datav-header {
|
|
align-items: flex-start;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.datav-header__center {
|
|
justify-content: flex-start;
|
|
width: 100%;
|
|
}
|
|
|
|
.datav-title {
|
|
font-size: 24px;
|
|
}
|
|
|
|
.chart-grid,
|
|
.legacy-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.metric-section-grid,
|
|
.metric-card-grid,
|
|
.metric-card-grid--app,
|
|
.metric-card-grid--arpu {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|