feat: enhance country config and dashboard charts
This commit is contained in:
parent
ccf7520107
commit
83ed9a1c6a
@ -214,6 +214,10 @@ export async function updateSysCountryCode(data: Record<string, any>) {
|
||||
return requestClient.put('/sys/country/code', data);
|
||||
}
|
||||
|
||||
export async function addSysCountryCode(data: Record<string, any>) {
|
||||
return requestClient.post('/sys/country/code', data);
|
||||
}
|
||||
|
||||
export async function refundAnchorTrackRecordPage(params: Record<string, any>) {
|
||||
return requestClient.get<LegacyPageResult<Record<string, any>>>(
|
||||
'/refund-anchor-track-record/page',
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
simpleUploadFile,
|
||||
} from '#/api/legacy/oss';
|
||||
import {
|
||||
addSysCountryCode,
|
||||
pageSysCountryCode,
|
||||
updateSysCountryCode,
|
||||
} from '#/api/legacy/system';
|
||||
@ -23,6 +24,7 @@ import {
|
||||
Input,
|
||||
Modal,
|
||||
Pagination,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
message,
|
||||
@ -37,6 +39,7 @@ const formOpen = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const uploadLoading = ref(false);
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
const formMode = ref<'create' | 'edit'>('edit');
|
||||
|
||||
const query = reactive<Record<string, any>>({
|
||||
aliasName: '',
|
||||
@ -47,7 +50,10 @@ const query = reactive<Record<string, any>>({
|
||||
|
||||
const form = reactive<Record<string, any>>({
|
||||
aliasName: '',
|
||||
alphaThree: '',
|
||||
alphaTwo: '',
|
||||
countryName: '',
|
||||
countryNumeric: '',
|
||||
countryCodeAliasesInput: '',
|
||||
id: '',
|
||||
nationalFlag: '',
|
||||
@ -59,6 +65,8 @@ const form = reactive<Record<string, any>>({
|
||||
|
||||
const columns: any[] = [
|
||||
{ dataIndex: 'countryName', key: 'countryName', title: '国家名称', width: 200 },
|
||||
{ dataIndex: 'alphaTwo', key: 'alphaTwo', title: '二字码', width: 100 },
|
||||
{ dataIndex: 'alphaThree', key: 'alphaThree', title: '三字码', width: 100 },
|
||||
{ dataIndex: 'aliasName', key: 'aliasName', title: '别名', width: 160 },
|
||||
{ dataIndex: 'countryCodeAliases', key: 'countryCodeAliases', title: '国家码集合', width: 220 },
|
||||
{ dataIndex: 'nationalFlag', key: 'nationalFlag', title: '国旗', width: 120 },
|
||||
@ -94,8 +102,12 @@ function handlePageChange(page: number, pageSize: number) {
|
||||
}
|
||||
|
||||
function openEdit(record: Record<string, any>) {
|
||||
formMode.value = 'edit';
|
||||
form.aliasName = record.aliasName || '';
|
||||
form.alphaThree = record.alphaThree || '';
|
||||
form.alphaTwo = record.alphaTwo || '';
|
||||
form.countryName = record.countryName || '';
|
||||
form.countryNumeric = record.countryNumeric || '';
|
||||
form.countryCodeAliasesInput = Array.isArray(record.countryCodeAliases)
|
||||
? record.countryCodeAliases.join(',')
|
||||
: '';
|
||||
@ -108,6 +120,27 @@ function openEdit(record: Record<string, any>) {
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.aliasName = '';
|
||||
form.alphaThree = '';
|
||||
form.alphaTwo = '';
|
||||
form.countryName = '';
|
||||
form.countryNumeric = '';
|
||||
form.countryCodeAliasesInput = '';
|
||||
form.id = '';
|
||||
form.nationalFlag = '';
|
||||
form.open = false;
|
||||
form.phonePrefix = '';
|
||||
form.sort = '';
|
||||
form.top = 0;
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
resetForm();
|
||||
formMode.value = 'create';
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
function pickFlag() {
|
||||
fileInputRef.value?.click();
|
||||
}
|
||||
@ -128,13 +161,18 @@ async function uploadFlag(event: Event) {
|
||||
}
|
||||
|
||||
async function saveForm() {
|
||||
if (!form.id) {
|
||||
return;
|
||||
}
|
||||
if (!form.nationalFlag) {
|
||||
message.warning('请上传国旗');
|
||||
return;
|
||||
}
|
||||
if (!String(form.alphaTwo || '').trim()) {
|
||||
message.warning('请填写国家二字码');
|
||||
return;
|
||||
}
|
||||
if (!String(form.alphaThree || '').trim()) {
|
||||
message.warning('请填写国家三字码');
|
||||
return;
|
||||
}
|
||||
if (!String(form.countryName || '').trim()) {
|
||||
message.warning('请填写国家名称');
|
||||
return;
|
||||
@ -151,15 +189,26 @@ async function saveForm() {
|
||||
try {
|
||||
const payload: Record<string, any> = {
|
||||
...form,
|
||||
alphaThree: String(form.alphaThree || '').trim().toUpperCase(),
|
||||
alphaTwo: String(form.alphaTwo || '').trim().toUpperCase(),
|
||||
countryNumeric: form.countryNumeric === '' ? undefined : Number(form.countryNumeric),
|
||||
countryCodeAliases: String(form.countryCodeAliasesInput || '')
|
||||
.split(',')
|
||||
.map((item) => item.trim().toUpperCase())
|
||||
.filter(Boolean),
|
||||
phonePrefix: Number(form.phonePrefix),
|
||||
sort: Number(form.sort),
|
||||
};
|
||||
delete payload.countryCodeAliasesInput;
|
||||
await updateSysCountryCode(payload);
|
||||
if (formMode.value === 'create') {
|
||||
delete payload.id;
|
||||
await addSysCountryCode(payload);
|
||||
} else {
|
||||
await updateSysCountryCode(payload);
|
||||
}
|
||||
message.success('保存成功');
|
||||
formOpen.value = false;
|
||||
resetForm();
|
||||
await loadData();
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
@ -192,6 +241,7 @@ loadData(true);
|
||||
style="width: 220px"
|
||||
/>
|
||||
<Button :loading="loading" type="primary" @click="handleSearch">搜索</Button>
|
||||
<Button type="primary" @click="openCreate">新增</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
@ -274,7 +324,7 @@ loadData(true);
|
||||
:confirm-loading="submitLoading"
|
||||
:open="formOpen"
|
||||
destroy-on-close
|
||||
title="修改"
|
||||
:title="formMode === 'create' ? '新增' : '修改'"
|
||||
@cancel="formOpen = false"
|
||||
@ok="saveForm"
|
||||
>
|
||||
@ -295,6 +345,15 @@ loadData(true);
|
||||
<FormItem label="国家名称">
|
||||
<Input v-model:value="form.countryName" />
|
||||
</FormItem>
|
||||
<FormItem label="国家二字码">
|
||||
<Input v-model:value="form.alphaTwo" :maxlength="2" placeholder="例如:SA" />
|
||||
</FormItem>
|
||||
<FormItem label="国家三字码">
|
||||
<Input v-model:value="form.alphaThree" :maxlength="3" placeholder="例如:SAU" />
|
||||
</FormItem>
|
||||
<FormItem label="数字码">
|
||||
<Input v-model:value="form.countryNumeric" placeholder="例如:682" />
|
||||
</FormItem>
|
||||
<FormItem label="别名">
|
||||
<Input v-model:value="form.aliasName" />
|
||||
</FormItem>
|
||||
@ -307,6 +366,9 @@ loadData(true);
|
||||
<FormItem label="权重序号">
|
||||
<Input v-model:value="form.sort" />
|
||||
</FormItem>
|
||||
<FormItem label="开放状态">
|
||||
<Switch v-model:checked="form.open" checked-children="已开放" un-checked-children="未开放" />
|
||||
</FormItem>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Page>
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||
|
||||
import type {
|
||||
CountryDashboardMetric,
|
||||
CountryDashboardResult,
|
||||
} from '#/api/legacy/datav';
|
||||
|
||||
import { computed, 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';
|
||||
|
||||
import {
|
||||
@ -34,6 +38,7 @@ const accessStore = useAccessStore();
|
||||
|
||||
const datavFullscreen = ref<HTMLElement>();
|
||||
const fullscreen = ref(false);
|
||||
const visualMode = ref(false);
|
||||
const loading = ref(false);
|
||||
const dashboard = ref<CountryDashboardResult | null>(null);
|
||||
const dateRange = ref<[string, string] | null>([
|
||||
@ -47,6 +52,16 @@ const query = reactive({
|
||||
sysOrigin: '',
|
||||
});
|
||||
|
||||
const userRechargeChartRef = ref<EchartsUIType>();
|
||||
const flowChartRef = ref<EchartsUIType>();
|
||||
const profitChartRef = ref<EchartsUIType>();
|
||||
const luckyChartRef = ref<EchartsUIType>();
|
||||
const { renderEcharts: renderUserRechargeChart } = useEcharts(userRechargeChartRef);
|
||||
const { renderEcharts: renderFlowChart } = useEcharts(flowChartRef);
|
||||
const { renderEcharts: renderProfitChart } = useEcharts(profitChartRef);
|
||||
const { renderEcharts: renderLuckyChart } = useEcharts(luckyChartRef);
|
||||
type ChartOption = Parameters<typeof renderUserRechargeChart>[0];
|
||||
|
||||
const accessCodes = computed(() => accessStore.accessCodes || []);
|
||||
const sysOriginOptions = computed(() => {
|
||||
const options = getAllowedSysOrigins(accessCodes.value);
|
||||
@ -100,6 +115,7 @@ const columns: any[] = [
|
||||
|
||||
const total = computed(() => dashboard.value?.total || null);
|
||||
const records = computed(() => dashboard.value?.records || []);
|
||||
const chartRecords = computed(() => records.value.filter((item) => item.countryCode !== 'ALL'));
|
||||
const summaryCards = computed(() => {
|
||||
const item = total.value;
|
||||
return [
|
||||
@ -141,6 +157,11 @@ async function screen() {
|
||||
await element.requestFullscreen();
|
||||
}
|
||||
|
||||
function toggleVisualMode() {
|
||||
visualMode.value = !visualMode.value;
|
||||
renderVisualCharts();
|
||||
}
|
||||
|
||||
function defaultRange(periodType: string): [string, string] | null {
|
||||
const now = dayjs();
|
||||
if (periodType === 'ALL') {
|
||||
@ -188,6 +209,12 @@ async function loadDashboard() {
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
renderVisualCharts();
|
||||
}
|
||||
|
||||
function toAmount(value?: null | number | string) {
|
||||
const number = Number(value || 0);
|
||||
return Number.isFinite(number) ? number : 0;
|
||||
}
|
||||
|
||||
function formatInteger(value?: null | number | string) {
|
||||
@ -254,6 +281,144 @@ function rowKey(record: Record<string, any>) {
|
||||
return `${record.countryCode}-${record.periodKey}`;
|
||||
}
|
||||
|
||||
function chartCategory(record: CountryDashboardMetric) {
|
||||
return `${record.periodName || '-'}\n${record.countryName || record.countryCode || '-'}`;
|
||||
}
|
||||
|
||||
function amountSeries(name: string, dataIndex: keyof CountryDashboardMetric, list: CountryDashboardMetric[]) {
|
||||
return {
|
||||
data: list.map((item) => toAmount(item[dataIndex] as number | string)),
|
||||
emphasis: { focus: 'series' },
|
||||
name,
|
||||
type: 'bar',
|
||||
};
|
||||
}
|
||||
|
||||
function lineSeries(name: string, dataIndex: keyof CountryDashboardMetric, list: CountryDashboardMetric[]) {
|
||||
return {
|
||||
data: list.map((item) => toAmount(item[dataIndex] as number | string)),
|
||||
emphasis: { focus: 'series' },
|
||||
name,
|
||||
smooth: true,
|
||||
type: 'line',
|
||||
};
|
||||
}
|
||||
|
||||
function valueYAxis(labelFormatter?: string) {
|
||||
return {
|
||||
axisLabel: {
|
||||
color: '#94a3b8',
|
||||
formatter: labelFormatter,
|
||||
},
|
||||
splitLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.16)' } },
|
||||
type: 'value' as const,
|
||||
};
|
||||
}
|
||||
|
||||
function baseChartOption(title: string, list: CountryDashboardMetric[], series: any[]): ChartOption {
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
dataZoom: [
|
||||
{ bottom: 6, height: 18, type: 'slider' },
|
||||
{ type: 'inside' },
|
||||
],
|
||||
grid: {
|
||||
bottom: 58,
|
||||
containLabel: true,
|
||||
left: 16,
|
||||
right: 20,
|
||||
top: 74,
|
||||
},
|
||||
legend: {
|
||||
textStyle: { color: '#cbd5e1' },
|
||||
top: 30,
|
||||
type: 'scroll',
|
||||
},
|
||||
series,
|
||||
textStyle: {
|
||||
color: '#e2e8f0',
|
||||
},
|
||||
title: {
|
||||
left: 16,
|
||||
text: title,
|
||||
textStyle: {
|
||||
color: '#f8fafc',
|
||||
fontSize: 15,
|
||||
fontWeight: 700,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
},
|
||||
xAxis: {
|
||||
axisLabel: {
|
||||
color: '#94a3b8',
|
||||
interval: 0,
|
||||
},
|
||||
axisLine: { lineStyle: { color: '#334155' } },
|
||||
data: list.map((item) => chartCategory(item)),
|
||||
type: 'category',
|
||||
},
|
||||
yAxis: valueYAxis(),
|
||||
};
|
||||
}
|
||||
|
||||
function luckyChartOption(list: CountryDashboardMetric[]): ChartOption {
|
||||
const option = baseChartOption('幸运礼物结构与利润率', list, [
|
||||
amountSeries('幸运礼物流水', 'luckyGiftTotalFlow', list),
|
||||
amountSeries('幸运礼物返奖', 'luckyGiftPayout', list),
|
||||
amountSeries('幸运礼物利润', 'luckyGiftProfit', list),
|
||||
{
|
||||
...lineSeries('幸运礼物利润率', 'luckyGiftProfitRate', list),
|
||||
yAxisIndex: 1,
|
||||
},
|
||||
]);
|
||||
return {
|
||||
...option,
|
||||
yAxis: [
|
||||
valueYAxis(),
|
||||
{
|
||||
axisLabel: {
|
||||
color: '#94a3b8',
|
||||
formatter: '{value}%',
|
||||
},
|
||||
splitLine: { show: false },
|
||||
type: 'value',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function renderVisualCharts() {
|
||||
if (!visualMode.value || loading.value) {
|
||||
return;
|
||||
}
|
||||
await nextTick();
|
||||
const list = chartRecords.value;
|
||||
if (list.length === 0) {
|
||||
return;
|
||||
}
|
||||
renderUserRechargeChart(baseChartOption('新增用户与充值', list, [
|
||||
amountSeries('新增用户', 'countryNewUser', list),
|
||||
amountSeries('新增充值', 'newUserRecharge', list),
|
||||
amountSeries('币商充值', 'dealerRecharge', list),
|
||||
amountSeries('工资兑换', 'salaryExchange', list),
|
||||
amountSeries('总充值', 'totalRecharge', list),
|
||||
]));
|
||||
renderFlowChart(baseChartOption('核心流水', list, [
|
||||
amountSeries('礼物消耗', 'giftConsume', list),
|
||||
amountSeries('幸运礼物流水', 'luckyGiftTotalFlow', list),
|
||||
amountSeries('游戏流水', 'gameTotalFlow', list),
|
||||
]));
|
||||
renderProfitChart(baseChartOption('返奖与利润', list, [
|
||||
amountSeries('幸运礼物返奖', 'luckyGiftPayout', list),
|
||||
amountSeries('幸运礼物利润', 'luckyGiftProfit', list),
|
||||
amountSeries('游戏返奖', 'gamePayout', list),
|
||||
amountSeries('游戏利润', 'gameProfit', list),
|
||||
]));
|
||||
renderLuckyChart(luckyChartOption(list));
|
||||
}
|
||||
|
||||
watch(
|
||||
sysOriginOptions,
|
||||
(options) => {
|
||||
@ -264,6 +429,10 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch([records, visualMode], () => {
|
||||
renderVisualCharts();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('fullscreenchange', syncFullscreenState);
|
||||
loadDashboard();
|
||||
@ -290,13 +459,18 @@ onBeforeUnmount(() => {
|
||||
国家维度 · {{ dashboard?.computedAt || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="datav-header__center">
|
||||
<Button
|
||||
:type="visualMode ? 'primary' : 'default'"
|
||||
@click="toggleVisualMode"
|
||||
>
|
||||
{{ visualMode ? '表格数据' : '可视化图表' }}
|
||||
</Button>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Button ghost @click="screen">
|
||||
{{ fullscreen ? '退出全屏' : '全屏' }}
|
||||
</Button>
|
||||
<Button :loading="loading" type="primary" @click="loadDashboard">
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
@ -350,32 +524,51 @@ onBeforeUnmount(() => {
|
||||
|
||||
<section class="panel dashboard-panel">
|
||||
<Spin :spinning="loading">
|
||||
<Table
|
||||
v-if="records.length > 0"
|
||||
:columns="columns"
|
||||
:data-source="records"
|
||||
:pagination="{ pageSize: 20, showSizeChanger: true }"
|
||||
:row-key="rowKey"
|
||||
:scroll="{ x: 2060 }"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="metricColumns.has(String(column.dataIndex))">
|
||||
<Tooltip v-if="column.dataIndex === 'luckyGiftProfit'">
|
||||
<template #title>
|
||||
主播分成:{{ formatAmount(record.luckyGiftAnchorShare) }}
|
||||
</template>
|
||||
<span class="number-cell">
|
||||
<template v-if="visualMode">
|
||||
<div v-if="chartRecords.length > 0" class="chart-grid">
|
||||
<section class="chart-card">
|
||||
<EchartsUI ref="userRechargeChartRef" class="chart-canvas" />
|
||||
</section>
|
||||
<section class="chart-card">
|
||||
<EchartsUI ref="flowChartRef" class="chart-canvas" />
|
||||
</section>
|
||||
<section class="chart-card">
|
||||
<EchartsUI ref="profitChartRef" class="chart-canvas" />
|
||||
</section>
|
||||
<section class="chart-card">
|
||||
<EchartsUI ref="luckyChartRef" class="chart-canvas" />
|
||||
</section>
|
||||
</div>
|
||||
<Empty v-else />
|
||||
</template>
|
||||
<template v-else>
|
||||
<Table
|
||||
v-if="records.length > 0"
|
||||
:columns="columns"
|
||||
:data-source="records"
|
||||
:pagination="{ pageSize: 20, showSizeChanger: true }"
|
||||
:row-key="rowKey"
|
||||
:scroll="{ x: 2060 }"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="metricColumns.has(String(column.dataIndex))">
|
||||
<Tooltip v-if="column.dataIndex === 'luckyGiftProfit'">
|
||||
<template #title>
|
||||
主播分成:{{ formatAmount(record.luckyGiftAnchorShare) }}
|
||||
</template>
|
||||
<span class="number-cell">
|
||||
{{ tableCellText(record, String(column.dataIndex)) }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span v-else class="number-cell">
|
||||
{{ tableCellText(record, String(column.dataIndex)) }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span v-else class="number-cell">
|
||||
{{ tableCellText(record, String(column.dataIndex)) }}
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
<Empty v-else />
|
||||
</Table>
|
||||
<Empty v-else />
|
||||
</template>
|
||||
</Spin>
|
||||
</section>
|
||||
|
||||
@ -422,6 +615,12 @@ onBeforeUnmount(() => {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.datav-header__center {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.datav-title {
|
||||
color: #f8fafc;
|
||||
font-size: 28px;
|
||||
@ -483,6 +682,25 @@ onBeforeUnmount(() => {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.chart-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: #111827;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
border-radius: 8px;
|
||||
min-height: 360px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.chart-canvas {
|
||||
height: 340px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.number-cell {
|
||||
color: #e2e8f0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
@ -539,11 +757,17 @@ onBeforeUnmount(() => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.datav-header__center {
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.datav-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.summary-grid,
|
||||
.chart-grid,
|
||||
.legacy-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user