数据大屏,灵仙游戏增加
This commit is contained in:
170-carry 2026-04-24 17:11:31 +08:00
parent e02a6ff92f
commit 18d13b6172
5 changed files with 770 additions and 11 deletions

View File

@ -60,3 +60,51 @@ export async function importBaishunCatalog(data: Record<string, any>) {
export async function syncBaishunGames(data: Record<string, any>) {
return requestClient.post<Record<string, any>>('/go/operate/baishun-game/sync', data);
}
export async function getLingxianProviderConfig(params: Record<string, any>) {
return requestClient.get<Record<string, any>>('/go/operate/lingxian-game/config', {
params,
});
}
export async function saveLingxianProviderConfig(data: Record<string, any>) {
return requestClient.post<Record<string, any>>(
'/go/operate/lingxian-game/config',
data,
);
}
export async function activateLingxianProviderProfile(data: Record<string, any>) {
return requestClient.post<Record<string, any>>(
'/go/operate/lingxian-game/config/activate',
data,
);
}
export async function pageLingxianCatalog(params: Record<string, any>) {
return requestClient.get<Record<string, any>>(
'/go/operate/lingxian-game/catalog/page',
{ params },
);
}
export async function fetchLingxianCatalog(data: Record<string, any>) {
return requestClient.post<Record<string, any>>(
'/go/operate/lingxian-game/catalog/fetch',
data,
);
}
export async function importLingxianCatalog(data: Record<string, any>) {
return requestClient.post<Record<string, any>>(
'/go/operate/lingxian-game/import-catalog',
data,
);
}
export async function syncLingxianGames(data: Record<string, any>) {
return requestClient.post<Record<string, any>>(
'/go/operate/lingxian-game/sync',
data,
);
}

View File

@ -4,16 +4,32 @@ export interface CountryDashboardMetric {
countryCode: string;
countryName: string;
countryNewUser: number;
d1RetentionBaseUser: number;
d1RetentionRate: number | string;
d1RetentionUser: number;
d7RetentionBaseUser: number;
d7RetentionRate: number | string;
d7RetentionUser: number;
d30RetentionBaseUser: number;
d30RetentionRate: number | string;
d30RetentionUser: number;
dailyActiveUser: number;
dealerRecharge: number | string;
gamePayoutRate: number | string;
gamePayout: number | string;
gameProfit: number | string;
gameProfitRate: number | string;
gameTotalFlow: number | string;
gameUser: number;
giftConsume: number | string;
luckyGiftAnchorShare: number | string;
luckyGiftPayout: number | string;
luckyGiftPayoutRate: number | string;
luckyGiftProfit: number | string;
luckyGiftProfitRate: number | string;
luckyGiftTotalFlow: number | string;
luckyGiftUser: number;
luckyGiftUserRate: number | string;
newUserRecharge: number | string;
officialRecharge: number | string;
periodKey: string;

View File

@ -23,6 +23,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('#/views/operate/baishun-config.vue'),
meta: { title: '百顺配置' },
},
{
name: 'OperateLingxianConfig',
path: 'lingxian-config',
component: () => import('#/views/operate/lingxian-config.vue'),
meta: { title: '灵仙配置' },
},
],
},
];

View File

