数据大屏

This commit is contained in:
zhx 2026-05-06 18:23:49 +08:00
parent 9d20c3a789
commit 34a84375e3
3 changed files with 252 additions and 12 deletions

View File

@ -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',

View File

@ -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;

View File

@ -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;