Compare commits
6 Commits
200e1c6fad
...
0a8bddbea0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a8bddbea0 | ||
|
|
790b0f9f7a | ||
|
|
6eb24bdd99 | ||
|
|
2e816ed681 | ||
|
|
fc8d5dc12a | ||
|
|
7d33d8a5e4 |
@ -65,6 +65,25 @@ export interface CountryDashboardRechargeDetail {
|
|||||||
userNickname: string;
|
userNickname: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CountryDashboardGameMetric {
|
||||||
|
consumeAmount: number | string;
|
||||||
|
countryCode: string;
|
||||||
|
countryName: string;
|
||||||
|
gameId: string;
|
||||||
|
gameName: string;
|
||||||
|
gameProvider: string;
|
||||||
|
orderCount: number;
|
||||||
|
payoutAmount: number | string;
|
||||||
|
payoutRate: number | string;
|
||||||
|
periodKey: string;
|
||||||
|
periodName: string;
|
||||||
|
profitAmount: number | string;
|
||||||
|
profitRate: number | string;
|
||||||
|
refreshedAt: string;
|
||||||
|
statTimezone: string;
|
||||||
|
userCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export async function countryDashboard(params: Record<string, any> = {}) {
|
export async function countryDashboard(params: Record<string, any> = {}) {
|
||||||
return requestClient.get<CountryDashboardResult>('/datav/country-dashboard', {
|
return requestClient.get<CountryDashboardResult>('/datav/country-dashboard', {
|
||||||
params,
|
params,
|
||||||
@ -84,6 +103,43 @@ export async function countryDashboardRechargeDetails(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function countryDashboardGameMetrics(
|
||||||
|
params: Record<string, any> = {},
|
||||||
|
) {
|
||||||
|
return requestClient.get<{
|
||||||
|
current: number;
|
||||||
|
records: CountryDashboardGameMetric[];
|
||||||
|
size: number;
|
||||||
|
total: number;
|
||||||
|
}>('/datav/country-dashboard/game-metrics', {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countryDashboardGameSummaries(
|
||||||
|
params: Record<string, any> = {},
|
||||||
|
) {
|
||||||
|
return requestClient.get<{
|
||||||
|
current: number;
|
||||||
|
records: CountryDashboardGameMetric[];
|
||||||
|
size: number;
|
||||||
|
total: number;
|
||||||
|
}>('/datav/country-dashboard/game-metrics/games', {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countryDashboardGameCountryRank(
|
||||||
|
params: Record<string, any> = {},
|
||||||
|
) {
|
||||||
|
return requestClient.get<CountryDashboardGameMetric[]>(
|
||||||
|
'/datav/country-dashboard/game-metrics/country-rank',
|
||||||
|
{
|
||||||
|
params,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function latestActiveUserCountryCode(params: Record<string, any> = {}) {
|
export async function latestActiveUserCountryCode(params: Record<string, any> = {}) {
|
||||||
return requestClient.get<Array<Record<string, any>>>(
|
return requestClient.get<Array<Record<string, any>>>(
|
||||||
'/datav/active/user-country-code',
|
'/datav/active/user-country-code',
|
||||||
|
|||||||
@ -1,6 +1,24 @@
|
|||||||
import { requestClient } from '#/api/request';
|
import { requestClient } from '#/api/request';
|
||||||
import type { LegacyPageResult } from '#/api/legacy/system';
|
import type { LegacyPageResult } from '#/api/legacy/system';
|
||||||
|
|
||||||
|
const BINANCE_RECHARGE_BASE = '/go/payment/binance';
|
||||||
|
|
||||||
|
export async function getBinanceRechargeConfig(sysOrigin: string) {
|
||||||
|
return requestClient.get<Record<string, any>>(
|
||||||
|
`${BINANCE_RECHARGE_BASE}/config`,
|
||||||
|
{
|
||||||
|
params: { sysOrigin },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveBinanceRechargeConfig(data: Record<string, any>) {
|
||||||
|
return requestClient.post<Record<string, any>>(
|
||||||
|
`${BINANCE_RECHARGE_BASE}/config/save`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function listPayOpenCountry() {
|
export async function listPayOpenCountry() {
|
||||||
return requestClient.get<Array<Record<string, any>>>(
|
return requestClient.get<Array<Record<string, any>>>(
|
||||||
'/sys-pay-open-country/list',
|
'/sys-pay-open-country/list',
|
||||||
|
|||||||
@ -11,6 +11,7 @@ const VIP_BASE = '/go/resident-activity/vip';
|
|||||||
const RECHARGE_REWARD_BASE = '/go/resident-activity/recharge-reward';
|
const RECHARGE_REWARD_BASE = '/go/resident-activity/recharge-reward';
|
||||||
const TASK_CENTER_BASE = '/go/resident-activity/task-center';
|
const TASK_CENTER_BASE = '/go/resident-activity/task-center';
|
||||||
const VOICE_ROOM_RED_PACKET_BASE = '/go/resident-activity/voice-room-red-packet';
|
const VOICE_ROOM_RED_PACKET_BASE = '/go/resident-activity/voice-room-red-packet';
|
||||||
|
const VOICE_ROOM_ROCKET_BASE = '/go/resident-activity/voice-room-rocket';
|
||||||
|
|
||||||
export async function getResidentInviteConfig(sysOrigin: string) {
|
export async function getResidentInviteConfig(sysOrigin: string) {
|
||||||
return requestClient.get<Record<string, any>>('/resident-activity/invite/config', {
|
return requestClient.get<Record<string, any>>('/resident-activity/invite/config', {
|
||||||
@ -146,12 +147,6 @@ export async function pageResidentDailyTaskClaimRecords(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pageResidentDailyTaskEvents(params: Record<string, any>) {
|
|
||||||
return requestClient.get<Record<string, any>>(`${TASK_CENTER_BASE}/event/page`, {
|
|
||||||
params: { ...params, taskCategory: params.taskCategory || 'DAILY' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getVoiceRoomRedPacketConfig(sysOrigin: string) {
|
export async function getVoiceRoomRedPacketConfig(sysOrigin: string) {
|
||||||
return requestClient.get<Record<string, any>>(`${VOICE_ROOM_RED_PACKET_BASE}/config`, {
|
return requestClient.get<Record<string, any>>(`${VOICE_ROOM_RED_PACKET_BASE}/config`, {
|
||||||
params: { sysOrigin },
|
params: { sysOrigin },
|
||||||
@ -178,6 +173,68 @@ export async function retryVoiceRoomRedPacketRefund(packetNo: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getVoiceRoomRocketConfig(sysOrigin: string) {
|
||||||
|
return requestClient.get<Record<string, any>>(`${VOICE_ROOM_ROCKET_BASE}/config`, {
|
||||||
|
params: { sysOrigin },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveVoiceRoomRocketConfig(data: Record<string, any>) {
|
||||||
|
return requestClient.post<Record<string, any>>(
|
||||||
|
`${VOICE_ROOM_ROCKET_BASE}/config/save`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVoiceRoomRocketLevelConfigs(sysOrigin: string) {
|
||||||
|
return requestClient.get<Array<Record<string, any>>>(
|
||||||
|
`${VOICE_ROOM_ROCKET_BASE}/level-configs`,
|
||||||
|
{ params: { sysOrigin } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveVoiceRoomRocketLevelConfigs(data: Record<string, any>) {
|
||||||
|
return requestClient.post<Array<Record<string, any>>>(
|
||||||
|
`${VOICE_ROOM_ROCKET_BASE}/level-configs/save`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVoiceRoomRocketRewardConfigs(params: Record<string, any>) {
|
||||||
|
return requestClient.get<Array<Record<string, any>>>(
|
||||||
|
`${VOICE_ROOM_ROCKET_BASE}/reward-configs`,
|
||||||
|
{ params },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveVoiceRoomRocketRewardConfigs(data: Record<string, any>) {
|
||||||
|
return requestClient.post<Array<Record<string, any>>>(
|
||||||
|
`${VOICE_ROOM_ROCKET_BASE}/reward-configs/save`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pageVoiceRoomRocketLaunchRecords(params: Record<string, any>) {
|
||||||
|
return requestClient.get<Record<string, any>>(
|
||||||
|
`${VOICE_ROOM_ROCKET_BASE}/launch-records`,
|
||||||
|
{ params },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pageVoiceRoomRocketRewardRecords(params: Record<string, any>) {
|
||||||
|
return requestClient.get<Record<string, any>>(
|
||||||
|
`${VOICE_ROOM_ROCKET_BASE}/reward-records`,
|
||||||
|
{ params },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retryVoiceRoomRocketRewardRecord(id: number | string) {
|
||||||
|
return requestClient.post<Record<string, any>>(
|
||||||
|
`${VOICE_ROOM_ROCKET_BASE}/reward-records/retry`,
|
||||||
|
{ id },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function pageSpecifiedGiftWeeklyRankConfigs(params: Record<string, any>) {
|
export async function pageSpecifiedGiftWeeklyRankConfigs(params: Record<string, any>) {
|
||||||
return requestClient.get<LegacyPageResult<Record<string, any>>>(
|
return requestClient.get<LegacyPageResult<Record<string, any>>>(
|
||||||
`${SPECIFIED_GIFT_WEEKLY_RANK_BASE}/config/page`,
|
`${SPECIFIED_GIFT_WEEKLY_RANK_BASE}/config/page`,
|
||||||
|
|||||||
@ -25,6 +25,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('#/views/operate/pay-country.vue'),
|
component: () => import('#/views/operate/pay-country.vue'),
|
||||||
meta: { title: '开通支付国家' },
|
meta: { title: '开通支付国家' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'BinanceRechargeConfig',
|
||||||
|
path: 'binance/config',
|
||||||
|
component: () => import('#/views/operate/binance-recharge-config.vue'),
|
||||||
|
meta: { title: '币安配置' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'RegionRelation',
|
name: 'RegionRelation',
|
||||||
path: 'region/relation',
|
path: 'region/relation',
|
||||||
|
|||||||
@ -41,6 +41,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('#/views/resident-activity/voice-room-red-packet.vue'),
|
component: () => import('#/views/resident-activity/voice-room-red-packet.vue'),
|
||||||
meta: { title: '红包' },
|
meta: { title: '红包' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'ResidentVoiceRoomRocket',
|
||||||
|
path: 'voice-room-rocket',
|
||||||
|
component: () => import('#/views/resident-activity/voice-room-rocket.vue'),
|
||||||
|
meta: { title: '火箭' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'ResidentRegisterRewardConfig',
|
name: 'ResidentRegisterRewardConfig',
|
||||||
path: 'register-reward/config',
|
path: 'register-reward/config',
|
||||||
|
|||||||
368
apps/src/views/operate/binance-recharge-config.vue
Normal file
368
apps/src/views/operate/binance-recharge-config.vue
Normal file
@ -0,0 +1,368 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { Page } from '@vben/common-ui';
|
||||||
|
import { useAccessStore } from '@vben/stores';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getBinanceRechargeConfig,
|
||||||
|
saveBinanceRechargeConfig,
|
||||||
|
} from '#/api/legacy/pay';
|
||||||
|
import { getAllowedSysOrigins } from '#/views/system/shared';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Switch,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
message,
|
||||||
|
} from 'antdv-next';
|
||||||
|
|
||||||
|
defineOptions({ name: 'OperateBinanceRechargeConfig' });
|
||||||
|
|
||||||
|
let localRowId = 0;
|
||||||
|
|
||||||
|
function createRateRow(
|
||||||
|
minAmount = '0',
|
||||||
|
maxAmount = '0',
|
||||||
|
goldPerUsd = '80000',
|
||||||
|
) {
|
||||||
|
localRowId += 1;
|
||||||
|
return {
|
||||||
|
goldPerUsd,
|
||||||
|
maxAmount,
|
||||||
|
minAmount,
|
||||||
|
rowKey: `binance-rate-${localRowId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessStore = useAccessStore();
|
||||||
|
const sysOriginOptions = computed(() => {
|
||||||
|
const options = getAllowedSysOrigins(accessStore.accessCodes || []);
|
||||||
|
return options.length > 0 ? options : getAllowedSysOrigins([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedSysOrigin = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const form = reactive<Record<string, any>>({
|
||||||
|
apiKey: '',
|
||||||
|
apiSecret: '',
|
||||||
|
apiSecretConfigured: false,
|
||||||
|
enabled: true,
|
||||||
|
rates: [createRateRow()],
|
||||||
|
sysOrigin: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const rateColumns = [
|
||||||
|
{ dataIndex: 'minAmount', key: 'minAmount', title: '最小金额(含)', width: 180 },
|
||||||
|
{ dataIndex: 'maxAmount', key: 'maxAmount', title: '最大金额(不含)', width: 180 },
|
||||||
|
{ dataIndex: 'goldPerUsd', key: 'goldPerUsd', title: '1 USDT 对应金币', width: 200 },
|
||||||
|
{ dataIndex: 'actions', key: 'actions', title: '操作', width: 100 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusMeta = computed(() => {
|
||||||
|
if (!form.apiKey && !form.apiSecretConfigured) {
|
||||||
|
return { color: 'default', text: '未配置' };
|
||||||
|
}
|
||||||
|
if (form.enabled) {
|
||||||
|
return { color: 'success', text: '已启用' };
|
||||||
|
}
|
||||||
|
return { color: 'default', text: '已关闭' };
|
||||||
|
});
|
||||||
|
|
||||||
|
function normalizeDecimalText(value: unknown) {
|
||||||
|
return String(value ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConfig(result: null | Record<string, any> | undefined) {
|
||||||
|
const value = result || {};
|
||||||
|
form.sysOrigin = String(value.sysOrigin || selectedSysOrigin.value || '');
|
||||||
|
form.apiKey = String(value.apiKey || '');
|
||||||
|
form.apiSecret = '';
|
||||||
|
form.apiSecretConfigured = Boolean(value.apiSecretConfigured);
|
||||||
|
form.enabled = value.enabled !== false;
|
||||||
|
const rates = Array.isArray(value.rates) && value.rates.length > 0
|
||||||
|
? value.rates
|
||||||
|
: [createRateRow('0', '100', '80000'), createRateRow('100', '0', '82000')];
|
||||||
|
form.rates = rates.map((item: Record<string, any>) => ({
|
||||||
|
goldPerUsd: String(item.goldPerUsd ?? '80000'),
|
||||||
|
maxAmount: String(item.maxAmount ?? '0'),
|
||||||
|
minAmount: String(item.minAmount ?? '0'),
|
||||||
|
rowKey: item.rowKey || createRateRow().rowKey,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
if (!selectedSysOrigin.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await getBinanceRechargeConfig(selectedSysOrigin.value);
|
||||||
|
normalizeConfig(result);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRateRow() {
|
||||||
|
const rows = form.rates as Array<Record<string, any>>;
|
||||||
|
const last = rows.at(-1);
|
||||||
|
const nextMin = Number(last?.maxAmount || last?.minAmount || 0);
|
||||||
|
rows.push(createRateRow(String(Number.isFinite(nextMin) ? nextMin : 0), '0', '80000'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRateRow(index: number) {
|
||||||
|
(form.rates as Array<Record<string, any>>).splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDecimal(value: unknown) {
|
||||||
|
const text = normalizeDecimalText(value);
|
||||||
|
if (!text) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const numeric = Number(text);
|
||||||
|
return Number.isFinite(numeric);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBeforeSave() {
|
||||||
|
if (!selectedSysOrigin.value) {
|
||||||
|
message.warning('请选择系统');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!String(form.apiKey || '').trim()) {
|
||||||
|
message.warning('请输入 API Key');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!form.apiSecretConfigured && !String(form.apiSecret || '').trim()) {
|
||||||
|
message.warning('请输入 API 秘钥');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const rows = form.rates as Array<Record<string, any>>;
|
||||||
|
if (rows.length === 0) {
|
||||||
|
message.warning('请至少配置一个发金币区间');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const normalized = rows.map((item) => ({
|
||||||
|
goldPerUsd: normalizeDecimalText(item.goldPerUsd),
|
||||||
|
maxAmount: normalizeDecimalText(item.maxAmount || '0'),
|
||||||
|
minAmount: normalizeDecimalText(item.minAmount),
|
||||||
|
}));
|
||||||
|
for (const item of normalized) {
|
||||||
|
if (
|
||||||
|
!validateDecimal(item.minAmount) ||
|
||||||
|
!validateDecimal(item.maxAmount) ||
|
||||||
|
!validateDecimal(item.goldPerUsd)
|
||||||
|
) {
|
||||||
|
message.warning('金额和金币比例必须是数字');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const minAmount = Number(item.minAmount);
|
||||||
|
const maxAmount = Number(item.maxAmount);
|
||||||
|
const goldPerUsd = Number(item.goldPerUsd);
|
||||||
|
if (minAmount < 0 || maxAmount < 0) {
|
||||||
|
message.warning('金额区间不能小于 0');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (maxAmount > 0 && maxAmount <= minAmount) {
|
||||||
|
message.warning('最大金额必须大于最小金额,或填 0 表示不限制');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (goldPerUsd <= 0) {
|
||||||
|
message.warning('金币比例必须大于 0');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sorted = [...normalized].sort(
|
||||||
|
(left, right) => Number(left.minAmount) - Number(right.minAmount),
|
||||||
|
);
|
||||||
|
for (let index = 0; index < sorted.length; index += 1) {
|
||||||
|
const current = sorted[index];
|
||||||
|
const next = sorted[index + 1];
|
||||||
|
const currentMax = Number(current?.maxAmount || 0);
|
||||||
|
if (currentMax === 0 && index !== sorted.length - 1) {
|
||||||
|
message.warning('不限制最大金额的区间必须放在最后');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (next && currentMax > Number(next.minAmount)) {
|
||||||
|
message.warning('发金币区间不能重叠');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForm() {
|
||||||
|
if (!validateBeforeSave()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
await saveBinanceRechargeConfig({
|
||||||
|
apiKey: String(form.apiKey || '').trim(),
|
||||||
|
apiSecret: String(form.apiSecret || '').trim(),
|
||||||
|
enabled: Boolean(form.enabled),
|
||||||
|
rates: (form.rates as Array<Record<string, any>>).map((item) => ({
|
||||||
|
goldPerUsd: normalizeDecimalText(item.goldPerUsd),
|
||||||
|
maxAmount: normalizeDecimalText(item.maxAmount || '0'),
|
||||||
|
minAmount: normalizeDecimalText(item.minAmount),
|
||||||
|
})),
|
||||||
|
sysOrigin: selectedSysOrigin.value,
|
||||||
|
});
|
||||||
|
message.success('保存成功');
|
||||||
|
await loadConfig();
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
sysOriginOptions,
|
||||||
|
(options) => {
|
||||||
|
if (!selectedSysOrigin.value) {
|
||||||
|
selectedSysOrigin.value = String(options[0]?.value || '');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
selectedSysOrigin,
|
||||||
|
(value) => {
|
||||||
|
if (value) {
|
||||||
|
void loadConfig();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Page auto-content-height>
|
||||||
|
<Card :bordered="false" title="币安配置">
|
||||||
|
<div class="toolbar">
|
||||||
|
<Select
|
||||||
|
v-model:value="selectedSysOrigin"
|
||||||
|
:options="sysOriginOptions"
|
||||||
|
option-filter-prop="label"
|
||||||
|
option-label-prop="label"
|
||||||
|
placeholder="请选择系统"
|
||||||
|
show-search
|
||||||
|
style="width: 180px"
|
||||||
|
/>
|
||||||
|
<Tag :color="statusMeta.color">{{ statusMeta.text }}</Tag>
|
||||||
|
<Button :loading="loading" @click="loadConfig">刷新</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form layout="vertical">
|
||||||
|
<div class="form-grid">
|
||||||
|
<FormItem label="启用自动充值验证">
|
||||||
|
<Switch v-model:checked="form.enabled" />
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="API Key">
|
||||||
|
<Input
|
||||||
|
v-model:value="form.apiKey"
|
||||||
|
allow-clear
|
||||||
|
placeholder="请输入 Binance API Key"
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="API 秘钥">
|
||||||
|
<Input
|
||||||
|
v-model:value="form.apiSecret"
|
||||||
|
allow-clear
|
||||||
|
:placeholder="form.apiSecretConfigured ? '留空则不修改已保存秘钥' : '请输入 Binance API 秘钥'"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<div class="section-head">
|
||||||
|
<div class="section-title">发金币区间</div>
|
||||||
|
<Space>
|
||||||
|
<Button @click="addRateRow">新增区间</Button>
|
||||||
|
<Button :loading="saving" type="primary" @click="submitForm">保存配置</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
:columns="rateColumns"
|
||||||
|
:data-source="form.rates"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="false"
|
||||||
|
row-key="rowKey"
|
||||||
|
:scroll="{ x: 760 }"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, index, record }">
|
||||||
|
<template v-if="column.key === 'minAmount'">
|
||||||
|
<Input
|
||||||
|
v-model:value="record.minAmount"
|
||||||
|
placeholder="0"
|
||||||
|
style="width: 140px"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'maxAmount'">
|
||||||
|
<Input
|
||||||
|
v-model:value="record.maxAmount"
|
||||||
|
placeholder="0 表示不限制"
|
||||||
|
style="width: 140px"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'goldPerUsd'">
|
||||||
|
<Input
|
||||||
|
v-model:value="record.goldPerUsd"
|
||||||
|
placeholder="80000"
|
||||||
|
style="width: 160px"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'actions'">
|
||||||
|
<Button danger type="link" @click="removeRateRow(index)">删除</Button>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
</Page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(160px, 220px) minmax(240px, 1fr) minmax(240px, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin: 8px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -63,6 +63,7 @@ function buildIdentityTags() {
|
|||||||
['货运代理', target.freightAgent],
|
['货运代理', target.freightAgent],
|
||||||
['BD', target.bd],
|
['BD', target.bd],
|
||||||
['BD Leader', target.bdLeader],
|
['BD Leader', target.bdLeader],
|
||||||
|
['Yumi 经理', target.yumiManager || target.yumi_manager],
|
||||||
];
|
];
|
||||||
return pairs.filter(([, enabled]) => Boolean(enabled));
|
return pairs.filter(([, enabled]) => Boolean(enabled));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,7 +24,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
getResidentDailyTaskConfig,
|
getResidentDailyTaskConfig,
|
||||||
pageResidentDailyTaskClaimRecords,
|
pageResidentDailyTaskClaimRecords,
|
||||||
pageResidentDailyTaskEvents,
|
|
||||||
saveResidentDailyTaskConfig,
|
saveResidentDailyTaskConfig,
|
||||||
} from '#/api/legacy/resident-activity';
|
} from '#/api/legacy/resident-activity';
|
||||||
import { getAllowedSysOrigins } from '#/views/system/shared';
|
import { getAllowedSysOrigins } from '#/views/system/shared';
|
||||||
@ -143,17 +142,6 @@ const claimColumns = [
|
|||||||
{ dataIndex: 'createTime', key: 'createTime', title: '创建时间', width: 180 },
|
{ dataIndex: 'createTime', key: 'createTime', title: '创建时间', width: 180 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const eventColumns = [
|
|
||||||
{ dataIndex: 'eventId', key: 'eventId', title: '事件ID', width: 260 },
|
|
||||||
{ dataIndex: 'userId', key: 'userId', title: '用户ID', width: 140 },
|
|
||||||
{ dataIndex: 'eventType', key: 'eventType', title: '事件类型', width: 190 },
|
|
||||||
{ dataIndex: 'taskCode', key: 'taskCode', title: '命中任务', width: 220 },
|
|
||||||
{ dataIndex: 'deltaValue', key: 'deltaValue', title: '增量', width: 100 },
|
|
||||||
{ dataIndex: 'cycleKey', key: 'cycleKey', title: '周期', width: 130 },
|
|
||||||
{ dataIndex: 'occurredAt', key: 'occurredAt', title: '发生时间', width: 180 },
|
|
||||||
{ dataIndex: 'createTime', key: 'createTime', title: '入库时间', width: 180 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const accessStore = useAccessStore();
|
const accessStore = useAccessStore();
|
||||||
const sysOriginOptions = computed(() => {
|
const sysOriginOptions = computed(() => {
|
||||||
const options = getAllowedSysOrigins(accessStore.accessCodes || []);
|
const options = getAllowedSysOrigins(accessStore.accessCodes || []);
|
||||||
@ -166,7 +154,6 @@ const activeTab = ref('config');
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const claimLoading = ref(false);
|
const claimLoading = ref(false);
|
||||||
const eventLoading = ref(false);
|
|
||||||
|
|
||||||
const form = reactive<Record<string, any>>({
|
const form = reactive<Record<string, any>>({
|
||||||
conditionJumps: [],
|
conditionJumps: [],
|
||||||
@ -188,14 +175,6 @@ const claimQuery = reactive<Record<string, any>>({
|
|||||||
userId: '',
|
userId: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const eventQuery = reactive<Record<string, any>>({
|
|
||||||
cursor: 1,
|
|
||||||
eventType: '',
|
|
||||||
limit: 20,
|
|
||||||
taskCode: '',
|
|
||||||
userId: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const claimPage = reactive<Record<string, any>>({
|
const claimPage = reactive<Record<string, any>>({
|
||||||
current: 1,
|
current: 1,
|
||||||
records: [],
|
records: [],
|
||||||
@ -203,13 +182,6 @@ const claimPage = reactive<Record<string, any>>({
|
|||||||
total: 0,
|
total: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const eventPage = reactive<Record<string, any>>({
|
|
||||||
current: 1,
|
|
||||||
records: [],
|
|
||||||
size: 20,
|
|
||||||
total: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const statusMeta = computed(() => {
|
const statusMeta = computed(() => {
|
||||||
if (!form.configured) {
|
if (!form.configured) {
|
||||||
return { color: 'processing', text: '未配置' };
|
return { color: 'processing', text: '未配置' };
|
||||||
@ -590,45 +562,17 @@ async function loadClaimRecords() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadEvents() {
|
|
||||||
if (!selectedSysOrigin.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
eventLoading.value = true;
|
|
||||||
try {
|
|
||||||
const result = await pageResidentDailyTaskEvents({
|
|
||||||
...eventQuery,
|
|
||||||
sysOrigin: selectedSysOrigin.value,
|
|
||||||
taskCategory: activeTaskCategory.value,
|
|
||||||
});
|
|
||||||
normalizePage(eventPage, result || {});
|
|
||||||
} finally {
|
|
||||||
eventLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function searchClaimRecords() {
|
function searchClaimRecords() {
|
||||||
claimQuery.cursor = 1;
|
claimQuery.cursor = 1;
|
||||||
void loadClaimRecords();
|
void loadClaimRecords();
|
||||||
}
|
}
|
||||||
|
|
||||||
function searchEvents() {
|
|
||||||
eventQuery.cursor = 1;
|
|
||||||
void loadEvents();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClaimTableChange(pagination: Record<string, any>) {
|
function handleClaimTableChange(pagination: Record<string, any>) {
|
||||||
claimQuery.cursor = Number(pagination.current || 1);
|
claimQuery.cursor = Number(pagination.current || 1);
|
||||||
claimQuery.limit = Number(pagination.pageSize || 20);
|
claimQuery.limit = Number(pagination.pageSize || 20);
|
||||||
void loadClaimRecords();
|
void loadClaimRecords();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEventTableChange(pagination: Record<string, any>) {
|
|
||||||
eventQuery.cursor = Number(pagination.current || 1);
|
|
||||||
eventQuery.limit = Number(pagination.pageSize || 20);
|
|
||||||
void loadEvents();
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
sysOriginOptions,
|
sysOriginOptions,
|
||||||
(options) => {
|
(options) => {
|
||||||
@ -644,10 +588,8 @@ watch(
|
|||||||
(value) => {
|
(value) => {
|
||||||
if (value) {
|
if (value) {
|
||||||
claimQuery.cursor = 1;
|
claimQuery.cursor = 1;
|
||||||
eventQuery.cursor = 1;
|
|
||||||
void loadConfig();
|
void loadConfig();
|
||||||
void loadClaimRecords();
|
void loadClaimRecords();
|
||||||
void loadEvents();
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
@ -658,10 +600,8 @@ watch(activeTaskCategory, () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
claimQuery.cursor = 1;
|
claimQuery.cursor = 1;
|
||||||
eventQuery.cursor = 1;
|
|
||||||
void loadConfig();
|
void loadConfig();
|
||||||
void loadClaimRecords();
|
void loadClaimRecords();
|
||||||
void loadEvents();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -881,46 +821,6 @@ watch(activeTaskCategory, () => {
|
|||||||
@change="handleClaimTableChange"
|
@change="handleClaimTableChange"
|
||||||
/>
|
/>
|
||||||
</TabPane>
|
</TabPane>
|
||||||
|
|
||||||
<TabPane key="events" tab="事件流水">
|
|
||||||
<div class="toolbar">
|
|
||||||
<Input
|
|
||||||
v-model:value="eventQuery.userId"
|
|
||||||
allow-clear
|
|
||||||
placeholder="用户ID"
|
|
||||||
style="width: 160px"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
v-model:value="eventQuery.taskCode"
|
|
||||||
allow-clear
|
|
||||||
placeholder="任务编码"
|
|
||||||
style="width: 220px"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
v-model:value="eventQuery.eventType"
|
|
||||||
allow-clear
|
|
||||||
placeholder="事件类型"
|
|
||||||
style="width: 220px"
|
|
||||||
/>
|
|
||||||
<Button :loading="eventLoading" type="primary" @click="searchEvents">
|
|
||||||
查询
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Table
|
|
||||||
:columns="eventColumns"
|
|
||||||
:data-source="eventPage.records"
|
|
||||||
:loading="eventLoading"
|
|
||||||
:pagination="{
|
|
||||||
current: eventPage.current,
|
|
||||||
pageSize: eventPage.size,
|
|
||||||
showSizeChanger: true,
|
|
||||||
total: eventPage.total,
|
|
||||||
}"
|
|
||||||
row-key="id"
|
|
||||||
:scroll="{ x: 1500 }"
|
|
||||||
@change="handleEventTableChange"
|
|
||||||
/>
|
|
||||||
</TabPane>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Card>
|
</Card>
|
||||||
</Spin>
|
</Spin>
|
||||||
|
|||||||
846
apps/src/views/resident-activity/voice-room-rocket.vue
Normal file
846
apps/src/views/resident-activity/voice-room-rocket.vue
Normal file
@ -0,0 +1,846 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { Page } from '@vben/common-ui';
|
||||||
|
import { useAccessStore } from '@vben/stores';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Switch,
|
||||||
|
Table,
|
||||||
|
Tabs,
|
||||||
|
TabPane,
|
||||||
|
Tag,
|
||||||
|
TextArea,
|
||||||
|
message,
|
||||||
|
} from 'antdv-next';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getVoiceRoomRocketConfig,
|
||||||
|
getVoiceRoomRocketLevelConfigs,
|
||||||
|
getVoiceRoomRocketRewardConfigs,
|
||||||
|
saveVoiceRoomRocketConfig,
|
||||||
|
saveVoiceRoomRocketLevelConfigs,
|
||||||
|
saveVoiceRoomRocketRewardConfigs,
|
||||||
|
} from '#/api/legacy/resident-activity';
|
||||||
|
import { getAllowedSysOrigins } from '#/views/system/shared';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ResidentVoiceRoomRocketPage' });
|
||||||
|
|
||||||
|
const RIYADH_TIMEZONE = 'Asia/Riyadh';
|
||||||
|
const DEFAULT_MAX_LEVEL = 6;
|
||||||
|
|
||||||
|
type LevelRow = {
|
||||||
|
enabled: boolean;
|
||||||
|
id?: number | string;
|
||||||
|
level: number;
|
||||||
|
needEnergy: number;
|
||||||
|
progressBarUrl: string;
|
||||||
|
rocketAnimationUrl: string;
|
||||||
|
rocketIconUrl: string;
|
||||||
|
shakeThresholdPercent: string;
|
||||||
|
sort: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RewardRow = {
|
||||||
|
enabled: boolean;
|
||||||
|
expireDays?: null | number;
|
||||||
|
id?: number | string;
|
||||||
|
localKey: string;
|
||||||
|
level: number;
|
||||||
|
rewardAmount: number;
|
||||||
|
rewardCover: string;
|
||||||
|
rewardItemId?: null | number | string;
|
||||||
|
rewardName: string;
|
||||||
|
rewardScene: string;
|
||||||
|
rewardType: string;
|
||||||
|
sort: number;
|
||||||
|
weight: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const accessStore = useAccessStore();
|
||||||
|
const sysOriginOptions = computed(() => {
|
||||||
|
const options = getAllowedSysOrigins(accessStore.accessCodes || []);
|
||||||
|
return options.length > 0 ? options : getAllowedSysOrigins([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedSysOrigin = ref('');
|
||||||
|
const activeTab = ref('config');
|
||||||
|
const rewardLevel = ref(1);
|
||||||
|
|
||||||
|
const configLoading = ref(false);
|
||||||
|
const levelLoading = ref(false);
|
||||||
|
const rewardLoading = ref(false);
|
||||||
|
const configSaving = ref(false);
|
||||||
|
const levelSaving = ref(false);
|
||||||
|
const rewardSaving = ref(false);
|
||||||
|
|
||||||
|
const configForm = reactive({
|
||||||
|
broadcastDurationSeconds: 7,
|
||||||
|
configured: false,
|
||||||
|
enabled: false,
|
||||||
|
energyRuleJsonText: '{}',
|
||||||
|
id: '',
|
||||||
|
inRoomRewardDelaySeconds: 10,
|
||||||
|
maxLevel: DEFAULT_MAX_LEVEL,
|
||||||
|
rankingTopLimit: 100,
|
||||||
|
rewardRecordKeepDays: 35,
|
||||||
|
ruleTextText: '{}',
|
||||||
|
timezone: RIYADH_TIMEZONE,
|
||||||
|
updateTime: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const levelRows = ref<LevelRow[]>([]);
|
||||||
|
const rewardRows = ref<RewardRow[]>([]);
|
||||||
|
|
||||||
|
const sceneOptions = [
|
||||||
|
{ label: '贡献第一', value: 'TOP1' },
|
||||||
|
{ label: '点火人', value: 'IGNITE' },
|
||||||
|
{ label: '在房用户', value: 'IN_ROOM' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const rewardTypeOptions = [
|
||||||
|
{ label: '金币', value: 'GOLD' },
|
||||||
|
{ label: '坐骑', value: 'MOUNT' },
|
||||||
|
{ label: '头像框', value: 'AVATAR_FRAME' },
|
||||||
|
{ label: '气泡', value: 'CHAT_BUBBLE' },
|
||||||
|
{ label: 'VIP', value: 'VIP_CARD' },
|
||||||
|
{ label: '礼物', value: 'GIFT' },
|
||||||
|
{ label: '徽章', value: 'BADGE' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const levelOptions = computed(() =>
|
||||||
|
Array.from({ length: configForm.maxLevel || DEFAULT_MAX_LEVEL }, (_, index) => ({
|
||||||
|
label: `Lv.${index + 1}`,
|
||||||
|
value: index + 1,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusMeta = computed(() => {
|
||||||
|
if (!configForm.configured) {
|
||||||
|
return { color: 'warning', text: '未配置' };
|
||||||
|
}
|
||||||
|
return configForm.enabled
|
||||||
|
? { color: 'success', text: '已启用' }
|
||||||
|
: { color: 'default', text: '已关闭' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const levelColumns = [
|
||||||
|
{ dataIndex: 'level', key: 'level', title: '等级', width: 90 },
|
||||||
|
{ dataIndex: 'needEnergy', key: 'needEnergy', title: '所需能量', width: 160 },
|
||||||
|
{ dataIndex: 'shakeThresholdPercent', key: 'shakeThresholdPercent', title: '晃动阈值(%)', width: 160 },
|
||||||
|
{ dataIndex: 'rocketIconUrl', key: 'rocketIconUrl', title: '火箭图标', width: 260 },
|
||||||
|
{ dataIndex: 'rocketAnimationUrl', key: 'rocketAnimationUrl', title: '发射动画', width: 260 },
|
||||||
|
{ dataIndex: 'progressBarUrl', key: 'progressBarUrl', title: '进度条资源', width: 260 },
|
||||||
|
{ dataIndex: 'enabled', key: 'enabled', title: '启用', width: 90 },
|
||||||
|
{ dataIndex: 'sort', key: 'sort', title: '排序', width: 100 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const rewardColumns = [
|
||||||
|
{ dataIndex: 'rewardScene', key: 'rewardScene', title: '奖励场景', width: 150 },
|
||||||
|
{ dataIndex: 'rewardType', key: 'rewardType', title: '奖励类型', width: 150 },
|
||||||
|
{ dataIndex: 'rewardItemId', key: 'rewardItemId', title: '资源ID', width: 170 },
|
||||||
|
{ dataIndex: 'rewardName', key: 'rewardName', title: '奖励名称', width: 180 },
|
||||||
|
{ dataIndex: 'rewardCover', key: 'rewardCover', title: '封面URL', width: 220 },
|
||||||
|
{ dataIndex: 'rewardAmount', key: 'rewardAmount', title: '数量/金币/天数', width: 150 },
|
||||||
|
{ dataIndex: 'expireDays', key: 'expireDays', title: '有效天数', width: 130 },
|
||||||
|
{ dataIndex: 'weight', key: 'weight', title: '权重', width: 110 },
|
||||||
|
{ dataIndex: 'enabled', key: 'enabled', title: '启用', width: 90 },
|
||||||
|
{ dataIndex: 'sort', key: 'sort', title: '排序', width: 100 },
|
||||||
|
{ dataIndex: 'actions', key: 'actions', title: '操作', width: 100 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function createDefaultLevel(level: number): LevelRow {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
level,
|
||||||
|
needEnergy: level * 100,
|
||||||
|
progressBarUrl: '',
|
||||||
|
rocketAnimationUrl: '',
|
||||||
|
rocketIconUrl: '',
|
||||||
|
shakeThresholdPercent: '95.0000',
|
||||||
|
sort: level,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyJSON(value: unknown) {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return '{}';
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value.trim() || '{}';
|
||||||
|
}
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJSONText(value: string, label: string) {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
message.warning(`${label} 必须是合法 JSON`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumber(value: unknown, fallback = 0) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOptionalNumber(value: unknown) {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOptionalString(value: unknown) {
|
||||||
|
const text = String(value ?? '').trim();
|
||||||
|
return text || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConfig(value: Record<string, any> | null | undefined) {
|
||||||
|
const next = value || {};
|
||||||
|
configForm.broadcastDurationSeconds = toNumber(
|
||||||
|
next.broadcastDurationSeconds,
|
||||||
|
7,
|
||||||
|
);
|
||||||
|
configForm.configured = Boolean(next.configured);
|
||||||
|
configForm.enabled = Boolean(next.enabled);
|
||||||
|
configForm.energyRuleJsonText = stringifyJSON(next.energyRuleJson);
|
||||||
|
configForm.id = String(next.id || '');
|
||||||
|
configForm.inRoomRewardDelaySeconds = toNumber(
|
||||||
|
next.inRoomRewardDelaySeconds,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
configForm.maxLevel = toNumber(next.maxLevel, DEFAULT_MAX_LEVEL);
|
||||||
|
configForm.rankingTopLimit = toNumber(next.rankingTopLimit, 100);
|
||||||
|
configForm.rewardRecordKeepDays = toNumber(next.rewardRecordKeepDays, 35);
|
||||||
|
configForm.ruleTextText = stringifyJSON(next.ruleText);
|
||||||
|
configForm.timezone = RIYADH_TIMEZONE;
|
||||||
|
configForm.updateTime = String(next.updateTime || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLevelRows(rows: Array<Record<string, any>> | undefined) {
|
||||||
|
const source = Array.isArray(rows) ? rows : [];
|
||||||
|
const byLevel = new Map<number, Record<string, any>>();
|
||||||
|
source.forEach((item) => {
|
||||||
|
byLevel.set(toNumber(item.level), item);
|
||||||
|
});
|
||||||
|
levelRows.value = Array.from({ length: DEFAULT_MAX_LEVEL }, (_, index) => {
|
||||||
|
const level = index + 1;
|
||||||
|
const row = byLevel.get(level) || {};
|
||||||
|
return {
|
||||||
|
...createDefaultLevel(level),
|
||||||
|
enabled: row.enabled === undefined ? true : Boolean(row.enabled),
|
||||||
|
id: row.id,
|
||||||
|
needEnergy: toNumber(row.needEnergy, level * 100),
|
||||||
|
progressBarUrl: String(row.progressBarUrl || ''),
|
||||||
|
rocketAnimationUrl: String(row.rocketAnimationUrl || ''),
|
||||||
|
rocketIconUrl: String(row.rocketIconUrl || ''),
|
||||||
|
shakeThresholdPercent: String(row.shakeThresholdPercent || '95.0000'),
|
||||||
|
sort: toNumber(row.sort, level),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRewardRows(rows: Array<Record<string, any>> | undefined) {
|
||||||
|
rewardRows.value = (Array.isArray(rows) ? rows : []).map((row, index) => ({
|
||||||
|
enabled: row.enabled === undefined ? true : Boolean(row.enabled),
|
||||||
|
expireDays: toOptionalNumber(row.expireDays) ?? null,
|
||||||
|
id: row.id,
|
||||||
|
level: toNumber(row.level, rewardLevel.value),
|
||||||
|
localKey: `${row.id || 'new'}-${index}-${Date.now()}`,
|
||||||
|
rewardAmount: toNumber(row.rewardAmount, 1),
|
||||||
|
rewardCover: String(row.rewardCover || ''),
|
||||||
|
rewardItemId: row.rewardItemId ?? null,
|
||||||
|
rewardName: String(row.rewardName || ''),
|
||||||
|
rewardScene: String(row.rewardScene || 'TOP1'),
|
||||||
|
rewardType: String(row.rewardType || 'GOLD'),
|
||||||
|
sort: toNumber(row.sort, index + 1),
|
||||||
|
weight: toNumber(row.weight, 1),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
if (!selectedSysOrigin.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
configLoading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await getVoiceRoomRocketConfig(selectedSysOrigin.value);
|
||||||
|
normalizeConfig(result);
|
||||||
|
} finally {
|
||||||
|
configLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLevels() {
|
||||||
|
if (!selectedSysOrigin.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
levelLoading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await getVoiceRoomRocketLevelConfigs(selectedSysOrigin.value);
|
||||||
|
normalizeLevelRows(result);
|
||||||
|
} finally {
|
||||||
|
levelLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRewards() {
|
||||||
|
if (!selectedSysOrigin.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rewardLoading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await getVoiceRoomRocketRewardConfigs({
|
||||||
|
level: rewardLevel.value,
|
||||||
|
sysOrigin: selectedSysOrigin.value,
|
||||||
|
});
|
||||||
|
normalizeRewardRows(result);
|
||||||
|
} finally {
|
||||||
|
rewardLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
await Promise.all([loadConfig(), loadLevels(), loadRewards()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig() {
|
||||||
|
const energyRuleJson = parseJSONText(
|
||||||
|
configForm.energyRuleJsonText,
|
||||||
|
'能量规则',
|
||||||
|
);
|
||||||
|
if (energyRuleJson === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ruleText = parseJSONText(configForm.ruleTextText, '规则说明');
|
||||||
|
if (ruleText === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
configSaving.value = true;
|
||||||
|
try {
|
||||||
|
const result = await saveVoiceRoomRocketConfig({
|
||||||
|
broadcastDurationSeconds: configForm.broadcastDurationSeconds,
|
||||||
|
enabled: configForm.enabled,
|
||||||
|
energyRuleJson,
|
||||||
|
inRoomRewardDelaySeconds: configForm.inRoomRewardDelaySeconds,
|
||||||
|
maxLevel: DEFAULT_MAX_LEVEL,
|
||||||
|
rankingTopLimit: configForm.rankingTopLimit,
|
||||||
|
rewardRecordKeepDays: configForm.rewardRecordKeepDays,
|
||||||
|
ruleText,
|
||||||
|
sysOrigin: selectedSysOrigin.value,
|
||||||
|
timezone: RIYADH_TIMEZONE,
|
||||||
|
});
|
||||||
|
normalizeConfig(result);
|
||||||
|
message.success('保存成功');
|
||||||
|
} finally {
|
||||||
|
configSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLevels() {
|
||||||
|
levelSaving.value = true;
|
||||||
|
try {
|
||||||
|
const result = await saveVoiceRoomRocketLevelConfigs({
|
||||||
|
levels: levelRows.value.map((row) => ({
|
||||||
|
enabled: row.enabled,
|
||||||
|
id: row.id,
|
||||||
|
level: row.level,
|
||||||
|
needEnergy: toNumber(row.needEnergy, row.level * 100),
|
||||||
|
progressBarUrl: row.progressBarUrl,
|
||||||
|
rocketAnimationUrl: row.rocketAnimationUrl,
|
||||||
|
rocketIconUrl: row.rocketIconUrl,
|
||||||
|
shakeThresholdPercent: row.shakeThresholdPercent,
|
||||||
|
sort: toNumber(row.sort, row.level),
|
||||||
|
})),
|
||||||
|
sysOrigin: selectedSysOrigin.value,
|
||||||
|
});
|
||||||
|
normalizeLevelRows(result);
|
||||||
|
message.success('保存成功');
|
||||||
|
} finally {
|
||||||
|
levelSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRewardRow(scene = 'TOP1') {
|
||||||
|
rewardRows.value.push({
|
||||||
|
enabled: true,
|
||||||
|
expireDays: null,
|
||||||
|
level: rewardLevel.value,
|
||||||
|
localKey: `new-${Date.now()}-${rewardRows.value.length}`,
|
||||||
|
rewardAmount: 1,
|
||||||
|
rewardCover: '',
|
||||||
|
rewardItemId: null,
|
||||||
|
rewardName: scene === 'TOP1' ? '金币' : '',
|
||||||
|
rewardScene: scene,
|
||||||
|
rewardType: 'GOLD',
|
||||||
|
sort: rewardRows.value.length + 1,
|
||||||
|
weight: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRewardRow(index: number) {
|
||||||
|
rewardRows.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRewardTypeChange(row: Record<string, any>) {
|
||||||
|
if (row.rewardType === 'GOLD') {
|
||||||
|
row.rewardItemId = null;
|
||||||
|
row.expireDays = null;
|
||||||
|
if (!row.rewardName) {
|
||||||
|
row.rewardName = '金币';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRewards() {
|
||||||
|
for (const row of rewardRows.value) {
|
||||||
|
if (!row.rewardScene) {
|
||||||
|
message.warning('请选择奖励场景');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!row.rewardType) {
|
||||||
|
message.warning('请选择奖励类型');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!String(row.rewardName || '').trim()) {
|
||||||
|
message.warning('请输入奖励名称');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (row.rewardType !== 'GOLD' && !String(row.rewardItemId || '').trim()) {
|
||||||
|
message.warning('非金币奖励必须填写资源ID');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRewards() {
|
||||||
|
if (!validateRewards()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rewardSaving.value = true;
|
||||||
|
try {
|
||||||
|
const result = await saveVoiceRoomRocketRewardConfigs({
|
||||||
|
level: rewardLevel.value,
|
||||||
|
rewards: rewardRows.value.map((row) => ({
|
||||||
|
enabled: row.enabled,
|
||||||
|
expireDays: toOptionalNumber(row.expireDays),
|
||||||
|
id: row.id,
|
||||||
|
level: rewardLevel.value,
|
||||||
|
rewardAmount: toNumber(row.rewardAmount, 1),
|
||||||
|
rewardCover: row.rewardCover,
|
||||||
|
rewardItemId:
|
||||||
|
row.rewardType === 'GOLD' ? undefined : toOptionalString(row.rewardItemId),
|
||||||
|
rewardName: row.rewardName.trim(),
|
||||||
|
rewardScene: row.rewardScene,
|
||||||
|
rewardType: row.rewardType,
|
||||||
|
sort: toNumber(row.sort, 1),
|
||||||
|
weight: toNumber(row.weight, 1),
|
||||||
|
})),
|
||||||
|
sysOrigin: selectedSysOrigin.value,
|
||||||
|
});
|
||||||
|
normalizeRewardRows(result);
|
||||||
|
message.success('保存成功');
|
||||||
|
} finally {
|
||||||
|
rewardSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
sysOriginOptions,
|
||||||
|
(options) => {
|
||||||
|
if (!selectedSysOrigin.value && options[0]?.value) {
|
||||||
|
selectedSysOrigin.value = String(options[0].value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
selectedSysOrigin,
|
||||||
|
() => {
|
||||||
|
void loadAll();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(rewardLevel, () => {
|
||||||
|
void loadRewards();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Page title="火箭">
|
||||||
|
<div class="voice-room-rocket-page">
|
||||||
|
<Card>
|
||||||
|
<Space wrap>
|
||||||
|
<span>系统</span>
|
||||||
|
<Select
|
||||||
|
v-model:value="selectedSysOrigin"
|
||||||
|
:options="sysOriginOptions"
|
||||||
|
style="width: 180px"
|
||||||
|
/>
|
||||||
|
<Tag :color="statusMeta.color">{{ statusMeta.text }}</Tag>
|
||||||
|
<Tag color="processing">利雅得时间每日 00:00 重置</Tag>
|
||||||
|
<span v-if="configForm.updateTime" class="muted-text">
|
||||||
|
更新时间:{{ configForm.updateTime }}
|
||||||
|
</span>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Tabs v-model:active-key="activeTab">
|
||||||
|
<TabPane key="config" tab="基础配置">
|
||||||
|
<Spin :spinning="configLoading">
|
||||||
|
<Card>
|
||||||
|
<div class="config-grid">
|
||||||
|
<label class="field-row">
|
||||||
|
<span>启用火箭</span>
|
||||||
|
<Switch v-model:checked="configForm.enabled" />
|
||||||
|
</label>
|
||||||
|
<label class="field-row">
|
||||||
|
<span>活动时区</span>
|
||||||
|
<Input
|
||||||
|
v-model:value="configForm.timezone"
|
||||||
|
class="field-control"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field-row">
|
||||||
|
<span>最大等级</span>
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="configForm.maxLevel"
|
||||||
|
:min="1"
|
||||||
|
:max="6"
|
||||||
|
:precision="0"
|
||||||
|
class="field-control"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field-row">
|
||||||
|
<span>榜单人数</span>
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="configForm.rankingTopLimit"
|
||||||
|
:min="1"
|
||||||
|
:max="100"
|
||||||
|
:precision="0"
|
||||||
|
class="field-control"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field-row">
|
||||||
|
<span>发奖记录保留天数</span>
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="configForm.rewardRecordKeepDays"
|
||||||
|
:min="1"
|
||||||
|
:precision="0"
|
||||||
|
class="field-control"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field-row">
|
||||||
|
<span>发射广播秒数</span>
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="configForm.broadcastDurationSeconds"
|
||||||
|
:min="1"
|
||||||
|
:precision="0"
|
||||||
|
class="field-control"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field-row">
|
||||||
|
<span>在房奖励延迟秒数</span>
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="configForm.inRoomRewardDelaySeconds"
|
||||||
|
:min="1"
|
||||||
|
:precision="0"
|
||||||
|
class="field-control"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field-row field-wide">
|
||||||
|
<span>能量规则 JSON</span>
|
||||||
|
<TextArea
|
||||||
|
v-model:value="configForm.energyRuleJsonText"
|
||||||
|
:rows="4"
|
||||||
|
class="field-control"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field-row field-wide">
|
||||||
|
<span>规则说明 JSON</span>
|
||||||
|
<TextArea
|
||||||
|
v-model:value="configForm.ruleTextText"
|
||||||
|
:rows="4"
|
||||||
|
class="field-control"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<Space>
|
||||||
|
<Button @click="loadConfig">刷新</Button>
|
||||||
|
<Button type="primary" :loading="configSaving" @click="saveConfig">
|
||||||
|
保存基础配置
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Spin>
|
||||||
|
</TabPane>
|
||||||
|
|
||||||
|
<TabPane key="levels" tab="等级进度">
|
||||||
|
<Card>
|
||||||
|
<Table
|
||||||
|
:columns="levelColumns"
|
||||||
|
:data-source="levelRows"
|
||||||
|
:loading="levelLoading"
|
||||||
|
:pagination="false"
|
||||||
|
:scroll="{ x: 1420 }"
|
||||||
|
row-key="level"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'level'">
|
||||||
|
<Tag color="blue">Lv.{{ record.level }}</Tag>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'needEnergy'">
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="record.needEnergy"
|
||||||
|
:min="1"
|
||||||
|
:precision="0"
|
||||||
|
class="table-number"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'shakeThresholdPercent'">
|
||||||
|
<Input
|
||||||
|
v-model:value="record.shakeThresholdPercent"
|
||||||
|
class="table-input"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'rocketIconUrl'">
|
||||||
|
<Input v-model:value="record.rocketIconUrl" class="table-input" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'rocketAnimationUrl'">
|
||||||
|
<Input
|
||||||
|
v-model:value="record.rocketAnimationUrl"
|
||||||
|
class="table-input"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'progressBarUrl'">
|
||||||
|
<Input v-model:value="record.progressBarUrl" class="table-input" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'enabled'">
|
||||||
|
<Switch v-model:checked="record.enabled" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'sort'">
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="record.sort"
|
||||||
|
:min="1"
|
||||||
|
:precision="0"
|
||||||
|
class="table-number"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<Space>
|
||||||
|
<Button @click="loadLevels">刷新</Button>
|
||||||
|
<Button type="primary" :loading="levelSaving" @click="saveLevels">
|
||||||
|
保存等级进度
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</TabPane>
|
||||||
|
|
||||||
|
<TabPane key="rewards" tab="奖励配置">
|
||||||
|
<Card>
|
||||||
|
<Space class="reward-toolbar" wrap>
|
||||||
|
<span>等级</span>
|
||||||
|
<Select
|
||||||
|
v-model:value="rewardLevel"
|
||||||
|
:options="levelOptions"
|
||||||
|
style="width: 120px"
|
||||||
|
/>
|
||||||
|
<Button @click="loadRewards">刷新</Button>
|
||||||
|
<Button @click="addRewardRow('TOP1')">添加贡献第一</Button>
|
||||||
|
<Button @click="addRewardRow('IGNITE')">添加点火人</Button>
|
||||||
|
<Button @click="addRewardRow('IN_ROOM')">添加在房用户</Button>
|
||||||
|
<Button type="primary" :loading="rewardSaving" @click="saveRewards">
|
||||||
|
保存当前等级奖励
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
:columns="rewardColumns"
|
||||||
|
:data-source="rewardRows"
|
||||||
|
:loading="rewardLoading"
|
||||||
|
:pagination="false"
|
||||||
|
:scroll="{ x: 1660 }"
|
||||||
|
row-key="localKey"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record, index }">
|
||||||
|
<template v-if="column.key === 'rewardScene'">
|
||||||
|
<Select
|
||||||
|
v-model:value="record.rewardScene"
|
||||||
|
:options="sceneOptions"
|
||||||
|
class="table-select"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'rewardType'">
|
||||||
|
<Select
|
||||||
|
v-model:value="record.rewardType"
|
||||||
|
:options="rewardTypeOptions"
|
||||||
|
class="table-select"
|
||||||
|
@change="() => handleRewardTypeChange(record)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'rewardItemId'">
|
||||||
|
<Input
|
||||||
|
v-model:value="record.rewardItemId"
|
||||||
|
:disabled="record.rewardType === 'GOLD'"
|
||||||
|
class="table-input-small"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'rewardName'">
|
||||||
|
<Input v-model:value="record.rewardName" class="table-input-small" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'rewardCover'">
|
||||||
|
<Input v-model:value="record.rewardCover" class="table-input" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'rewardAmount'">
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="record.rewardAmount"
|
||||||
|
:min="1"
|
||||||
|
:precision="0"
|
||||||
|
class="table-number"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'expireDays'">
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="record.expireDays"
|
||||||
|
:disabled="record.rewardType === 'GOLD'"
|
||||||
|
:min="0"
|
||||||
|
:precision="0"
|
||||||
|
class="table-number"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'weight'">
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="record.weight"
|
||||||
|
:min="0"
|
||||||
|
:precision="0"
|
||||||
|
class="table-number"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'enabled'">
|
||||||
|
<Switch v-model:checked="record.enabled" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'sort'">
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="record.sort"
|
||||||
|
:min="1"
|
||||||
|
:precision="0"
|
||||||
|
class="table-number"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'actions'">
|
||||||
|
<Button danger size="small" @click="removeRewardRow(index)">
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<div class="reward-summary">
|
||||||
|
<Tag v-for="scene in sceneOptions" :key="scene.value">
|
||||||
|
{{ scene.label }}:
|
||||||
|
{{
|
||||||
|
rewardRows.filter((item) => item.rewardScene === scene.value).length
|
||||||
|
}}
|
||||||
|
</Tag>
|
||||||
|
<Tag v-for="type in rewardTypeOptions" :key="type.value">
|
||||||
|
{{ type.label }}:
|
||||||
|
{{ rewardRows.filter((item) => item.rewardType === type.value).length }}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</TabPane>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</Page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.voice-room-rocket-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-control {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted-text {
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-toolbar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-input {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-input-small,
|
||||||
|
.table-select {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-number {
|
||||||
|
width: 110px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -2,6 +2,7 @@
|
|||||||
import type { EchartsUIType } from '@vben/plugins/echarts';
|
import type { EchartsUIType } from '@vben/plugins/echarts';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
CountryDashboardGameMetric,
|
||||||
CountryDashboardMetric,
|
CountryDashboardMetric,
|
||||||
CountryDashboardRechargeDetail,
|
CountryDashboardRechargeDetail,
|
||||||
CountryDashboardResult,
|
CountryDashboardResult,
|
||||||
@ -24,13 +25,14 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
DateRangePicker,
|
DateRangePicker,
|
||||||
Empty,
|
Empty,
|
||||||
|
Input,
|
||||||
message,
|
message,
|
||||||
Modal,
|
Modal,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Spin,
|
Spin,
|
||||||
TabPane,
|
|
||||||
Table,
|
Table,
|
||||||
|
TabPane,
|
||||||
Tabs,
|
Tabs,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from 'antdv-next';
|
} from 'antdv-next';
|
||||||
@ -40,6 +42,8 @@ import utc from 'dayjs/plugin/utc';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
countryDashboard,
|
countryDashboard,
|
||||||
|
countryDashboardGameCountryRank,
|
||||||
|
countryDashboardGameSummaries,
|
||||||
countryDashboardRechargeDetails,
|
countryDashboardRechargeDetails,
|
||||||
} from '#/api/legacy/datav';
|
} from '#/api/legacy/datav';
|
||||||
import SysOriginSelect from '#/components/sys-origin-select.vue';
|
import SysOriginSelect from '#/components/sys-origin-select.vue';
|
||||||
@ -62,7 +66,7 @@ const fullscreen = ref(false);
|
|||||||
const visualMode = ref(false);
|
const visualMode = ref(false);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const dashboard = ref<CountryDashboardResult | null>(null);
|
const dashboard = ref<CountryDashboardResult | null>(null);
|
||||||
const activeTableTab = ref<'overview' | 'recharge'>('overview');
|
const activeTableTab = ref<'game' | 'overview' | 'recharge'>('overview');
|
||||||
const arpuDashboards = ref<{
|
const arpuDashboards = ref<{
|
||||||
day: CountryDashboardResult | null;
|
day: CountryDashboardResult | null;
|
||||||
threeDay: CountryDashboardResult | null;
|
threeDay: CountryDashboardResult | null;
|
||||||
@ -86,6 +90,31 @@ const rechargeDetailPagination = reactive({
|
|||||||
pageSize: 100,
|
pageSize: 100,
|
||||||
total: 0,
|
total: 0,
|
||||||
});
|
});
|
||||||
|
const gameMetricLoading = ref(false);
|
||||||
|
const gameMetricRecords = ref<CountryDashboardGameMetric[]>([]);
|
||||||
|
const gameMetricPagination = reactive({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
const gameMetricSorter = reactive<{
|
||||||
|
field?: string;
|
||||||
|
order?: 'ascend' | 'descend';
|
||||||
|
}>({
|
||||||
|
field: 'consumeAmount',
|
||||||
|
order: 'descend',
|
||||||
|
});
|
||||||
|
const gameCountryRankOpen = ref(false);
|
||||||
|
const gameCountryRankLoading = ref(false);
|
||||||
|
const gameCountryRankRecords = ref<CountryDashboardGameMetric[]>([]);
|
||||||
|
const selectedGameMetric = ref<null | Partial<CountryDashboardGameMetric>>(null);
|
||||||
|
const gameQuery = reactive<{
|
||||||
|
gameKeyword: string;
|
||||||
|
gameProvider?: string;
|
||||||
|
}>({
|
||||||
|
gameKeyword: '',
|
||||||
|
gameProvider: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
const query = reactive({
|
const query = reactive({
|
||||||
periodType: 'DAY',
|
periodType: 'DAY',
|
||||||
@ -104,6 +133,13 @@ const { renderEcharts: renderLuckyChart } = useEcharts(luckyChartRef);
|
|||||||
type ChartOption = Parameters<typeof renderUserRechargeChart>[0];
|
type ChartOption = Parameters<typeof renderUserRechargeChart>[0];
|
||||||
|
|
||||||
const accessCodes = computed(() => accessStore.accessCodes || []);
|
const accessCodes = computed(() => accessStore.accessCodes || []);
|
||||||
|
const gameCountryRankTitle = computed(() => {
|
||||||
|
const game = selectedGameMetric.value;
|
||||||
|
if (!game) {
|
||||||
|
return '国家消耗排名';
|
||||||
|
}
|
||||||
|
return `${gameDisplayText(game)} 国家消耗排名`;
|
||||||
|
});
|
||||||
const sysOriginOptions = computed(() => {
|
const sysOriginOptions = computed(() => {
|
||||||
const options = getAllowedSysOrigins(accessCodes.value);
|
const options = getAllowedSysOrigins(accessCodes.value);
|
||||||
return options.length > 0 ? options : getAllowedSysOrigins([]);
|
return options.length > 0 ? options : getAllowedSysOrigins([]);
|
||||||
@ -125,6 +161,18 @@ const statTimezoneOptions = [
|
|||||||
{ label: '北京', value: 'Asia/Shanghai' },
|
{ label: '北京', value: 'Asia/Shanghai' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const gameProviderOptions = [
|
||||||
|
{ label: '全部游戏厂商', value: '' },
|
||||||
|
{ label: '站内游戏', value: 'INTERNAL' },
|
||||||
|
{ label: '百顺', value: 'BAISHUN' },
|
||||||
|
{ label: 'Game Open', value: 'GAME_OPEN' },
|
||||||
|
{ label: '灵犀', value: 'LINGXIAN' },
|
||||||
|
{ label: 'Yomi', value: 'YOMI' },
|
||||||
|
{ label: 'HKYS', value: 'HKYS' },
|
||||||
|
{ label: 'Hot', value: 'HOT' },
|
||||||
|
{ label: '未识别', value: 'UNKNOWN' },
|
||||||
|
];
|
||||||
|
|
||||||
const metricColumns = new Set([
|
const metricColumns = new Set([
|
||||||
'countryNewUser',
|
'countryNewUser',
|
||||||
'd1RetentionRate',
|
'd1RetentionRate',
|
||||||
@ -132,7 +180,6 @@ const metricColumns = new Set([
|
|||||||
'd30RetentionRate',
|
'd30RetentionRate',
|
||||||
'dailyActiveUser',
|
'dailyActiveUser',
|
||||||
'dealerRecharge',
|
'dealerRecharge',
|
||||||
'googleRecharge',
|
|
||||||
'gamePayout',
|
'gamePayout',
|
||||||
'gamePayoutRate',
|
'gamePayoutRate',
|
||||||
'gameProfit',
|
'gameProfit',
|
||||||
@ -140,6 +187,7 @@ const metricColumns = new Set([
|
|||||||
'gameTotalFlow',
|
'gameTotalFlow',
|
||||||
'gameUser',
|
'gameUser',
|
||||||
'giftConsume',
|
'giftConsume',
|
||||||
|
'googleRecharge',
|
||||||
'luckyGiftPayout',
|
'luckyGiftPayout',
|
||||||
'luckyGiftPayoutRate',
|
'luckyGiftPayoutRate',
|
||||||
'luckyGiftProfit',
|
'luckyGiftProfit',
|
||||||
@ -288,6 +336,51 @@ const rechargeColumns: any[] = [
|
|||||||
sorter: compareTableColumn(String(column.dataIndex)),
|
sorter: compareTableColumn(String(column.dataIndex)),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const gameMetricNumberColumns = new Set([
|
||||||
|
'consumeAmount',
|
||||||
|
'orderCount',
|
||||||
|
'payoutAmount',
|
||||||
|
'profitAmount',
|
||||||
|
'userCount',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const gameMetricIntegerColumns = new Set(['orderCount', 'userCount']);
|
||||||
|
|
||||||
|
const gameMetricPercentColumns = new Set(['payoutRate', 'profitRate']);
|
||||||
|
|
||||||
|
const gameMetricColumns: any[] = [
|
||||||
|
{ align: 'right' as const, dataIndex: 'rank', fixed: 'left' as const, key: 'rank', title: '排名', width: 72 },
|
||||||
|
{ dataIndex: 'gameName', fixed: 'left' as const, key: 'gameName', title: '游戏', width: 240 },
|
||||||
|
{ dataIndex: 'gameProvider', key: 'gameProvider', title: '游戏厂商', width: 130 },
|
||||||
|
{ dataIndex: 'gameId', key: 'gameId', title: '游戏 ID', width: 150 },
|
||||||
|
{
|
||||||
|
align: 'right' as const,
|
||||||
|
dataIndex: 'consumeAmount',
|
||||||
|
key: 'consumeAmount',
|
||||||
|
sortDirections: ['descend', 'ascend'],
|
||||||
|
sorter: true,
|
||||||
|
title: '消耗',
|
||||||
|
width: 130,
|
||||||
|
},
|
||||||
|
{ dataIndex: 'refreshedAt', key: 'refreshedAt', title: '更新时间', width: 170 },
|
||||||
|
];
|
||||||
|
const gameTableScrollX = gameMetricColumns.reduce(
|
||||||
|
(totalWidth, item) => totalWidth + Number(item.width || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const gameCountryRankColumns: any[] = [
|
||||||
|
{ align: 'right' as const, dataIndex: 'rank', key: 'rank', title: '排名', width: 72 },
|
||||||
|
{ dataIndex: 'countryName', key: 'countryName', title: '国家', width: 180 },
|
||||||
|
{ dataIndex: 'countryCode', key: 'countryCode', title: 'Code', width: 100 },
|
||||||
|
{ align: 'right' as const, dataIndex: 'consumeAmount', key: 'consumeAmount', title: '消耗', width: 150 },
|
||||||
|
{ dataIndex: 'refreshedAt', key: 'refreshedAt', title: '更新时间', width: 170 },
|
||||||
|
];
|
||||||
|
const gameCountryRankScrollX = gameCountryRankColumns.reduce(
|
||||||
|
(totalWidth, item) => totalWidth + Number(item.width || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
const rechargeDetailColumns: any[] = [
|
const rechargeDetailColumns: any[] = [
|
||||||
{ dataIndex: 'createTime', key: 'createTime', title: '时间', width: 170 },
|
{ dataIndex: 'createTime', key: 'createTime', title: '时间', width: 170 },
|
||||||
{ dataIndex: 'sourceType', key: 'sourceType', title: '来源', width: 120 },
|
{ dataIndex: 'sourceType', key: 'sourceType', title: '来源', width: 120 },
|
||||||
@ -478,6 +571,12 @@ async function loadDashboard() {
|
|||||||
threeDay: threeDayArpuDashboard,
|
threeDay: threeDayArpuDashboard,
|
||||||
week: weekArpuDashboard,
|
week: weekArpuDashboard,
|
||||||
};
|
};
|
||||||
|
if (activeTableTab.value === 'game') {
|
||||||
|
await loadGameMetrics();
|
||||||
|
if (gameCountryRankOpen.value) {
|
||||||
|
await loadGameCountryRank();
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
dashboard.value = null;
|
dashboard.value = null;
|
||||||
arpuDashboards.value = {
|
arpuDashboards.value = {
|
||||||
@ -493,6 +592,99 @@ async function loadDashboard() {
|
|||||||
renderVisualCharts();
|
renderVisualCharts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildGameMetricParams() {
|
||||||
|
return {
|
||||||
|
...buildParams(),
|
||||||
|
countryCodes: selectedCountryCodes.value.join(',') || undefined,
|
||||||
|
cursor: gameMetricPagination.current,
|
||||||
|
gameKeyword: gameQuery.gameKeyword.trim() || undefined,
|
||||||
|
gameProvider: gameQuery.gameProvider || undefined,
|
||||||
|
limit: gameMetricPagination.pageSize,
|
||||||
|
sortField: gameMetricSorter.field || undefined,
|
||||||
|
sortOrder: gameMetricSorter.order || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGameMetrics() {
|
||||||
|
if (!canQuery.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
gameMetricLoading.value = true;
|
||||||
|
try {
|
||||||
|
const result = await countryDashboardGameSummaries(buildGameMetricParams());
|
||||||
|
gameMetricRecords.value = result.records || [];
|
||||||
|
gameMetricPagination.total = Number(result.total || 0);
|
||||||
|
gameMetricPagination.current = Number(result.current || gameMetricPagination.current || 1);
|
||||||
|
const resultPageSize = Number(result.size);
|
||||||
|
gameMetricPagination.pageSize =
|
||||||
|
Number.isFinite(resultPageSize) && resultPageSize > 0
|
||||||
|
? resultPageSize
|
||||||
|
: gameMetricPagination.pageSize;
|
||||||
|
} catch {
|
||||||
|
gameMetricRecords.value = [];
|
||||||
|
gameMetricPagination.total = 0;
|
||||||
|
message.error('游戏数据加载失败');
|
||||||
|
} finally {
|
||||||
|
gameMetricLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGameCountryRankParams(record: Partial<CountryDashboardGameMetric>) {
|
||||||
|
return {
|
||||||
|
...buildParams(),
|
||||||
|
countryCodes: selectedCountryCodes.value.join(',') || undefined,
|
||||||
|
gameId: record.gameId,
|
||||||
|
gameProvider: record.gameProvider,
|
||||||
|
rankLimit: 200,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGameCountryRank(record = selectedGameMetric.value) {
|
||||||
|
if (!record?.gameId || !record.gameProvider || !canQuery.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
gameCountryRankLoading.value = true;
|
||||||
|
try {
|
||||||
|
gameCountryRankRecords.value = await countryDashboardGameCountryRank(
|
||||||
|
buildGameCountryRankParams(record),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
gameCountryRankRecords.value = [];
|
||||||
|
message.error('游戏国家排名加载失败');
|
||||||
|
} finally {
|
||||||
|
gameCountryRankLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openGameCountryRank(record: Partial<CountryDashboardGameMetric>) {
|
||||||
|
selectedGameMetric.value = record;
|
||||||
|
gameCountryRankOpen.value = true;
|
||||||
|
await loadGameCountryRank(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGameMetricTableChange(
|
||||||
|
pagination: Record<string, any>,
|
||||||
|
_filters: Record<string, any>,
|
||||||
|
sorter: Record<string, any> | Record<string, any>[],
|
||||||
|
) {
|
||||||
|
const activeSorter = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||||
|
gameMetricPagination.current = Number(pagination.current || 1);
|
||||||
|
gameMetricPagination.pageSize = Number(pagination.pageSize || gameMetricPagination.pageSize || 50);
|
||||||
|
gameMetricSorter.field = activeSorter?.field ? String(activeSorter.field) : undefined;
|
||||||
|
gameMetricSorter.order =
|
||||||
|
activeSorter?.order === 'ascend' || activeSorter?.order === 'descend'
|
||||||
|
? activeSorter.order
|
||||||
|
: undefined;
|
||||||
|
await loadGameMetrics();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGameMetricFilterChange() {
|
||||||
|
gameMetricPagination.current = 1;
|
||||||
|
if (activeTableTab.value === 'game') {
|
||||||
|
await loadGameMetrics();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadRechargeDetails() {
|
async function loadRechargeDetails() {
|
||||||
rechargeDetailLoading.value = true;
|
rechargeDetailLoading.value = true;
|
||||||
try {
|
try {
|
||||||
@ -524,6 +716,22 @@ function rechargeDetailRowKey(record: Record<string, any>) {
|
|||||||
return `${record.sourceType}-${record.recordId}`;
|
return `${record.sourceType}-${record.recordId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function gameMetricRowKey(record: Record<string, any>) {
|
||||||
|
return [record.gameProvider, record.gameId].join('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
function gameCountryRankRowKey(record: Record<string, any>) {
|
||||||
|
return [record.countryCode, record.gameProvider, record.gameId].join('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
function gameMetricRank(index: number) {
|
||||||
|
return (gameMetricPagination.current - 1) * gameMetricPagination.pageSize + index + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gameDisplayText(record: Partial<CountryDashboardGameMetric>) {
|
||||||
|
return record.gameName || record.gameId || '-';
|
||||||
|
}
|
||||||
|
|
||||||
function rechargeSourceName(sourceType: string) {
|
function rechargeSourceName(sourceType: string) {
|
||||||
return rechargeSourceNameMap[sourceType] || sourceType || '-';
|
return rechargeSourceNameMap[sourceType] || sourceType || '-';
|
||||||
}
|
}
|
||||||
@ -541,6 +749,10 @@ function showRechargeDetailTotal(total: number) {
|
|||||||
return `共 ${total} 条`;
|
return `共 ${total} 条`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showGameMetricTotal(total: number) {
|
||||||
|
return `共 ${total} 条`;
|
||||||
|
}
|
||||||
|
|
||||||
function toAmount(value?: null | number | string) {
|
function toAmount(value?: null | number | string) {
|
||||||
const number = Number(value || 0);
|
const number = Number(value || 0);
|
||||||
return Number.isFinite(number) ? number : 0;
|
return Number.isFinite(number) ? number : 0;
|
||||||
@ -730,6 +942,19 @@ function tableCellText(record: Record<string, any>, key: string) {
|
|||||||
return formatAmount(record[key] as number | string);
|
return formatAmount(record[key] as number | string);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function gameMetricCellText(record: Record<string, any>, key: string) {
|
||||||
|
if (gameMetricIntegerColumns.has(key)) {
|
||||||
|
return formatInteger(record[key] as number | string);
|
||||||
|
}
|
||||||
|
if (gameMetricPercentColumns.has(key)) {
|
||||||
|
return formatPercent(record[key] as number | string);
|
||||||
|
}
|
||||||
|
if (gameMetricNumberColumns.has(key)) {
|
||||||
|
return formatAmount(record[key] as number | string);
|
||||||
|
}
|
||||||
|
return record[key] || '-';
|
||||||
|
}
|
||||||
|
|
||||||
function compareTableColumn(key: string) {
|
function compareTableColumn(key: string) {
|
||||||
return (current: Record<string, any>, next: Record<string, any>) => {
|
return (current: Record<string, any>, next: Record<string, any>) => {
|
||||||
if (metricColumns.has(key)) {
|
if (metricColumns.has(key)) {
|
||||||
@ -921,6 +1146,23 @@ watch(countryOptions, (options) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(activeTableTab, (value) => {
|
||||||
|
if (value === 'game') {
|
||||||
|
gameMetricPagination.current = 1;
|
||||||
|
void loadGameMetrics();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(selectedCountryCodes, () => {
|
||||||
|
if (activeTableTab.value === 'game') {
|
||||||
|
gameMetricPagination.current = 1;
|
||||||
|
void loadGameMetrics();
|
||||||
|
if (gameCountryRankOpen.value) {
|
||||||
|
void loadGameCountryRank();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('fullscreenchange', syncFullscreenState);
|
document.addEventListener('fullscreenchange', syncFullscreenState);
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
@ -1004,7 +1246,24 @@ onBeforeUnmount(() => {
|
|||||||
show-search
|
show-search
|
||||||
style="width: 300px"
|
style="width: 300px"
|
||||||
/>
|
/>
|
||||||
<Button :loading="loading" type="primary" @click="loadDashboard">
|
<Select
|
||||||
|
v-if="activeTableTab === 'game'"
|
||||||
|
v-model:value="gameQuery.gameProvider"
|
||||||
|
:options="gameProviderOptions"
|
||||||
|
allow-clear
|
||||||
|
placeholder="游戏厂商"
|
||||||
|
style="width: 150px"
|
||||||
|
@change="handleGameMetricFilterChange"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-if="activeTableTab === 'game'"
|
||||||
|
v-model:value="gameQuery.gameKeyword"
|
||||||
|
allow-clear
|
||||||
|
placeholder="游戏 ID / 名称"
|
||||||
|
style="width: 220px"
|
||||||
|
@press-enter="handleGameMetricFilterChange"
|
||||||
|
/>
|
||||||
|
<Button :loading="loading || gameMetricLoading" type="primary" @click="loadDashboard">
|
||||||
查询
|
查询
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
@ -1105,12 +1364,56 @@ onBeforeUnmount(() => {
|
|||||||
<Empty v-else />
|
<Empty v-else />
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<Tabs v-model:activeKey="activeTableTab" class="dashboard-tabs" size="small">
|
<Tabs v-model:active-key="activeTableTab" class="dashboard-tabs" size="small">
|
||||||
<TabPane key="overview" tab="指标总览" />
|
<TabPane key="overview" tab="指标总览" />
|
||||||
<TabPane key="recharge" tab="充值相关数据" />
|
<TabPane key="recharge" tab="充值相关数据" />
|
||||||
|
<TabPane key="game" tab="游戏数据" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<Table
|
<Table
|
||||||
v-if="records.length > 0"
|
v-if="activeTableTab === 'game'"
|
||||||
|
:columns="gameMetricColumns"
|
||||||
|
:data-source="gameMetricRecords"
|
||||||
|
:loading="gameMetricLoading"
|
||||||
|
:pagination="{
|
||||||
|
current: gameMetricPagination.current,
|
||||||
|
pageSize: gameMetricPagination.pageSize,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: showGameMetricTotal,
|
||||||
|
total: gameMetricPagination.total,
|
||||||
|
}"
|
||||||
|
:row-key="gameMetricRowKey"
|
||||||
|
:scroll="{ x: gameTableScrollX }"
|
||||||
|
size="small"
|
||||||
|
@change="handleGameMetricTableChange"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, index, record }">
|
||||||
|
<template v-if="column.key === 'rank'">
|
||||||
|
<span class="number-cell">{{ gameMetricRank(index) }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'gameName'">
|
||||||
|
<Button
|
||||||
|
class="game-link-button"
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
@click="openGameCountryRank(record)"
|
||||||
|
>
|
||||||
|
{{ gameDisplayText(record) }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
<template
|
||||||
|
v-else-if="
|
||||||
|
gameMetricNumberColumns.has(String(column.dataIndex)) ||
|
||||||
|
gameMetricPercentColumns.has(String(column.dataIndex))
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span class="number-cell">
|
||||||
|
{{ gameMetricCellText(record, String(column.dataIndex)) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
<Table
|
||||||
|
v-else-if="records.length > 0"
|
||||||
:columns="activeColumns"
|
:columns="activeColumns"
|
||||||
:data-source="records"
|
:data-source="records"
|
||||||
:pagination="{ pageSize: 20, showSizeChanger: true }"
|
:pagination="{ pageSize: 20, showSizeChanger: true }"
|
||||||
@ -1192,6 +1495,34 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
</Table>
|
</Table>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
v-model:open="gameCountryRankOpen"
|
||||||
|
:footer="null"
|
||||||
|
:title="gameCountryRankTitle"
|
||||||
|
width="860px"
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
:columns="gameCountryRankColumns"
|
||||||
|
:data-source="gameCountryRankRecords"
|
||||||
|
:loading="gameCountryRankLoading"
|
||||||
|
:pagination="false"
|
||||||
|
:row-key="gameCountryRankRowKey"
|
||||||
|
:scroll="{ x: gameCountryRankScrollX, y: 520 }"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, index, record }">
|
||||||
|
<template v-if="column.key === 'rank'">
|
||||||
|
<span class="number-cell">{{ index + 1 }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'consumeAmount'">
|
||||||
|
<span class="number-cell">{{ formatAmount(record.consumeAmount) }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'countryName'">
|
||||||
|
{{ record.countryName || record.countryCode || '-' }}
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -1372,6 +1703,17 @@ onBeforeUnmount(() => {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.game-link-button {
|
||||||
|
height: auto;
|
||||||
|
padding: 0;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-link-button :deep(span) {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.summary-value-cell {
|
.summary-value-cell {
|
||||||
display: block;
|
display: block;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@ -71,6 +71,10 @@ const canEdit = computed(() => hasPermission('team:manager:list:edit'));
|
|||||||
const canDelete = computed(() => hasPermission('team:manager:list:del'));
|
const canDelete = computed(() => hasPermission('team:manager:list:del'));
|
||||||
const showOperationCol = computed(() => canEdit.value || canDelete.value);
|
const showOperationCol = computed(() => canEdit.value || canDelete.value);
|
||||||
|
|
||||||
|
function getUserShortId(profile?: Record<string, any> | null) {
|
||||||
|
return profile?.actualAccount || profile?.account || profile?.id || '-';
|
||||||
|
}
|
||||||
|
|
||||||
async function loadData(reset = false) {
|
async function loadData(reset = false) {
|
||||||
if (reset) {
|
if (reset) {
|
||||||
query.cursor = 1;
|
query.cursor = 1;
|
||||||
@ -170,7 +174,7 @@ loadData(true);
|
|||||||
<div class="user-name-row">
|
<div class="user-name-row">
|
||||||
<span>{{ record.userProfile?.userNickname || '-' }}</span>
|
<span>{{ record.userProfile?.userNickname || '-' }}</span>
|
||||||
<span class="user-short-id">
|
<span class="user-short-id">
|
||||||
短ID:{{ record.userProfile?.actualAccount || '-' }}
|
短ID:{{ getUserShortId(record.userProfile) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-sub">
|
<div class="user-sub">
|
||||||
|
|||||||
@ -753,3 +753,67 @@ LEFT JOIN sys_role_menu AS existing_role_menu
|
|||||||
ON existing_role_menu.role_id = parent_role.role_id
|
ON existing_role_menu.role_id = parent_role.role_id
|
||||||
AND existing_role_menu.menu_id = target_menu.id
|
AND existing_role_menu.menu_id = target_menu.id
|
||||||
WHERE existing_role_menu.id IS NULL;
|
WHERE existing_role_menu.id IS NULL;
|
||||||
|
|
||||||
|
INSERT INTO sys_menu (
|
||||||
|
id,
|
||||||
|
parent_id,
|
||||||
|
menu_name,
|
||||||
|
path,
|
||||||
|
menu_type,
|
||||||
|
icon,
|
||||||
|
create_user,
|
||||||
|
update_user,
|
||||||
|
sort,
|
||||||
|
status,
|
||||||
|
router,
|
||||||
|
alias
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
next_id,
|
||||||
|
@resident_activity_id,
|
||||||
|
'火箭',
|
||||||
|
'resident-activity/voice-room-rocket',
|
||||||
|
2,
|
||||||
|
'form',
|
||||||
|
'0',
|
||||||
|
'0',
|
||||||
|
10,
|
||||||
|
0,
|
||||||
|
'voice-room-rocket',
|
||||||
|
'ResidentVoiceRoomRocket'
|
||||||
|
FROM (
|
||||||
|
SELECT COALESCE(MAX(id), 1700000000) + 1 AS next_id
|
||||||
|
FROM sys_menu
|
||||||
|
) AS menu_id
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM sys_menu WHERE alias = 'ResidentVoiceRoomRocket'
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE sys_menu
|
||||||
|
SET
|
||||||
|
parent_id = @resident_activity_id,
|
||||||
|
menu_name = '火箭',
|
||||||
|
path = 'resident-activity/voice-room-rocket',
|
||||||
|
menu_type = 2,
|
||||||
|
icon = 'form',
|
||||||
|
create_user = '0',
|
||||||
|
update_user = '0',
|
||||||
|
sort = 10,
|
||||||
|
status = 0,
|
||||||
|
router = 'voice-room-rocket'
|
||||||
|
WHERE alias = 'ResidentVoiceRoomRocket';
|
||||||
|
|
||||||
|
INSERT INTO sys_role_menu (role_id, menu_id)
|
||||||
|
SELECT DISTINCT
|
||||||
|
parent_role.role_id,
|
||||||
|
target_menu.id
|
||||||
|
FROM sys_role_menu AS parent_role
|
||||||
|
JOIN sys_menu AS parent_menu
|
||||||
|
ON parent_menu.id = parent_role.menu_id
|
||||||
|
AND parent_menu.alias = 'ResidentActivityManager'
|
||||||
|
JOIN sys_menu AS target_menu
|
||||||
|
ON target_menu.alias = 'ResidentVoiceRoomRocket'
|
||||||
|
LEFT JOIN sys_role_menu AS existing_role_menu
|
||||||
|
ON existing_role_menu.role_id = parent_role.role_id
|
||||||
|
AND existing_role_menu.menu_id = target_menu.id
|
||||||
|
WHERE existing_role_menu.id IS NULL;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user