数据大屏

This commit is contained in:
hy 2026-04-24 15:33:04 +08:00
parent 4f52a03dc2
commit 882d232c35
2 changed files with 180 additions and 75 deletions

View File

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

View File

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