数据大屏
This commit is contained in:
parent
4f52a03dc2
commit
882d232c35
@ -6,7 +6,15 @@ import type {
|
|||||||
CountryDashboardResult,
|
CountryDashboardResult,
|
||||||
} from '#/api/legacy/datav';
|
} from '#/api/legacy/datav';
|
||||||
|
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
import {
|
||||||
|
computed,
|
||||||
|
nextTick,
|
||||||
|
onBeforeUnmount,
|
||||||
|
onMounted,
|
||||||
|
reactive,
|
||||||
|
ref,
|
||||||
|
watch,
|
||||||
|
} from 'vue';
|
||||||
|
|
||||||
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
||||||
import { useAccessStore } from '@vben/stores';
|
import { useAccessStore } from '@vben/stores';
|
||||||
@ -15,7 +23,6 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
DateRangePicker,
|
DateRangePicker,
|
||||||
Empty,
|
Empty,
|
||||||
Input,
|
|
||||||
message,
|
message,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
@ -45,9 +52,9 @@ const dateRange = ref<[string, string] | null>([
|
|||||||
dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
|
dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
|
||||||
dayjs().format('YYYY-MM-DD'),
|
dayjs().format('YYYY-MM-DD'),
|
||||||
]);
|
]);
|
||||||
|
const selectedCountryCodes = ref<string[]>([]);
|
||||||
|
|
||||||
const query = reactive({
|
const query = reactive({
|
||||||
countryKeyword: '',
|
|
||||||
periodType: 'DAY',
|
periodType: 'DAY',
|
||||||
sysOrigin: '',
|
sysOrigin: '',
|
||||||
});
|
});
|
||||||
@ -94,6 +101,51 @@ const metricColumns = new Set([
|
|||||||
'totalRecharge',
|
'totalRecharge',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const summaryMetricKeys = [
|
||||||
|
'countryNewUser',
|
||||||
|
'newUserRecharge',
|
||||||
|
'dealerRecharge',
|
||||||
|
'salaryExchange',
|
||||||
|
'totalRecharge',
|
||||||
|
'giftConsume',
|
||||||
|
'luckyGiftTotalFlow',
|
||||||
|
'luckyGiftPayout',
|
||||||
|
'luckyGiftProfit',
|
||||||
|
'gameTotalFlow',
|
||||||
|
'gamePayout',
|
||||||
|
'gameProfit',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const summaryMetricDefinitions = [
|
||||||
|
{ key: 'countryNewUser', label: '新增' },
|
||||||
|
{ key: 'newUserRecharge', label: '新增充值' },
|
||||||
|
{ key: 'dealerRecharge', label: '币商充值' },
|
||||||
|
{ key: 'salaryExchange', label: '工资兑换' },
|
||||||
|
{ key: 'totalRecharge', label: '总充值' },
|
||||||
|
{ key: 'giftConsume', label: '礼物消耗' },
|
||||||
|
{ key: 'luckyGiftTotalFlow', label: '幸运礼物流水' },
|
||||||
|
{ key: 'luckyGiftPayout', label: '幸运礼物返奖' },
|
||||||
|
{ key: 'luckyGiftProfit', label: '幸运礼物利润' },
|
||||||
|
{ key: 'luckyGiftProfitRate', label: '幸运礼物利润率' },
|
||||||
|
{ key: 'gameTotalFlow', label: '游戏流水' },
|
||||||
|
{ key: 'gamePayout', label: '游戏返奖' },
|
||||||
|
{ key: 'gameProfit', label: '游戏利润' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type SummaryMetricKey = (typeof summaryMetricDefinitions)[number]['key'];
|
||||||
|
type SummaryMetric = Record<SummaryMetricKey, number>;
|
||||||
|
|
||||||
|
const summaryColumns = [
|
||||||
|
...summaryMetricDefinitions.map((item) => ({
|
||||||
|
align: 'right' as const,
|
||||||
|
dataIndex: item.key,
|
||||||
|
key: item.key,
|
||||||
|
title: item.label,
|
||||||
|
width: item.key === 'countryNewUser' ? 110 : (item.key === 'luckyGiftProfitRate' ? 160 : 140),
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
const summaryScrollX = summaryColumns.reduce((totalWidth, item) => totalWidth + item.width, 0);
|
||||||
|
|
||||||
const columns: any[] = [
|
const columns: any[] = [
|
||||||
{ dataIndex: 'countryName', fixed: 'left' as const, key: 'countryName', title: '国家', width: 150 },
|
{ dataIndex: 'countryName', fixed: 'left' as const, key: 'countryName', title: '国家', width: 150 },
|
||||||
{ dataIndex: 'countryCode', key: 'countryCode', title: 'Code', width: 86 },
|
{ dataIndex: 'countryCode', key: 'countryCode', title: 'Code', width: 86 },
|
||||||
@ -113,21 +165,35 @@ const columns: any[] = [
|
|||||||
{ align: 'right', dataIndex: 'gameProfit', key: 'gameProfit', title: '游戏利润', width: 130 },
|
{ align: 'right', dataIndex: 'gameProfit', key: 'gameProfit', title: '游戏利润', width: 130 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const total = computed(() => dashboard.value?.total || null);
|
const rawRecords = computed(() => dashboard.value?.records || []);
|
||||||
const records = 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(() => {
|
||||||
|
const selected = new Set(selectedCountryCodes.value);
|
||||||
|
if (selected.size === 0) {
|
||||||
|
return rawRecords.value;
|
||||||
|
}
|
||||||
|
return rawRecords.value.filter((item) => selected.has(String(item.countryCode || '')));
|
||||||
|
});
|
||||||
const chartRecords = computed(() => records.value.filter((item) => item.countryCode !== 'ALL'));
|
const chartRecords = computed(() => records.value.filter((item) => item.countryCode !== 'ALL'));
|
||||||
const summaryCards = computed(() => {
|
const visibleSummary = computed(() => buildVisibleSummary(records.value));
|
||||||
const item = total.value;
|
const summaryDataSource = computed(() => {
|
||||||
return [
|
const row: Record<string, string> = { key: 'summary' };
|
||||||
{ label: '新增用户', value: formatInteger(item?.countryNewUser) },
|
for (const item of summaryMetricDefinitions) {
|
||||||
{ label: '总充值', value: formatAmount(item?.totalRecharge) },
|
row[item.key] = formatSummaryValue(item.key, visibleSummary.value);
|
||||||
{ label: '礼物消耗', value: formatAmount(item?.giftConsume) },
|
}
|
||||||
{ label: '幸运礼物流水', value: formatAmount(item?.luckyGiftTotalFlow) },
|
return [row];
|
||||||
{ label: '幸运礼物利润', value: formatAmount(item?.luckyGiftProfit) },
|
|
||||||
{ label: '幸运礼物利润率', value: formatPercent(resolveLuckyGiftProfitRate(item)) },
|
|
||||||
{ label: '游戏流水', value: formatAmount(item?.gameTotalFlow) },
|
|
||||||
{ label: '游戏利润', value: formatAmount(item?.gameProfit) },
|
|
||||||
];
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function hasPermission(code: string) {
|
function hasPermission(code: string) {
|
||||||
@ -157,9 +223,9 @@ async function screen() {
|
|||||||
await element.requestFullscreen();
|
await element.requestFullscreen();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleVisualMode() {
|
async function toggleVisualMode() {
|
||||||
visualMode.value = !visualMode.value;
|
visualMode.value = !visualMode.value;
|
||||||
renderVisualCharts();
|
await renderVisualCharts();
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultRange(periodType: string): [string, string] | null {
|
function defaultRange(periodType: string): [string, string] | null {
|
||||||
@ -184,7 +250,6 @@ function handlePeriodChange(value: string) {
|
|||||||
|
|
||||||
function buildParams() {
|
function buildParams() {
|
||||||
const params: Record<string, any> = {
|
const params: Record<string, any> = {
|
||||||
countryKeyword: query.countryKeyword || undefined,
|
|
||||||
periodType: query.periodType,
|
periodType: query.periodType,
|
||||||
sysOrigin: query.sysOrigin || undefined,
|
sysOrigin: query.sysOrigin || undefined,
|
||||||
};
|
};
|
||||||
@ -267,6 +332,52 @@ function resolveLuckyGiftProfitRate(record?: null | {
|
|||||||
return (Number(record.luckyGiftProfit || 0) / flow) * 100;
|
return (Number(record.luckyGiftProfit || 0) / flow) * 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptySummary(): SummaryMetric {
|
||||||
|
return {
|
||||||
|
countryNewUser: 0,
|
||||||
|
dealerRecharge: 0,
|
||||||
|
gamePayout: 0,
|
||||||
|
gameProfit: 0,
|
||||||
|
gameTotalFlow: 0,
|
||||||
|
giftConsume: 0,
|
||||||
|
luckyGiftPayout: 0,
|
||||||
|
luckyGiftProfit: 0,
|
||||||
|
luckyGiftProfitRate: 0,
|
||||||
|
luckyGiftTotalFlow: 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(item[key] as number | string);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summary.luckyGiftProfitRate = resolveLuckyGiftProfitRate(summary);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSummaryValue(key: SummaryMetricKey, summary: SummaryMetric) {
|
||||||
|
if (key === 'countryNewUser') {
|
||||||
|
return formatInteger(summary[key]);
|
||||||
|
}
|
||||||
|
if (key === 'luckyGiftProfitRate') {
|
||||||
|
return formatPercent(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) {
|
function tableCellText(record: Record<string, any>, key: string) {
|
||||||
if (key === 'countryNewUser') {
|
if (key === 'countryNewUser') {
|
||||||
return formatInteger(record.countryNewUser);
|
return formatInteger(record.countryNewUser);
|
||||||
@ -394,6 +505,9 @@ async function renderVisualCharts() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
requestAnimationFrame(() => resolve());
|
||||||
|
});
|
||||||
const list = chartRecords.value;
|
const list = chartRecords.value;
|
||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
return;
|
return;
|
||||||
@ -433,6 +547,13 @@ watch([records, visualMode], () => {
|
|||||||
renderVisualCharts();
|
renderVisualCharts();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(countryOptions, (options) => {
|
||||||
|
const optionValues = new Set(options.map((item) => item.value));
|
||||||
|
selectedCountryCodes.value = selectedCountryCodes.value.filter((code) =>
|
||||||
|
optionValues.has(code),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('fullscreenchange', syncFullscreenState);
|
document.addEventListener('fullscreenchange', syncFullscreenState);
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
@ -498,12 +619,17 @@ onBeforeUnmount(() => {
|
|||||||
value-format="YYYY-MM-DD"
|
value-format="YYYY-MM-DD"
|
||||||
@change="loadDashboard"
|
@change="loadDashboard"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Select
|
||||||
v-model:value="query.countryKeyword"
|
v-model:value="selectedCountryCodes"
|
||||||
|
:filter-option="filterCountry"
|
||||||
|
:max-tag-count="2"
|
||||||
|
:options="countryOptions"
|
||||||
allow-clear
|
allow-clear
|
||||||
placeholder="国家编码 / 名称"
|
mode="multiple"
|
||||||
style="width: 180px"
|
option-label-prop="label"
|
||||||
@press-enter="loadDashboard"
|
placeholder="国家多选"
|
||||||
|
show-search
|
||||||
|
style="width: 300px"
|
||||||
/>
|
/>
|
||||||
<Button :loading="loading" type="primary" @click="loadDashboard">
|
<Button :loading="loading" type="primary" @click="loadDashboard">
|
||||||
查询
|
查询
|
||||||
@ -511,32 +637,38 @@ onBeforeUnmount(() => {
|
|||||||
</Space>
|
</Space>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="summary-grid">
|
<section class="panel summary-table-panel">
|
||||||
<section
|
<Table
|
||||||
v-for="item in summaryCards"
|
:columns="summaryColumns"
|
||||||
:key="item.label"
|
:data-source="summaryDataSource"
|
||||||
class="summary-card"
|
:pagination="false"
|
||||||
|
row-key="key"
|
||||||
|
:scroll="{ x: summaryScrollX }"
|
||||||
|
size="small"
|
||||||
>
|
>
|
||||||
<div class="summary-card__label">{{ item.label }}</div>
|
<template #bodyCell="{ column, record }">
|
||||||
<div class="summary-card__value">{{ item.value }}</div>
|
<span class="number-cell summary-value-cell">
|
||||||
</section>
|
{{ record[String(column.dataIndex)] }}
|
||||||
</div>
|
</span>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="panel dashboard-panel">
|
<section class="panel dashboard-panel">
|
||||||
<Spin :spinning="loading">
|
<Spin :spinning="loading">
|
||||||
<template v-if="visualMode">
|
<template v-if="visualMode">
|
||||||
<div v-if="chartRecords.length > 0" class="chart-grid">
|
<div v-if="chartRecords.length > 0" class="chart-grid">
|
||||||
<section class="chart-card">
|
<section class="chart-card">
|
||||||
<EchartsUI ref="userRechargeChartRef" class="chart-canvas" />
|
<EchartsUI ref="userRechargeChartRef" style="height: 340px; width: 100%" />
|
||||||
</section>
|
</section>
|
||||||
<section class="chart-card">
|
<section class="chart-card">
|
||||||
<EchartsUI ref="flowChartRef" class="chart-canvas" />
|
<EchartsUI ref="flowChartRef" style="height: 340px; width: 100%" />
|
||||||
</section>
|
</section>
|
||||||
<section class="chart-card">
|
<section class="chart-card">
|
||||||
<EchartsUI ref="profitChartRef" class="chart-canvas" />
|
<EchartsUI ref="profitChartRef" style="height: 340px; width: 100%" />
|
||||||
</section>
|
</section>
|
||||||
<section class="chart-card">
|
<section class="chart-card">
|
||||||
<EchartsUI ref="luckyChartRef" class="chart-canvas" />
|
<EchartsUI ref="luckyChartRef" style="height: 340px; width: 100%" />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<Empty v-else />
|
<Empty v-else />
|
||||||
@ -651,31 +783,8 @@ onBeforeUnmount(() => {
|
|||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-grid {
|
.summary-table-panel {
|
||||||
display: grid;
|
width: 100%;
|
||||||
gap: 12px;
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card {
|
|
||||||
background: #18212f;
|
|
||||||
border: 1px solid rgba(45, 212, 191, 0.22);
|
|
||||||
border-radius: 8px;
|
|
||||||
min-height: 92px;
|
|
||||||
padding: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card__label {
|
|
||||||
color: #cbd5e1;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card__value {
|
|
||||||
color: #f8fafc;
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.5;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-panel {
|
.dashboard-panel {
|
||||||
@ -696,16 +805,17 @@ onBeforeUnmount(() => {
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-canvas {
|
|
||||||
height: 340px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.number-cell {
|
.number-cell {
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.summary-value-cell {
|
||||||
|
display: block;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
.legacy-grid {
|
.legacy-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
@ -737,12 +847,6 @@ onBeforeUnmount(() => {
|
|||||||
color: #cbd5e1;
|
color: #cbd5e1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
|
||||||
.summary-grid {
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.app-container-datav {
|
.app-container-datav {
|
||||||
min-height: calc(100vh - 88px);
|
min-height: calc(100vh - 88px);
|
||||||
@ -766,7 +870,6 @@ onBeforeUnmount(() => {
|
|||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-grid,
|
|
||||||
.chart-grid,
|
.chart-grid,
|
||||||
.legacy-grid {
|
.legacy-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { BarChart, LineChart, PieChart, RadarChart } from 'echarts/charts';
|
import { BarChart, LineChart, PieChart, RadarChart } from 'echarts/charts';
|
||||||
import {
|
import {
|
||||||
DatasetComponent,
|
DatasetComponent,
|
||||||
|
DataZoomComponent,
|
||||||
GridComponent,
|
GridComponent,
|
||||||
LegendComponent,
|
LegendComponent,
|
||||||
TitleComponent,
|
TitleComponent,
|
||||||
@ -21,6 +22,7 @@ echarts.use([
|
|||||||
PieChart,
|
PieChart,
|
||||||
RadarChart,
|
RadarChart,
|
||||||
TooltipComponent,
|
TooltipComponent,
|
||||||
|
DataZoomComponent,
|
||||||
GridComponent,
|
GridComponent,
|
||||||
DatasetComponent,
|
DatasetComponent,
|
||||||
TransformComponent,
|
TransformComponent,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user