@ -0,0 +1,464 @@
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { useAccessStore } from '@vben/stores';
import {
activateLingxianProviderProfile,
fetchLingxianCatalog,
getLingxianProviderConfig,
importLingxianCatalog,
pageLingxianCatalog,
saveLingxianProviderConfig,
} from '#/api/legacy/baishun-game';
import { getAllowedSysOrigins } from '#/views/system/shared';
import {
Button,
Card,
Collapse,
CollapsePanel,
Form,
FormItem,
Input,
Pagination,
Select,
Table,
Tag,
message,
} from 'antdv-next';
defineOptions({ name: 'OperateLingxianConfig' });
const defaultGameListUrl =
'https://sg-test.leadercc.com/yumichat_games/test_game_list.json';
function createProviderForm(sysOrigin = '') {
return {
appKey: '',
gameListUrl: defaultGameListUrl,
profile: 'PROD',
sysOrigin,
testToken: '',
testUid: '',
};
}
const accessStore = useAccessStore();
const sysOriginOptions = computed(() => {
const options = getAllowedSysOrigins(accessStore.accessCodes || []);
return options.length > 0 ? options : getAllowedSysOrigins([]);
});
const profileOptions = [
{ label: '正式环境', value: 'PROD' },
{ label: '测试环境', value: 'TEST' },
];
const contextQuery = reactive({ profile: 'PROD', sysOrigin: '' });
const providerForm = reactive(createProviderForm());
const catalogQuery = reactive<Record<string, any>>({
cursor: 1,
keyword: '',
limit: 20,
});
const providerLoading = ref(false);
const providerSaving = ref(false);
const catalogLoading = ref(false);
const catalogFetching = ref(false);
const catalogImporting = ref(false);
const profileActivating = ref(false);
const totalCatalog = ref(0);
const catalogList = ref<Array<Record<string, any>>>([]);
const selectedCatalogKeys = ref<Array<string>>([]);
const activeProfile = ref('PROD');
const isCurrentProfileActive = computed(
() => activeProfile.value === contextQuery.profile,
);
const activeProfileLabel = computed(
() =>
profileOptions.find((item) => item.value === activeProfile.value)?.label ||
activeProfile.value,
);
const catalogColumns = [
{ dataIndex: 'name', key: 'name', title: '名称', width: 180 },
{ dataIndex: 'gameId', key: 'gameId', title: '内部ID', width: 150 },
{ dataIndex: 'vendorGameId', key: 'vendorGameId', title: '灵仙ID', width: 120 },
{ dataIndex: 'status', key: 'status', title: '目录状态', width: 110 },
{ dataIndex: 'packageVersion', key: 'packageVersion', title: '版本', width: 90 },
{ dataIndex: 'added', key: 'added', title: '统一游戏列表', width: 140 },
{ dataIndex: 'downloadUrl', key: 'downloadUrl', title: '入口地址', width: 440 },
{ dataIndex: 'previewUrl', key: 'previewUrl', title: '半屏地址', width: 440 },
{ dataIndex: 'updateTime', key: 'updateTime', title: '更新时间', width: 180 },
];
const catalogRowSelection = computed(() => ({
selectedRowKeys: selectedCatalogKeys.value,
onChange: (keys: Array<number | string>) => {
selectedCatalogKeys.value = keys.map((item) => String(item));
},
getCheckboxProps: (record: Record<string, any>) => ({
disabled: Boolean(record.added),
}),
}));
watch(
sysOriginOptions,
(options) => {
if (!contextQuery.sysOrigin) {
contextQuery.sysOrigin = String(options[0]?.value || '');
}
},
{ immediate: true },
);
watch(
() => [contextQuery.sysOrigin, contextQuery.profile],
([sysOrigin, profile]) => {
if (!sysOrigin || !profile) {
return;
}
Object.assign(providerForm, createProviderForm(sysOrigin), {
profile,
sysOrigin,
});
selectedCatalogKeys.value = [];
void Promise.all([loadProviderConfig(), loadCatalog(true)]);
},
{ immediate: true },
);
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,
profile: contextQuery.profile,
sysOrigin: contextQuery.sysOrigin,
});
}
async function loadProviderConfig() {
if (!contextQuery.sysOrigin) {
return;
}
providerLoading.value = true;
try {
const result = await getLingxianProviderConfig({
profile: contextQuery.profile,
sysOrigin: contextQuery.sysOrigin,
});
applyProviderForm(result || {});
} finally {
providerLoading.value = false;
}
}
async function persistProviderConfig(showSuccess = true) {
providerForm.gameListUrl =
String(providerForm.gameListUrl || '').trim() || defaultGameListUrl;
if (!contextQuery.sysOrigin || !providerForm.gameListUrl || !providerForm.appKey) {
message.warning('请补全灵仙游戏列表地址和 Key');
return false;
}
providerSaving.value = true;
try {
const result = await saveLingxianProviderConfig({
...providerForm,
profile: contextQuery.profile,
sysOrigin: contextQuery.sysOrigin,
});
applyProviderForm(result || providerForm);
if (showSuccess) {
message.success('灵仙配置已保存');
}
return true;
} catch {
return false;
} finally {
providerSaving.value = false;
}
}
async function loadCatalog(reset = false) {
if (!contextQuery.sysOrigin) {
return;
}
if (reset) {
catalogQuery.cursor = 1;
}
catalogLoading.value = true;
try {
const result = await pageLingxianCatalog({
...catalogQuery,
profile: contextQuery.profile,
sysOrigin: contextQuery.sysOrigin,
});
catalogList.value = Array.isArray(result.records) ? result.records : [];
totalCatalog.value = Number(result.total || 0);
} finally {
catalogLoading.value = false;
}
}
function handleCatalogPageChange(page: number, pageSize: number) {
catalogQuery.cursor = page;
catalogQuery.limit = pageSize;
void loadCatalog();
}
async function handleActivateProfile() {
if (!(await persistProviderConfig(false))) {
return;
}
profileActivating.value = true;
try {
const result = await activateLingxianProviderProfile({
profile: contextQuery.profile,
sysOrigin: contextQuery.sysOrigin,
});
applyProviderForm(result || providerForm);
message.success(`已切换到${activeProfileLabel.value}`);
await loadCatalog(true);
} finally {
profileActivating.value = false;
}
}
async function handleFetchCatalog() {
if (!(await persistProviderConfig(false))) {
return;
}
catalogFetching.value = true;
try {
const result = await fetchLingxianCatalog({
profile: contextQuery.profile,
sysOrigin: contextQuery.sysOrigin,
});
message.success(
`获取完成:新增 ${Number(result.inserted || 0)},更新 ${Number(result.updated || 0)},跳过 ${Number(result.skipped || 0)}`,
);
await loadCatalog(true);
} finally {
catalogFetching.value = false;
}
}
async function handleImportSelected() {
if (selectedCatalogKeys.value.length === 0) {
message.warning('请选择要添加到统一游戏列表的灵仙游戏');
return;
}
catalogImporting.value = true;
try {
const result = await importLingxianCatalog({
profile: contextQuery.profile,
sysOrigin: contextQuery.sysOrigin,
vendorGameIds: selectedCatalogKeys.value,
});
selectedCatalogKeys.value = [];
message.success(
`添加完成:新增 ${Number(result.imported || 0)},已存在 ${Number(result.existing || 0)}`,
);
await loadCatalog(true);
} finally {
catalogImporting.value = false;
}
}
</script>
<template>
<Page title="灵仙配置">
<Card :loading="providerLoading" title="灵仙接入配置">
<Form layout="vertical">
<div class="form-grid provider-grid">
<FormItem label="系统">
<SysOriginSelect
v-model:value="contextQuery.sysOrigin"
:options="sysOriginOptions"
/>
</FormItem>
<FormItem label="配置环境" extra="app 侧只读取当前启用环境。">
<Select
v-model:value="contextQuery.profile"
:options="profileOptions"
option-label-prop="label"
/>
</FormItem>
<FormItem label="游戏列表地址">
<Input
v-model:value="providerForm.gameListUrl"
:placeholder="defaultGameListUrl"
/>
</FormItem>
<FormItem label="Key">
<Input
v-model:value="providerForm.appKey"
placeholder="请输入灵仙签名 Key"
type="password"
/>
</FormItem>
</div>
<Collapse class="test-user-collapse" ghost>
<CollapsePanel key="test-user" header="测试用户">
<div class="form-grid">
<FormItem label="UID">
<Input v-model:value="providerForm.testUid" placeholder="可选" />
</FormItem>
<FormItem label="Token">
<Input
v-model:value="providerForm.testToken"
placeholder="可选"
type="password"
/>
</FormItem>
</div>
</CollapsePanel>
</Collapse>
<div class="toolbar">
<Button
type="primary"
:loading="providerSaving"
@click="persistProviderConfig(true)"
>
保存配置
</Button>
<Button
:disabled="isCurrentProfileActive"
:loading="profileActivating"
@click="handleActivateProfile"
>
启用此环境
</Button>
<Button :loading="catalogFetching" @click="handleFetchCatalog">
获取灵仙游戏列表
</Button>
<Tag :color="isCurrentProfileActive ? 'success' : 'warning'">
当前启用{{ activeProfileLabel }}
</Tag>
</div>
</Form>
</Card>
<Card class="content-card" title="灵仙目录">
<div class="toolbar">
<Input
v-model:value="catalogQuery.keyword"
placeholder="搜索目录名称 / 内部ID / 灵仙ID"
style="width: 280px"
@pressEnter="loadCatalog(true)"
/>
<Button :loading="catalogLoading" type="primary" @click="loadCatalog(true)">
搜索
</Button>
<Button :loading="catalogFetching" @click="handleFetchCatalog">
重新获取目录
</Button>
<Button
type="primary"
:loading="catalogImporting"
@click="handleImportSelected"
>
添加已选游戏
</Button>
</div>
<Table
:columns="catalogColumns"
:data-source="catalogList"
:loading="catalogLoading"
:pagination="false"
row-key="vendorGameId"
:row-selection="catalogRowSelection"
:scroll="{ x: 1780 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<Tag :color="record.status === 'ENABLED' ? 'success' : 'default'">
{{ record.status || '-' }}
</Tag>
</template>
<template v-else-if="column.key === 'added'">
<Tag :color="record.added ? 'success' : 'default'">
{{ record.added ? '已进入统一游戏列表' : '未添加' }}
</Tag>
</template>
<template v-else-if="column.key === 'downloadUrl' || column.key === 'previewUrl'">
<a
v-if="record[column.key]"
:href="record[column.key]"
class="link-cell"
rel="noreferrer"
target="_blank"
>
{{ record[column.key] }}
</a>
<span v-else>-</span>
</template>
</template>
</Table>
<div v-if="totalCatalog > 0" class="pager">
<Pagination
:current="catalogQuery.cursor"
:page-size="catalogQuery.limit"
:total="totalCatalog"
show-size-changer
@change="handleCatalogPageChange"
@showSizeChange="handleCatalogPageChange"
/>
</div>
</Card>
</Page>
</template>
<style scoped>
.content-card {
margin-top: 16px;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.pager {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 16px;
}
.provider-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.test-user-collapse {
margin-bottom: 12px;
}
.link-cell {
display: inline-block;
max-width: 420px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 960px) {
.form-grid,
.provider-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -87,53 +87,100 @@ const periodTypeOptions = [
const metricColumns = new Set([
'countryNewUser',
'd1RetentionRate',
'd7RetentionRate',
'd30RetentionRate',
'dailyActiveUser',
'dealerRecharge',
'gamePayout',
'gamePayoutRate',
'gameProfit',
'gameProfitRate',
'gameTotalFlow',
'gameUser',
'giftConsume',
'luckyGiftPayout',
'luckyGiftPayoutRate',
'luckyGiftProfit',
'luckyGiftProfitRate',
'luckyGiftTotalFlow',
'luckyGiftUser',
'luckyGiftUserRate',
'newUserRecharge',
'salaryExchange',
'totalRecharge',
]);
const integerMetricColumns = new Set([
'countryNewUser',
'dailyActiveUser',
'gameUser',
'luckyGiftUser',
]);
const percentMetricColumns = new Set([
'd1RetentionRate',
'd7RetentionRate',
'd30RetentionRate',
'gamePayoutRate',
'gameProfitRate',
'luckyGiftPayoutRate',
'luckyGiftProfitRate',
'luckyGiftUserRate',
]);
const summaryMetricKeys = [
'countryNewUser',
'dailyActiveUser',
'newUserRecharge',
'dealerRecharge',
'salaryExchange',
'totalRecharge',
'giftConsume',
'luckyGiftTotalFlow',
'luckyGiftUser',
'luckyGiftPayout',
'luckyGiftProfit',
'gameTotalFlow',
'gameUser',
'gamePayout',
'gameProfit',
'd1RetentionUser',
'd1RetentionBaseUser',
'd7RetentionUser',
'd7RetentionBaseUser',
'd30RetentionUser',
'd30RetentionBaseUser',
] as const;
const summaryMetricDefinitions = [
{ key: 'countryNewUser', label: '新增' },
{ key: 'dailyActiveUser', label: '当日活跃' },
{ key: 'newUserRecharge', label: '新增充值' },
{ key: 'dealerRecharge', label: '币商充值' },
{ key: 'salaryExchange', label: '工资兑换' },
{ key: 'totalRecharge', label: '总充值' },
{ key: 'giftConsume', label: '礼物消耗' },
{ key: 'luckyGiftTotalFlow', label: '幸运礼物流水' },
{ key: 'luckyGiftUser', label: '幸运礼物参与人数' },
{ key: 'luckyGiftUserRate', label: '幸运礼物付费率' },
{ key: 'luckyGiftPayout', label: '幸运礼物返奖' },
{ key: 'luckyGiftPayoutRate', label: '幸运礼物返奖率' },
{ key: 'luckyGiftProfit', label: '幸运礼物利润' },
{ key: 'luckyGiftProfitRate', label: '幸运礼物利润率' },
{ key: 'gameTotalFlow', label: '游戏流水' },
{ key: 'gameUser', label: '游戏参与人数' },
{ key: 'gamePayout', label: '游戏返奖' },
{ key: 'gamePayoutRate', label: '游戏返奖率' },
{ key: 'gameProfit', label: '游戏利润' },
{ key: 'gameProfitRate', label: '游戏利润率' },
{ key: 'd1RetentionRate', label: '次日留存率' },
{ key: 'd7RetentionRate', label: '七日留存率' },
{ key: 'd30RetentionRate', label: '30 日留存率' },
] as const;
type SummaryMetricKey = (typeof summaryMetricDefinitions)[number]['key'];
type SummaryMetric = Record<SummaryMetricKey, number>;
type SummaryMetric = Record<string, number>;
const summaryColumns = [
...summaryMetricDefinitions.map((item) => ({
@ -141,7 +188,7 @@ const summaryColumns = [
dataIndex: item.key,
key: item.key,
title: item.label,
width: item.key === 'countryNewUser' ? 110 : (item.key === 'luckyGiftProfitRate' ? 160 : 140),
width: item.key === 'countryNewUser' ? 110 : (String(item.key).includes('Rate') ? 160 : 150),
})),
];
const summaryScrollX = summaryColumns.reduce((totalWidth, item) => totalWidth + item.width, 0);
@ -151,19 +198,30 @@ const columns: any[] = [
{ 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: 'dailyActiveUser', key: 'dailyActiveUser', title: '当日活跃', width: 120 },
{ align: 'right', dataIndex: 'd1RetentionRate', key: 'd1RetentionRate', title: '次日留存率', width: 140 },
{ align: 'right', dataIndex: 'd7RetentionRate', key: 'd7RetentionRate', title: '七日留存率', width: 140 },
{ align: 'right', dataIndex: 'd30RetentionRate', key: 'd30RetentionRate', title: '30 日留存率', width: 150 },
{ 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: 'luckyGiftUser', key: 'luckyGiftUser', title: '幸运礼物参与人数', width: 170 },
{ align: 'right', dataIndex: 'luckyGiftUserRate', key: 'luckyGiftUserRate', title: '幸运礼物付费率', width: 160 },
{ align: 'right', dataIndex: 'luckyGiftPayout', key: 'luckyGiftPayout', title: '幸运礼物返奖', width: 150 },
{ align: 'right', dataIndex: 'luckyGiftPayoutRate', key: 'luckyGiftPayoutRate', title: '幸运礼物返奖率', width: 160 },
{ align: 'right', dataIndex: 'luckyGiftProfit', key: 'luckyGiftProfit', title: '幸运礼物利润', width: 150 },
{ align: 'right', dataIndex: 'luckyGiftProfitRate', key: 'luckyGiftProfitRate', title: '幸运礼物利润率', width: 160 },
{ align: 'right', dataIndex: 'gameTotalFlow', key: 'gameTotalFlow', title: '游戏流水', width: 130 },
{ align: 'right', dataIndex: 'gameUser', key: 'gameUser', title: '游戏参与人数', width: 140 },
{ align: 'right', dataIndex: 'gamePayout', key: 'gamePayout', title: '游戏返奖', width: 130 },
{ align: 'right', dataIndex: 'gamePayoutRate', key: 'gamePayoutRate', title: '游戏返奖率', width: 140 },
{ align: 'right', dataIndex: 'gameProfit', key: 'gameProfit', title: '游戏利润', width: 130 },
{ align: 'right', dataIndex: 'gameProfitRate', key: 'gameProfitRate', title: '游戏利润率', width: 140 },
];
const tableScrollX = columns.reduce((totalWidth, item) => totalWidth + Number(item.width || 0), 0);
const rawRecords = computed(() => dashboard.value?.records || []);
const countryOptions = computed(() => {
@ -195,6 +253,26 @@ const summaryDataSource = computed(() => {
}
return [row];
});
const luckyGameCards = computed(() => {
const summary = visibleSummary.value;
return [
{ label: '幸运礼物参与人数', value: formatInteger(summary.luckyGiftUser) },
{ label: '幸运礼物付费率', value: formatPercent(summary.luckyGiftUserRate) },
{ label: '幸运礼物返奖率', value: formatPercent(summary.luckyGiftPayoutRate) },
{ label: '游戏参与人数', value: formatInteger(summary.gameUser) },
{ label: '游戏返奖率', value: formatPercent(summary.gamePayoutRate) },
{ label: '游戏利润率', value: formatPercent(summary.gameProfitRate) },
];
});
const appDataCards = computed(() => {
const summary = visibleSummary.value;
return [
{ label: '当日活跃数', value: formatInteger(summary.dailyActiveUser) },
{ label: '次日留存率', value: formatPercent(summary.d1RetentionRate) },
{ label: '七日留存率', value: formatPercent(summary.d7RetentionRate) },
{ label: '30 日留存率', value: formatPercent(summary.d30RetentionRate) },
];
});
function hasPermission(code: string) {
const codes = accessCodes.value;
@ -282,6 +360,14 @@ function toAmount(value?: null | number | string) {
return Number.isFinite(number) ? number : 0;
}
function calcPercent(numerator?: null | number | string, denominator?: null | number | string) {
const fixedDenominator = toAmount(denominator);
if (fixedDenominator === 0) {
return 0;
}
return (toAmount(numerator) / fixedDenominator) * 100;
}
function formatInteger(value?: null | number | string) {
const number = Number(value || 0);
return Number.isFinite(number) ? number.toLocaleString('en-US', { maximumFractionDigits: 0 }) : '0';
@ -332,18 +418,66 @@ function resolveLuckyGiftProfitRate(record?: null | {
return (Number(record.luckyGiftProfit || 0) / flow) * 100;
}
function resolveMetricRate(record: Record<string, any>, key: string) {
if (key === 'luckyGiftProfitRate') {
return resolveLuckyGiftProfitRate(record);
}
const rate = Number(record[key]);
if (Number.isFinite(rate) && record[key] !== undefined && record[key] !== null && record[key] !== '') {
return rate;
}
if (key === 'luckyGiftUserRate') {
return calcPercent(record.luckyGiftUser, record.dailyActiveUser);
}
if (key === 'luckyGiftPayoutRate') {
return calcPercent(record.luckyGiftPayout, record.luckyGiftTotalFlow);
}
if (key === 'gamePayoutRate') {
return calcPercent(record.gamePayout, record.gameTotalFlow);
}
if (key === 'gameProfitRate') {
return calcPercent(record.gameProfit, record.gameTotalFlow);
}
if (key === 'd1RetentionRate') {
return calcPercent(record.d1RetentionUser, record.d1RetentionBaseUser);
}
if (key === 'd7RetentionRate') {
return calcPercent(record.d7RetentionUser, record.d7RetentionBaseUser);
}
if (key === 'd30RetentionRate') {
return calcPercent(record.d30RetentionUser, record.d30RetentionBaseUser);
}
return 0;
}
function emptySummary(): SummaryMetric {
return {
countryNewUser: 0,
d1RetentionBaseUser: 0,
d1RetentionRate: 0,
d1RetentionUser: 0,
d7RetentionBaseUser: 0,
d7RetentionRate: 0,
d7RetentionUser: 0,
d30RetentionBaseUser: 0,
d30RetentionRate: 0,
d30RetentionUser: 0,
dailyActiveUser: 0,
dealerRecharge: 0,
gamePayout: 0,
gamePayoutRate: 0,
gameProfit: 0,
gameProfitRate: 0,
gameTotalFlow: 0,
gameUser: 0,
giftConsume: 0,
luckyGiftPayout: 0,
luckyGiftPayoutRate: 0,
luckyGiftProfit: 0,
luckyGiftProfitRate: 0,
luckyGiftTotalFlow: 0,
luckyGiftUser: 0,
luckyGiftUserRate: 0,
newUserRecharge: 0,
salaryExchange: 0,
totalRecharge: 0,
@ -354,19 +488,26 @@ 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[key] = toAmount(summary[key]) + toAmount(item[key] as number | string);
}
}
summary.luckyGiftUserRate = calcPercent(summary.luckyGiftUser, summary.dailyActiveUser);
summary.luckyGiftPayoutRate = calcPercent(summary.luckyGiftPayout, summary.luckyGiftTotalFlow);
summary.luckyGiftProfitRate = resolveLuckyGiftProfitRate(summary);
summary.gamePayoutRate = calcPercent(summary.gamePayout, summary.gameTotalFlow);
summary.gameProfitRate = calcPercent(summary.gameProfit, summary.gameTotalFlow);
summary.d1RetentionRate = calcPercent(summary.d1RetentionUser, summary.d1RetentionBaseUser);
summary.d7RetentionRate = calcPercent(summary.d7RetentionUser, summary.d7RetentionBaseUser);
summary.d30RetentionRate = calcPercent(summary.d30RetentionUser, summary.d30RetentionBaseUser);
return summary;
}
function formatSummaryValue(key: SummaryMetricKey, summary: SummaryMetric) {
if (key === 'countryNewUser') {
if (integerMetricColumns.has(key)) {
return formatInteger(summary[key]);
}
if (key === 'luckyGiftProfitRate') {
return formatPercent(summary[key]);
if (percentMetricColumns.has(key)) {
return formatPercent(resolveMetricRate(summary, key));
}
return formatAmount(summary[key]);
}
@ -379,11 +520,11 @@ function filterCountry(input: string, option?: Record<string, any>) {
}
function tableCellText(record: Record<string, any>, key: string) {
if (key === 'countryNewUser') {
return formatInteger(record.countryNewUser);
if (integerMetricColumns.has(key)) {
return formatInteger(record[key] as number | string);
}
if (key === 'luckyGiftProfitRate') {
return formatPercent(resolveLuckyGiftProfitRate(record));
if (percentMetricColumns.has(key)) {
return formatPercent(resolveMetricRate(record, key));
}
return formatAmount(record[key] as number | string);
}
@ -637,6 +778,35 @@ onBeforeUnmount(() => {
</Space>
</section>
<div class="metric-section-grid">
<section class="panel metric-section">
<div class="metric-section__title">幸运礼物 / 游戏</div>
<div class="metric-card-grid">
<div
v-for="item in luckyGameCards"
:key="item.label"
class="metric-card"
>
<div class="metric-card__label">{{ item.label }}</div>
<div class="metric-card__value">{{ item.value }}</div>
</div>
</div>
</section>
<section class="panel metric-section">
<div class="metric-section__title">APP 数据</div>
<div class="metric-card-grid metric-card-grid--app">
<div
v-for="item in appDataCards"
:key="item.label"
class="metric-card"
>
<div class="metric-card__label">{{ item.label }}</div>
<div class="metric-card__value">{{ item.value }}</div>
</div>
</div>
</section>
</div>
<section class="panel summary-table-panel">
<Table
:columns="summaryColumns"
@ -680,7 +850,7 @@ onBeforeUnmount(() => {
:data-source="records"
:pagination="{ pageSize: 20, showSizeChanger: true }"
:row-key="rowKey"
:scroll="{ x: 2060 }"
:scroll="{ x: tableScrollX }"
size="small"
>
<template #bodyCell="{ column, record }">
@ -783,6 +953,55 @@ onBeforeUnmount(() => {
padding: 14px;
}
.metric-section-grid {
display: grid;
gap: 16px;
grid-template-columns: minmax(0, 3fr) minmax(280px, 2fr);
}
.metric-section {
padding: 14px;
}
.metric-section__title {
color: #2dd4bf;
font-size: 15px;
font-weight: 700;
margin-bottom: 12px;
}
.metric-card-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.metric-card-grid--app {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metric-card {
background: #111827;
border: 1px solid rgba(45, 212, 191, 0.18);
border-radius: 8px;
min-height: 76px;
padding: 12px;
}
.metric-card__label {
color: #cbd5e1;
font-size: 12px;
line-height: 1.4;
}
.metric-card__value {
color: #f8fafc;
font-size: 20px;
font-weight: 700;
line-height: 1.6;
overflow-wrap: anywhere;
}
.summary-table-panel {
width: 100%;
}
@ -874,5 +1093,11 @@ onBeforeUnmount(() => {
.legacy-grid {
grid-template-columns: 1fr;
}
.metric-section-grid,
.metric-card-grid,
.metric-card-grid--app {
grid-template-columns: 1fr;
}
}
</style>