feat: update admin dashboard and baishun config

This commit is contained in:
hy 2026-04-24 14:05:37 +08:00
parent d0fcfe2151
commit 3a3c5a4bf9
4 changed files with 492 additions and 96 deletions

View File

@ -1,5 +1,41 @@
import { requestClient } from '#/api/request';
export interface CountryDashboardMetric {
countryCode: string;
countryName: string;
countryNewUser: number;
dealerRecharge: number | string;
gamePayout: number | string;
gameProfit: number | string;
gameTotalFlow: number | string;
giftConsume: number | string;
luckyGiftAnchorShare: number | string;
luckyGiftPayout: number | string;
luckyGiftProfit: number | string;
luckyGiftTotalFlow: number | string;
newUserRecharge: number | string;
officialRecharge: number | string;
periodKey: string;
periodName: string;
salaryExchange: number | string;
totalRecharge: number | string;
}
export interface CountryDashboardResult {
computedAt: string;
endDate?: string;
periodType: string;
records: CountryDashboardMetric[];
startDate?: string;
total: CountryDashboardMetric;
}
export async function countryDashboard(params: Record<string, any> = {}) {
return requestClient.get<CountryDashboardResult>('/datav/country-dashboard', {
params,
});
}
export async function latestActiveUserCountryCode(params: Record<string, any> = {}) {
return requestClient.get<Array<Record<string, any>>>(
'/datav/active/user-country-code',

View File

@ -36,6 +36,8 @@ function createProviderForm(sysOrigin = '') {
sysOrigin,
profile: 'PROD',
platformBaseUrl: '',
gameListUrl: '',
luckyGiftUrl: '',
appId: undefined as any,
appName: '',
appChannel: '',
@ -85,6 +87,16 @@ const activeProfileLabel = computed(
activeProfile.value,
);
function defaultGameListUrl(platformBaseUrl: any) {
const baseUrl = String(platformBaseUrl || '').trim().replace(/\/+$/, '');
return baseUrl ? `${baseUrl}/v1/api/gamelist` : '';
}
function defaultLuckyGiftUrl(platformBaseUrl: any) {
const baseUrl = String(platformBaseUrl || '').trim().replace(/\/+$/, '');
return baseUrl ? `${baseUrl}/lucky_gift/start_game` : '';
}
const catalogColumns = [
{ dataIndex: 'cover', key: 'cover', title: '封面', width: 90 },
{ dataIndex: 'name', key: 'name', title: '名称', width: 180 },
@ -138,6 +150,9 @@ function applyProviderForm(record: Record<string, any>) {
activeProfile.value = String(record.activeProfile || activeProfile.value || 'PROD');
Object.assign(providerForm, createProviderForm(contextQuery.sysOrigin), {
...record,
gameListUrl: record.gameListUrl || defaultGameListUrl(record.platformBaseUrl),
luckyGiftUrl:
record.luckyGiftUrl || defaultLuckyGiftUrl(record.platformBaseUrl),
profile: contextQuery.profile,
sysOrigin: contextQuery.sysOrigin,
appId: Number(record.appId || 0) || undefined,
@ -164,14 +179,24 @@ async function loadProviderConfig() {
}
async function persistProviderConfig(showSuccess = true) {
const gameListUrl =
String(providerForm.gameListUrl || '').trim() ||
defaultGameListUrl(providerForm.platformBaseUrl);
const luckyGiftUrl =
String(providerForm.luckyGiftUrl || '').trim() ||
defaultLuckyGiftUrl(providerForm.platformBaseUrl);
providerForm.gameListUrl = gameListUrl;
providerForm.luckyGiftUrl = luckyGiftUrl;
if (
!contextQuery.sysOrigin ||
!providerForm.platformBaseUrl ||
!gameListUrl ||
!luckyGiftUrl ||
!providerForm.appId ||
!providerForm.appChannel ||
!providerForm.appKey
) {
message.warning('请补全百顺平台地址、AppId、AppChannel、AppKey');
message.warning('请补全百顺平台地址、游戏列表地址、幸运礼物地址、AppId、AppChannel、AppKey');
return false;
}
providerSaving.value = true;
@ -322,6 +347,24 @@ async function handleImportSelected() {
placeholder="https://game-cn-test.jieyou.shop"
/>
</FormItem>
<FormItem
label="游戏列表地址"
extra="获取百顺游戏列表时直接请求这个完整地址。"
>
<Input
v-model:value="providerForm.gameListUrl"
placeholder="https://game-cn-test.jieyou.shop/v1/api/gamelist"
/>
</FormItem>
<FormItem
label="幸运礼物地址"
extra="幸运礼物抽奖时直接请求这个完整地址。"
>
<Input
v-model:value="providerForm.luckyGiftUrl"
placeholder="https://game-cn-test.jieyou.shop/lucky_gift/start_game"
/>
</FormItem>
<FormItem label="AppId">
<InputNumber
v-model:value="providerForm.appId"

View File

@ -76,6 +76,7 @@ function createProviderForm(sysOrigin = '') {
return {
sysOrigin,
platformBaseUrl: '',
gameListUrl: '',
appId: undefined as any,
appName: '',
appChannel: '',
@ -109,6 +110,12 @@ const categoryOptions = GAME_CATEGORY_OPTIONS.map((item) => ({
label: item.name,
value: item.value as any,
}));
function defaultGameListUrl(platformBaseUrl: any) {
const baseUrl = String(platformBaseUrl || '').trim().replace(/\/+$/, '');
return baseUrl ? `${baseUrl}/v1/api/gamelist` : '';
}
const launchModeOptions = [
{ label: 'H5_REMOTE', value: 'H5_REMOTE' },
{ label: 'H5_ZIP_LOCAL', value: 'H5_ZIP_LOCAL' },
@ -222,6 +229,7 @@ function resetGameForm() {
function applyProviderForm(record: Record<string, any>) {
Object.assign(providerForm, createProviderForm(contextQuery.sysOrigin), {
...record,
gameListUrl: record.gameListUrl || defaultGameListUrl(record.platformBaseUrl),
sysOrigin: contextQuery.sysOrigin,
appId: Number(record.appId || 0) || undefined,
gsp: Number(record.gsp || 101) || 101,
@ -248,14 +256,19 @@ async function loadProviderConfig() {
}
async function persistProviderConfig(showSuccess = true) {
const gameListUrl =
String(providerForm.gameListUrl || '').trim() ||
defaultGameListUrl(providerForm.platformBaseUrl);
providerForm.gameListUrl = gameListUrl;
if (
!contextQuery.sysOrigin ||
!providerForm.platformBaseUrl ||
!gameListUrl ||
!providerForm.appId ||
!providerForm.appChannel ||
!providerForm.appKey
) {
message.warning('请补全百顺平台地址、AppId、AppChannel、AppKey');
message.warning('请补全百顺平台地址、游戏列表地址、AppId、AppChannel、AppKey');
return false;
}
providerSaving.value = true;
@ -458,6 +471,15 @@ async function handleImportSelected() {
placeholder="https://game-cn-test.jieyou.shop"
/>
</FormItem>
<FormItem
label="游戏列表地址"
extra="获取百顺游戏列表时直接请求这个完整地址。"
>
<Input
v-model:value="providerForm.gameListUrl"
placeholder="https://game-cn-test.jieyou.shop/v1/api/gamelist"
/>
</FormItem>
<FormItem label="AppId">
<InputNumber
v-model:value="providerForm.appId"

View File

@ -1,9 +1,28 @@
<script lang="ts" setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import type {
CountryDashboardResult,
} from '#/api/legacy/datav';
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useWindowSize } from '@vueuse/core';
import { useAccessStore } from '@vben/stores';
import {
Button,
DateRangePicker,
Empty,
Input,
message,
Select,
Space,
Spin,
Table,
Tooltip,
} from 'antdv-next';
import dayjs from 'dayjs';
import { countryDashboard } from '#/api/legacy/datav';
import SysOriginSelect from '#/components/sys-origin-select.vue';
import { getAllowedSysOrigins } from '#/views/system/shared';
import LatestPurchaseList from './components/latest-purchase-list.vue';
@ -12,17 +31,83 @@ import PurchaseCount from './components/purchase-count.vue';
defineOptions({ name: 'StatisticsDatav' });
const accessStore = useAccessStore();
const { width } = useWindowSize();
const datavFullscreen = ref<HTMLElement>();
const fullscreen = ref(false);
const loading = ref(false);
const dashboard = ref<CountryDashboardResult | null>(null);
const dateRange = ref<[string, string] | null>([
dayjs().subtract(6, 'day').format('YYYY-MM-DD'),
dayjs().format('YYYY-MM-DD'),
]);
const query = reactive({
countryKeyword: '',
periodType: 'DAY',
sysOrigin: '',
});
const mobile = computed(() => width.value < 768);
const accessCodes = computed(() => accessStore.accessCodes || []);
const permissionsSysOriginCode = computed(() => {
const sysOriginOptions = computed(() => {
const options = getAllowedSysOrigins(accessCodes.value);
const finalOptions = options.length > 0 ? options : getAllowedSysOrigins([]);
return finalOptions.map((item) => String(item.value || ''));
return options.length > 0 ? options : getAllowedSysOrigins([]);
});
const permissionsSysOriginCode = computed(() =>
sysOriginOptions.value.map((item) => String(item.value || '')),
);
const periodTypeOptions = [
{ label: '日', value: 'DAY' },
{ label: '周', value: 'WEEK' },
{ label: '月', value: 'MONTH' },
{ label: '全部', value: 'ALL' },
];
const metricColumns = new Set([
'countryNewUser',
'dealerRecharge',
'gamePayout',
'gameProfit',
'gameTotalFlow',
'giftConsume',
'luckyGiftPayout',
'luckyGiftProfit',
'luckyGiftTotalFlow',
'newUserRecharge',
'salaryExchange',
'totalRecharge',
]);
const columns: any[] = [
{ dataIndex: 'countryName', fixed: 'left' as const, key: 'countryName', title: '国家', width: 150 },
{ dataIndex: 'countryCode', key: 'countryCode', title: 'Code', width: 86 },
{ dataIndex: 'periodName', key: 'periodName', title: '周期', width: 110 },
{ align: 'right', dataIndex: 'countryNewUser', key: 'countryNewUser', title: '新增', width: 110 },
{ align: 'right', dataIndex: 'newUserRecharge', key: 'newUserRecharge', title: '新增充值', width: 130 },
{ align: 'right', dataIndex: 'dealerRecharge', key: 'dealerRecharge', title: '币商充值', width: 130 },
{ align: 'right', dataIndex: 'salaryExchange', key: 'salaryExchange', title: '工资兑换', width: 130 },
{ align: 'right', dataIndex: 'totalRecharge', key: 'totalRecharge', title: '总充值', width: 130 },
{ align: 'right', dataIndex: 'giftConsume', key: 'giftConsume', title: '礼物消耗', width: 130 },
{ align: 'right', dataIndex: 'luckyGiftTotalFlow', key: 'luckyGiftTotalFlow', title: '幸运礼物流水', width: 150 },
{ align: 'right', dataIndex: 'luckyGiftPayout', key: 'luckyGiftPayout', title: '幸运礼物返奖', width: 150 },
{ align: 'right', dataIndex: 'luckyGiftProfit', key: 'luckyGiftProfit', title: '幸运礼物利润', width: 150 },
{ align: 'right', dataIndex: 'gameTotalFlow', key: 'gameTotalFlow', title: '游戏流水', width: 130 },
{ align: 'right', dataIndex: 'gamePayout', key: 'gamePayout', title: '游戏返奖', width: 130 },
{ align: 'right', dataIndex: 'gameProfit', key: 'gameProfit', title: '游戏利润', width: 130 },
];
const total = computed(() => dashboard.value?.total || null);
const records = computed(() => dashboard.value?.records || []);
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?.luckyGiftProfit) },
{ label: '游戏流水', value: formatAmount(item?.gameTotalFlow) },
{ label: '游戏利润', value: formatAmount(item?.gameProfit) },
];
});
function hasPermission(code: string) {
@ -52,8 +137,94 @@ async function screen() {
await element.requestFullscreen();
}
function defaultRange(periodType: string): [string, string] | null {
const now = dayjs();
if (periodType === 'ALL') {
return null;
}
if (periodType === 'MONTH') {
return [now.startOf('month').format('YYYY-MM-DD'), now.format('YYYY-MM-DD')];
}
if (periodType === 'WEEK') {
const daysSinceMonday = now.day() === 0 ? 6 : now.day() - 1;
return [now.subtract(daysSinceMonday, 'day').format('YYYY-MM-DD'), now.format('YYYY-MM-DD')];
}
return [now.subtract(6, 'day').format('YYYY-MM-DD'), now.format('YYYY-MM-DD')];
}
function handlePeriodChange(value: string) {
dateRange.value = defaultRange(value);
loadDashboard();
}
function buildParams() {
const params: Record<string, any> = {
countryKeyword: query.countryKeyword || undefined,
periodType: query.periodType,
sysOrigin: query.sysOrigin || undefined,
};
if (dateRange.value?.[0] && dateRange.value?.[1]) {
params.startDate = dateRange.value[0];
params.endDate = dateRange.value[1];
}
return params;
}
async function loadDashboard() {
if (!canQuery.value) {
return;
}
loading.value = true;
try {
dashboard.value = await countryDashboard(buildParams());
} catch (error) {
dashboard.value = null;
message.error('数据大屏加载失败');
throw error;
} finally {
loading.value = false;
}
}
function formatInteger(value?: null | number | string) {
const number = Number(value || 0);
return Number.isFinite(number) ? number.toLocaleString('en-US', { maximumFractionDigits: 0 }) : '0';
}
function formatAmount(value?: null | number | string) {
const number = Number(value || 0);
return Number.isFinite(number)
? number.toLocaleString('en-US', {
maximumFractionDigits: 2,
minimumFractionDigits: 0,
})
: '0';
}
function tableCellText(record: Record<string, any>, key: string) {
if (key === 'countryNewUser') {
return formatInteger(record.countryNewUser);
}
return formatAmount(record[key] as number | string);
}
function rowKey(record: Record<string, any>) {
return `${record.countryCode}-${record.periodKey}`;
}
watch(
sysOriginOptions,
(options) => {
if (!query.sysOrigin && options[0]?.value) {
query.sysOrigin = String(options[0].value);
}
},
{ immediate: true },
);
onMounted(() => {
document.addEventListener('fullscreenchange', syncFullscreenState);
loadDashboard();
});
onBeforeUnmount(() => {
@ -65,33 +236,115 @@ onBeforeUnmount(() => {
<div class="app-container-datav">
<div
ref="datavFullscreen"
:class="[
'office-efficiency-index',
fullscreen ? 'office-efficiency-index--fullscreen' : '',
class="datav-shell" :class="[
fullscreen ? 'datav-shell--fullscreen' : '',
]"
@dblclick="screen"
>
<div v-if="!mobile" class="office-header">
<div class="title-info">数据大屏</div>
<div class="datav-header">
<div>
<div class="datav-title">数据大屏</div>
<div class="datav-subtitle">
国家维度 · {{ dashboard?.computedAt || '-' }}
</div>
</div>
<Space wrap>
<Button ghost @click="screen">
{{ fullscreen ? '退出全屏' : '全屏' }}
</Button>
<Button :loading="loading" type="primary" @click="loadDashboard">
查询
</Button>
</Space>
</div>
<div class="analysis-info">实时数据预览</div>
<div v-if="canQuery" class="office-content">
<div class="office-content__left">
<section class="col-info">
<div class="title">购买用户</div>
<div class="content">
<LatestPurchaseList :sys-origin-codes="permissionsSysOriginCode" />
</div>
<div v-if="canQuery" class="datav-content">
<section class="panel filter-panel">
<Space wrap>
<SysOriginSelect
v-model:value="query.sysOrigin"
:options="sysOriginOptions"
allow-clear
placeholder="系统"
style="width: 150px"
@change="loadDashboard"
/>
<Select
v-model:value="query.periodType"
:options="periodTypeOptions"
style="width: 110px"
@change="handlePeriodChange"
/>
<DateRangePicker
v-model:value="dateRange"
allow-clear
style="width: 260px"
value-format="YYYY-MM-DD"
@change="loadDashboard"
/>
<Input
v-model:value="query.countryKeyword"
allow-clear
placeholder="国家编码 / 名称"
style="width: 180px"
@press-enter="loadDashboard"
/>
<Button :loading="loading" type="primary" @click="loadDashboard">
查询
</Button>
</Space>
</section>
<div class="summary-grid">
<section
v-for="item in summaryCards"
:key="item.label"
class="summary-card"
>
<div class="summary-card__label">{{ item.label }}</div>
<div class="summary-card__value">{{ item.value }}</div>
</section>
</div>
<div class="office-content__right">
<section v-if="canPaidPreview" class="col-info">
<div class="title">今日付费预览</div>
<div class="content">
<PurchaseCount />
</div>
<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: 1900 }"
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>
</template>
</template>
</Table>
<Empty v-else />
</Spin>
</section>
<div class="legacy-grid">
<section class="panel">
<div class="panel-title">购买用户</div>
<LatestPurchaseList :sys-origin-codes="permissionsSysOriginCode" />
</section>
<section v-if="canPaidPreview" class="panel">
<div class="panel-title">今日付费预览</div>
<PurchaseCount />
</section>
</div>
</div>
@ -101,114 +354,156 @@ onBeforeUnmount(() => {
<style scoped>
.app-container-datav {
background-color: #22284a;
border-radius: 20px;
background: #111827;
min-height: calc(100vh - 120px);
overflow: hidden;
}
.office-efficiency-index {
.datav-shell {
background:
radial-gradient(circle at top left, rgba(49, 86, 173, 0.35), transparent 40%),
radial-gradient(circle at top right, rgba(3, 194, 236, 0.12), transparent 36%),
#22284a;
color: #fff;
linear-gradient(180deg, rgba(15, 23, 42, 0.96), rgba(17, 24, 39, 0.98)),
#111827;
color: #f8fafc;
min-height: inherit;
padding: 20px;
}
.office-efficiency-index--fullscreen {
border-radius: 0;
.datav-shell--fullscreen {
min-height: 100vh;
padding: 24px;
}
.office-header {
background: url('../../assets/datav/office_efficiency_header_bg.png')
no-repeat center center;
background-size: 100% 100%;
padding: 18px 20px 10px;
.datav-header {
align-items: center;
display: flex;
gap: 16px;
justify-content: space-between;
}
.title-info {
color: #03c2ec;
font-family: PingFangSC-Semibold, 'PingFang SC', sans-serif;
font-size: clamp(26px, 3vw, 36px);
font-weight: 600;
line-height: 1.4;
text-align: center;
.datav-title {
color: #f8fafc;
font-size: 28px;
font-weight: 700;
line-height: 1.3;
}
.analysis-info {
background-image: linear-gradient(#d8ae22, #dc9546);
color: transparent;
font-size: 18px;
font-weight: 600;
margin-top: 12px;
padding: 0 12px;
-webkit-background-clip: text;
background-clip: text;
.datav-subtitle {
color: #94a3b8;
font-size: 13px;
line-height: 1.8;
}
.office-content {
.datav-content {
display: grid;
gap: 20px;
grid-template-columns: minmax(320px, 0.95fr) minmax(0, 2.1fr);
margin-top: 20px;
gap: 16px;
margin-top: 18px;
}
.office-content__left,
.office-content__right {
display: grid;
gap: 20px;
}
.col-info {
background: rgba(24, 28, 65, 0.96);
border: 1px solid rgba(86, 114, 195, 0.28);
border-radius: 20px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
.panel {
background: rgba(30, 41, 59, 0.82);
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 8px;
overflow: hidden;
}
.title {
color: #03c2ec;
font-size: clamp(22px, 2vw, 30px);
font-weight: 600;
padding: 22px 24px 12px;
.filter-panel {
padding: 14px;
}
.content {
color: #fff;
padding: 0 24px 24px;
.summary-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(6, 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 {
padding: 12px;
}
.number-cell {
color: #e2e8f0;
font-variant-numeric: tabular-nums;
}
.legacy-grid {
display: grid;
gap: 16px;
grid-template-columns: minmax(300px, 1fr) minmax(300px, 1fr);
}
.panel-title {
color: #2dd4bf;
font-size: 18px;
font-weight: 700;
padding: 16px 16px 8px;
}
.panel :deep(.ant-table) {
background: transparent;
}
.panel :deep(.ant-table-cell) {
background: #111827;
color: #e2e8f0;
}
.panel :deep(.ant-table-thead > tr > th) {
background: #1f2937;
color: #f8fafc;
}
.panel :deep(.ant-empty-description) {
color: #cbd5e1;
}
@media (max-width: 1200px) {
.office-content {
grid-template-columns: minmax(280px, 0.9fr) minmax(0, 1.7fr);
.summary-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 960px) {
@media (max-width: 768px) {
.app-container-datav {
border-radius: 16px;
min-height: calc(100vh - 88px);
}
.office-efficiency-index {
padding: 16px;
.datav-shell {
padding: 14px;
}
.office-content {
.datav-header {
align-items: flex-start;
flex-direction: column;
}
.datav-title {
font-size: 24px;
}
.summary-grid,
.legacy-grid {
grid-template-columns: 1fr;
}
.title {
padding: 18px 18px 12px;
}
.content {
padding: 0 18px 18px;
}
}
</style>