z
数据大屏,灵仙游戏增加
This commit is contained in:
parent
e02a6ff92f
commit
18d13b6172
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: '灵仙配置' },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
464
apps/src/views/operate/lingxian-config.vue
Normal file
464
apps/src/views/operate/lingxian-config.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user