feat: enhance country config and dashboard charts

This commit is contained in:
hy 2026-04-24 14:50:39 +08:00
parent ccf7520107
commit 83ed9a1c6a
3 changed files with 322 additions and 32 deletions

View File

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

View File

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

View File

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