数据大屏

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,
} 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 { useAccessStore } from '@vben/stores';
@ -15,7 +23,6 @@ import {
Button,
DateRangePicker,
Empty,
Input,
message,
Select,
Space,
@ -45,9 +52,9 @@ const dateRange = ref<[string, string] | null>([
dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
dayjs().format('YYYY-MM-DD'),
]);
const selectedCountryCodes = ref<string[]>([]);
const query = reactive({
countryKeyword: '',
periodType: 'DAY',
sysOrigin: '',
});
@ -94,6 +101,51 @@ const metricColumns = new Set([
'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[] = [
{ dataIndex: 'countryName', fixed: 'left' as const, key: 'countryName', title: '国家', width: 150 },
{ dataIndex: 'countryCode', key: 'countryCode', title: 'Code', width: 86 },
@ -113,21 +165,35 @@ const columns: any[] = [
{ align: 'right', dataIndex: 'gameProfit', key: 'gameProfit', title: '游戏利润', width: 130 },
];
const total = computed(() => dashboard.value?.total || null);
const records = computed(() => dashboard.value?.records || []);
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(() => {
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 summaryCards = computed(() => {
const item = total.value;
return [
{ label: '新增用户', value: formatInteger(item?.countryNewUser) },
{ label: '总充值', value: formatAmount(item?.totalRecharge) },
{ label: '礼物消耗', value: formatAmount(item?.giftConsume) },
{ label: '幸运礼物流水', value: formatAmount(item?.luckyGiftTotalFlow) },
{ label: '幸运礼物利润', value: formatAmount(item?.luckyGiftProfit) },
{ label: '幸运礼物利润率', value: formatPercent(resolveLuckyGiftProfitRate(item)) },
{ label: '游戏流水', value: formatAmount(item?.gameTotalFlow) },
{ label: '游戏利润', value: formatAmount(item?.gameProfit) },
];
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];
});
function hasPermission(code: string) {
@ -157,9 +223,9 @@ async function screen() {
await element.requestFullscreen();
}
function toggleVisualMode() {
async function toggleVisualMode() {
visualMode.value = !visualMode.value;
renderVisualCharts();
await renderVisualCharts();
}
function defaultRange(periodType: string): [string, string] | null {
@ -184,7 +250,6 @@ function handlePeriodChange(value: string) {
function buildParams() {
const params: Record<string, any> = {
countryKeyword: query.countryKeyword || undefined,
periodType: query.periodType,
sysOrigin: query.sysOrigin || undefined,
};
@ -267,6 +332,52 @@ function resolveLuckyGiftProfitRate(record?: null | {
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) {
if (key === 'countryNewUser') {
return formatInteger(record.countryNewUser);
@ -394,6 +505,9 @@ async function renderVisualCharts() {
return;
}
await nextTick();
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
const list = chartRecords.value;
if (list.length === 0) {
return;
@ -433,6 +547,13 @@ 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),
);
});
onMounted(() => {
document.addEventListener('fullscreenchange', syncFullscreenState);
loadDashboard();
@ -498,12 +619,17 @@ onBeforeUnmount(() => {
value-format="YYYY-MM-DD"
@change="loadDashboard"
/>
<Input
v-model:value="query.countryKeyword"
<Select
v-model:value="selectedCountryCodes"
:filter-option="filterCountry"
:max-tag-count="2"
:options="countryOptions"
allow-clear
placeholder="国家编码 / 名称"
style="width: 180px"
@press-enter="loadDashboard"
mode="multiple"
option-label-prop="label"
placeholder="国家多选"
show-search
style="width: 300px"
/>
<Button :loading="loading" type="primary" @click="loadDashboard">
查询
@ -511,32 +637,38 @@ onBeforeUnmount(() => {
</Space>
</section>
<div class="summary-grid">
<section
v-for="item in summaryCards"
:key="item.label"
class="summary-card"
<section class="panel summary-table-panel">
<Table
:columns="summaryColumns"
:data-source="summaryDataSource"
:pagination="false"
row-key="key"
:scroll="{ x: summaryScrollX }"
size="small"
>
<div class="summary-card__label">{{ item.label }}</div>
<div class="summary-card__value">{{ item.value }}</div>
</section>
</div>
<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" class="chart-canvas" />
<EchartsUI ref="userRechargeChartRef" style="height: 340px; width: 100%" />
</section>
<section class="chart-card">
<EchartsUI ref="flowChartRef" class="chart-canvas" />
<EchartsUI ref="flowChartRef" style="height: 340px; width: 100%" />
</section>
<section class="chart-card">
<EchartsUI ref="profitChartRef" class="chart-canvas" />
<EchartsUI ref="profitChartRef" style="height: 340px; width: 100%" />
</section>
<section class="chart-card">
<EchartsUI ref="luckyChartRef" class="chart-canvas" />
<EchartsUI ref="luckyChartRef" style="height: 340px; width: 100%" />
</section>
</div>
<Empty v-else />
@ -651,31 +783,8 @@ onBeforeUnmount(() => {
padding: 14px;
}
.summary-grid {
display: grid;
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;
.summary-table-panel {
width: 100%;
}
.dashboard-panel {
@ -696,16 +805,17 @@ onBeforeUnmount(() => {
padding: 10px;
}
.chart-canvas {
height: 340px;
width: 100%;
}
.number-cell {
color: #e2e8f0;
font-variant-numeric: tabular-nums;
}
.summary-value-cell {
display: block;
font-weight: 700;
text-align: right;
}
.legacy-grid {
display: grid;
gap: 16px;
@ -737,12 +847,6 @@ onBeforeUnmount(() => {
color: #cbd5e1;
}
@media (max-width: 1200px) {
.summary-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.app-container-datav {
min-height: calc(100vh - 88px);
@ -766,7 +870,6 @@ onBeforeUnmount(() => {
font-size: 24px;
}
.summary-grid,
.chart-grid,
.legacy-grid {
grid-template-columns: 1fr;

View File

@ -1,6 +1,7 @@
import { BarChart, LineChart, PieChart, RadarChart } from 'echarts/charts';
import {
DatasetComponent,
DataZoomComponent,
GridComponent,
LegendComponent,
TitleComponent,
@ -21,6 +22,7 @@ echarts.use([
PieChart,
RadarChart,
TooltipComponent,
DataZoomComponent,
GridComponent,
DatasetComponent,
TransformComponent,