数据大屏
This commit is contained in:
parent
9d20c3a789
commit
34a84375e3
@ -15,6 +15,7 @@ export interface CountryDashboardMetric {
|
||||
d30RetentionUser: number;
|
||||
dailyActiveUser: number;
|
||||
dealerRecharge: number | string;
|
||||
googleRecharge: number | string;
|
||||
gamePayoutRate: number | string;
|
||||
gamePayout: number | string;
|
||||
gameProfit: number | string;
|
||||
@ -30,6 +31,7 @@ export interface CountryDashboardMetric {
|
||||
luckyGiftTotalFlow: number | string;
|
||||
luckyGiftUser: number;
|
||||
luckyGiftUserRate: number | string;
|
||||
mifapayRecharge: number | string;
|
||||
newUserRecharge: number | string;
|
||||
officialRecharge: number | string;
|
||||
periodKey: string;
|
||||
@ -47,12 +49,40 @@ export interface CountryDashboardResult {
|
||||
total: CountryDashboardMetric;
|
||||
}
|
||||
|
||||
export interface CountryDashboardRechargeDetail {
|
||||
amount: number | string;
|
||||
coinQuantity: number | string;
|
||||
countryCode: string;
|
||||
countryName: string;
|
||||
createTime: string;
|
||||
recordId: string;
|
||||
remark: string;
|
||||
sourceChannel: string;
|
||||
sourceType: string;
|
||||
userAccount: string;
|
||||
userId: number | string;
|
||||
userNickname: string;
|
||||
}
|
||||
|
||||
export async function countryDashboard(params: Record<string, any> = {}) {
|
||||
return requestClient.get<CountryDashboardResult>('/datav/country-dashboard', {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function countryDashboardRechargeDetails(
|
||||
params: Record<string, any> = {},
|
||||
) {
|
||||
return requestClient.get<{
|
||||
current: number;
|
||||
records: CountryDashboardRechargeDetail[];
|
||||
size: number;
|
||||
total: number;
|
||||
}>('/datav/country-dashboard/recharge-details', {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function latestActiveUserCountryCode(params: Record<string, any> = {}) {
|
||||
return requestClient.get<Array<Record<string, any>>>(
|
||||
'/datav/active/user-country-code',
|
||||
|
||||
@ -3,6 +3,7 @@ import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||
|
||||
import type {
|
||||
CountryDashboardMetric,
|
||||
CountryDashboardRechargeDetail,
|
||||
CountryDashboardResult,
|
||||
} from '#/api/legacy/datav';
|
||||
|
||||
@ -24,15 +25,21 @@ import {
|
||||
DateRangePicker,
|
||||
Empty,
|
||||
message,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
TabPane,
|
||||
Table,
|
||||
Tabs,
|
||||
Tooltip,
|
||||
} from 'antdv-next';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { countryDashboard } from '#/api/legacy/datav';
|
||||
import {
|
||||
countryDashboard,
|
||||
countryDashboardRechargeDetails,
|
||||
} from '#/api/legacy/datav';
|
||||
import SysOriginSelect from '#/components/sys-origin-select.vue';
|
||||
import { getAllowedSysOrigins } from '#/views/system/shared';
|
||||
|
||||
@ -48,6 +55,7 @@ const fullscreen = ref(false);
|
||||
const visualMode = ref(false);
|
||||
const loading = ref(false);
|
||||
const dashboard = ref<CountryDashboardResult | null>(null);
|
||||
const activeTableTab = ref<'overview' | 'recharge'>('overview');
|
||||
const arpuDashboards = ref<{
|
||||
day: CountryDashboardResult | null;
|
||||
threeDay: CountryDashboardResult | null;
|
||||
@ -62,6 +70,14 @@ const dateRange = ref<[string, string] | null>([
|
||||
dayjs().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 query = reactive({
|
||||
periodType: 'DAY',
|
||||
@ -101,6 +117,7 @@ const metricColumns = new Set([
|
||||
'd30RetentionRate',
|
||||
'dailyActiveUser',
|
||||
'dealerRecharge',
|
||||
'googleRecharge',
|
||||
'gamePayout',
|
||||
'gamePayoutRate',
|
||||
'gameProfit',
|
||||
@ -115,6 +132,7 @@ const metricColumns = new Set([
|
||||
'luckyGiftTotalFlow',
|
||||
'luckyGiftUser',
|
||||
'luckyGiftUserRate',
|
||||
'mifapayRecharge',
|
||||
'newUserRecharge',
|
||||
'salaryExchange',
|
||||
'totalRecharge',
|
||||
@ -143,6 +161,8 @@ const summaryMetricKeys = [
|
||||
'dailyActiveUser',
|
||||
'newUserRecharge',
|
||||
'dealerRecharge',
|
||||
'mifapayRecharge',
|
||||
'googleRecharge',
|
||||
'salaryExchange',
|
||||
'totalRecharge',
|
||||
'giftConsume',
|
||||
@ -167,6 +187,8 @@ const summaryMetricDefinitions = [
|
||||
{ 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: '礼物消耗' },
|
||||
@ -202,7 +224,7 @@ const summaryColumns = [
|
||||
];
|
||||
const summaryScrollX = summaryColumns.reduce((totalWidth, item) => totalWidth + item.width, 0);
|
||||
|
||||
const columns: any[] = [
|
||||
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 },
|
||||
@ -213,6 +235,8 @@ const columns: any[] = [
|
||||
{ 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 },
|
||||
@ -234,7 +258,47 @@ const columns: any[] = [
|
||||
sortDirections: ['ascend', 'descend'],
|
||||
sorter: compareTableColumn(String(column.dataIndex)),
|
||||
}));
|
||||
const tableScrollX = columns.reduce((totalWidth, item) => totalWidth + Number(item.width || 0), 0);
|
||||
|
||||
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 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(() => {
|
||||
@ -290,6 +354,10 @@ const arpuCards = computed(() => {
|
||||
{ label: '筛选 ARPU', value: formatAmount(calcArpu(visibleSummary.value)) },
|
||||
];
|
||||
});
|
||||
const totalRechargeCard = computed(() => ({
|
||||
label: '总充值',
|
||||
value: formatAmount(visibleSummary.value.totalRecharge),
|
||||
}));
|
||||
|
||||
function hasPermission(code: string) {
|
||||
const codes = accessCodes.value;
|
||||
@ -400,6 +468,54 @@ async function loadDashboard() {
|
||||
renderVisualCharts();
|
||||
}
|
||||
|
||||
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 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 toAmount(value?: null | number | string) {
|
||||
const number = Number(value || 0);
|
||||
return Number.isFinite(number) ? number : 0;
|
||||
@ -522,6 +638,7 @@ function emptySummary(): SummaryMetric {
|
||||
d30RetentionUser: 0,
|
||||
dailyActiveUser: 0,
|
||||
dealerRecharge: 0,
|
||||
googleRecharge: 0,
|
||||
gamePayout: 0,
|
||||
gamePayoutRate: 0,
|
||||
gameProfit: 0,
|
||||
@ -536,6 +653,7 @@ function emptySummary(): SummaryMetric {
|
||||
luckyGiftTotalFlow: 0,
|
||||
luckyGiftUser: 0,
|
||||
luckyGiftUserRate: 0,
|
||||
mifapayRecharge: 0,
|
||||
newUserRecharge: 0,
|
||||
salaryExchange: 0,
|
||||
totalRecharge: 0,
|
||||
@ -738,6 +856,8 @@ async function renderVisualCharts() {
|
||||
amountSeries('新增用户', 'countryNewUser', list),
|
||||
amountSeries('新增充值', 'newUserRecharge', list),
|
||||
amountSeries('币商充值', 'dealerRecharge', list),
|
||||
amountSeries('MifaPay 充值', 'mifapayRecharge', list),
|
||||
amountSeries('Google 充值', 'googleRecharge', list),
|
||||
amountSeries('工资兑换', 'salaryExchange', list),
|
||||
amountSeries('总充值', 'totalRecharge', list),
|
||||
]));
|
||||
@ -859,6 +979,22 @@ onBeforeUnmount(() => {
|
||||
</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>
|
||||
@ -938,9 +1074,13 @@ onBeforeUnmount(() => {
|
||||
<Empty v-else />
|
||||
</template>
|
||||
<template v-else>
|
||||
<Tabs v-model:activeKey="activeTableTab" class="dashboard-tabs" size="small">
|
||||
<TabPane key="overview" tab="指标总览" />
|
||||
<TabPane key="recharge" tab="充值相关数据" />
|
||||
</Tabs>
|
||||
<Table
|
||||
v-if="records.length > 0"
|
||||
:columns="columns"
|
||||
:columns="activeColumns"
|
||||
:data-source="records"
|
||||
:pagination="{ pageSize: 20, showSizeChanger: true }"
|
||||
:row-key="rowKey"
|
||||
@ -979,6 +1119,48 @@ onBeforeUnmount(() => {
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -1078,6 +1260,10 @@ onBeforeUnmount(() => {
|
||||
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);
|
||||
@ -1086,6 +1272,18 @@ onBeforeUnmount(() => {
|
||||
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;
|
||||
@ -1108,6 +1306,22 @@ onBeforeUnmount(() => {
|
||||
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;
|
||||
|
||||
@ -56,6 +56,7 @@ const query = reactive({
|
||||
const columns = [
|
||||
{ dataIndex: 'sysOrigin', key: 'sysOrigin', title: '系统', width: 100 },
|
||||
{ dataIndex: 'userProfile', key: 'userProfile', title: '用户', width: 220 },
|
||||
{ dataIndex: 'shortId', key: 'shortId', title: '短ID', width: 120 },
|
||||
{ dataIndex: 'regionName', key: 'regionName', title: '区域', width: 120 },
|
||||
{ dataIndex: 'bdQuantity', key: 'bdQuantity', title: 'BD数量', width: 100 },
|
||||
{ dataIndex: 'contact', key: 'contact', title: '联系方式', width: 180 },
|
||||
@ -181,9 +182,6 @@ loadData(true);
|
||||
<div class="user-info">
|
||||
<div class="user-name-row">
|
||||
<span>{{ record.userProfile?.userNickname || '-' }}</span>
|
||||
<span class="user-short-id">
|
||||
短ID:{{ record.userProfile?.actualAccount || '-' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="user-sub">
|
||||
ID:{{ record.userProfile?.id || '-' }}
|
||||
@ -191,6 +189,9 @@ loadData(true);
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'shortId'">
|
||||
{{ record.userProfile?.actualAccount || record.userProfile?.account || '-' }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'contact'">
|
||||
{{ canEdit ? record.contact || '-' : '-' }}
|
||||
</template>
|
||||
@ -283,11 +284,6 @@ loadData(true);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.user-short-id {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-sub {
|
||||
color: #64748b;
|
||||
margin-top: 4px;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user