数据大屏
This commit is contained in:
parent
4f52a03dc2
commit
882d232c35
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user