Compare commits

..

18 Commits
test ... main

Author SHA1 Message Date
zhx
e41b630e02 fix: point admin proxy to yumiadmin 2026-06-26 14:37:21 +08:00
zhx
9678f262a9 1 2026-06-25 19:29:26 +08:00
zhx
25ccb09f31 经理 2026-06-25 18:40:25 +08:00
zhx
a73b079333 增加汇率 2026-06-25 16:12:50 +08:00
zhx
7510dac801 经理中心 2026-06-24 15:39:37 +08:00
zhx
5befd6297b 商品配置 2026-06-24 12:56:42 +08:00
zhx
cd9cbea7d6 1 2026-06-12 19:35:51 +08:00
zhx
d56fc0f50e 1 2026-06-10 17:59:13 +08:00
zhx
915c8bf0be 增加热游 2026-06-10 15:50:41 +08:00
zhx
c22cff97c3 增加导出 2026-06-09 15:42:57 +08:00
zhx
1995f9b078 增加导出 2026-06-04 12:12:12 +08:00
zhx
33db3e0ea2 fix(admin): repair banner times and wheel value cards 2026-06-03 21:02:14 +08:00
zhx
66568253aa 自动结算 2026-06-01 19:46:15 +08:00
zhx
b94e6db9af 增加月度账单筛选 2026-05-29 20:55:40 +08:00
zhx
2ef1aa24ea 首冲奖励配置 2026-05-28 15:01:01 +08:00
zhx
a62e5a65a5 增加新字段 2026-05-27 22:13:04 +08:00
zhx
d92285ae4a Use country dashboard ARPU summary 2026-05-27 17:01:36 +08:00
zhx
b2fb090fb1 fix admin reward group bigint ids 2026-05-26 20:51:10 +08:00
24 changed files with 1564 additions and 144 deletions

View File

@ -108,3 +108,51 @@ export async function syncLingxianGames(data: Record<string, any>) {
data, data,
); );
} }
export async function getHotgameProviderConfig(params: Record<string, any>) {
return requestClient.get<Record<string, any>>('/go/operate/hotgame-game/config', {
params,
});
}
export async function saveHotgameProviderConfig(data: Record<string, any>) {
return requestClient.post<Record<string, any>>(
'/go/operate/hotgame-game/config',
data,
);
}
export async function activateHotgameProviderProfile(data: Record<string, any>) {
return requestClient.post<Record<string, any>>(
'/go/operate/hotgame-game/config/activate',
data,
);
}
export async function pageHotgameCatalog(params: Record<string, any>) {
return requestClient.get<Record<string, any>>(
'/go/operate/hotgame-game/catalog/page',
{ params },
);
}
export async function fetchHotgameCatalog(data: Record<string, any>) {
return requestClient.post<Record<string, any>>(
'/go/operate/hotgame-game/catalog/fetch',
data,
);
}
export async function importHotgameCatalog(data: Record<string, any>) {
return requestClient.post<Record<string, any>>(
'/go/operate/hotgame-game/import-catalog',
data,
);
}
export async function syncHotgameGames(data: Record<string, any>) {
return requestClient.post<Record<string, any>>(
'/go/operate/hotgame-game/sync',
data,
);
}

View File

@ -14,6 +14,7 @@ export interface CountryDashboardMetric {
d30RetentionRate: number | string; d30RetentionRate: number | string;
d30RetentionUser: number; d30RetentionUser: number;
dailyActiveUser: number; dailyActiveUser: number;
dailyRechargeUser: number;
dealerRecharge: number | string; dealerRecharge: number | string;
googleRecharge: number | string; googleRecharge: number | string;
gamePayoutRate: number | string; gamePayoutRate: number | string;
@ -32,6 +33,7 @@ export interface CountryDashboardMetric {
luckyGiftUser: number; luckyGiftUser: number;
luckyGiftUserRate: number | string; luckyGiftUserRate: number | string;
mifapayRecharge: number | string; mifapayRecharge: number | string;
newDealerUserRecharge: number | string;
newUserRecharge: number | string; newUserRecharge: number | string;
officialRecharge: number | string; officialRecharge: number | string;
periodKey: string; periodKey: string;
@ -39,9 +41,11 @@ export interface CountryDashboardMetric {
salaryExchange: number | string; salaryExchange: number | string;
salaryTransfer: number | string; salaryTransfer: number | string;
totalRecharge: number | string; totalRecharge: number | string;
userRecharge: number | string;
} }
export interface CountryDashboardResult { export interface CountryDashboardResult {
arpuSummary?: CountryDashboardArpuSummary;
computedAt: string; computedAt: string;
endDate?: string; endDate?: string;
periodType: string; periodType: string;
@ -51,6 +55,12 @@ export interface CountryDashboardResult {
total: CountryDashboardMetric; total: CountryDashboardMetric;
} }
export interface CountryDashboardArpuSummary {
day?: CountryDashboardResult | null;
threeDay?: CountryDashboardResult | null;
week?: CountryDashboardResult | null;
}
export interface CountryDashboardRechargeDetail { export interface CountryDashboardRechargeDetail {
amount: number | string; amount: number | string;
coinQuantity: number | string; coinQuantity: number | string;

View File

@ -212,6 +212,17 @@ export async function getClsGoldRunningWater(params: Record<string, any>) {
}); });
} }
export async function exportClsGoldRunningWater(
params: Record<string, any>,
filename = 'GmGoldRunningWater',
) {
return downloadLegacyExcel(
'/user-wallet/gold/running-water/cls/export',
params,
filename,
);
}
export async function getPurchaseTable(params: Record<string, any>) { export async function getPurchaseTable(params: Record<string, any>) {
return requestClient.get<LegacyPageResult<Record<string, any>>>( return requestClient.get<LegacyPageResult<Record<string, any>>>(
'/order/purchase/history/page', '/order/purchase/history/page',
@ -230,6 +241,17 @@ export async function listInAppPurchase(params: Record<string, any>) {
); );
} }
export async function exportInAppPurchaseDetails(
params: Record<string, any>,
filename = 'InAppPurchaseOrders',
) {
return downloadLegacyExcel(
'/order/in-app-purchase/details/export',
params,
filename,
);
}
export async function getInAppPurchase(id: number | string) { export async function getInAppPurchase(id: number | string) {
return requestClient.get<Record<string, any>>( return requestClient.get<Record<string, any>>(
'/order/in-app-purchase/details', '/order/in-app-purchase/details',

View File

@ -78,6 +78,13 @@ export async function updatePayOpenCountyShelf(
}); });
} }
export async function syncPayOpenCountryCurrencyRates(data: Record<string, any>) {
return requestClient.post<Record<string, any>>(
'/sys-pay-open-country/sync-currency-rates',
data,
);
}
export async function pagePayChannel(params: Record<string, any>) { export async function pagePayChannel(params: Record<string, any>) {
return requestClient.get<LegacyPageResult<Record<string, any>>>( return requestClient.get<LegacyPageResult<Record<string, any>>>(
'/sys-pay-channel/page', '/sys-pay-channel/page',

View File

@ -43,12 +43,23 @@ export interface LegacyUser {
}>; }>;
} }
export interface LegacyBackUserOption {
id: number | string;
loginName?: string;
nickname?: string;
status?: number | string;
}
export async function pageUsers(params: Record<string, any>) { export async function pageUsers(params: Record<string, any>) {
return requestClient.get<LegacyPageResult<LegacyUser>>('/users', { return requestClient.get<LegacyPageResult<LegacyUser>>('/users', {
params, params,
}); });
} }
export async function listBackUserOptions() {
return requestClient.get<LegacyBackUserOption[]>('/users/list/combo');
}
export async function switchUserStatus( export async function switchUserStatus(
id: number | string, id: number | string,
status: number | string, status: number | string,

View File

@ -216,11 +216,14 @@ export async function pageFreightRunningWater(params: Record<string, any>) {
); );
} }
export async function exportFreightWaters(params: Record<string, any>) { export async function exportFreightWaters(
params: Record<string, any>,
filename = 'ExportFreightWaters',
) {
return downloadLegacyExcel( return downloadLegacyExcel(
'/freight/running-water/export', '/freight/running-water/export',
params, params,
'ExportFreightWaters', filename,
); );
} }

View File

@ -29,6 +29,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('#/views/operate/lingxian-config.vue'), component: () => import('#/views/operate/lingxian-config.vue'),
meta: { title: '灵仙配置' }, meta: { title: '灵仙配置' },
}, },
{
name: 'OperateHotgameConfig',
path: 'hotgame-config',
component: () => import('#/views/operate/hotgame-config.vue'),
meta: { title: '热游配置' },
},
], ],
}, },
]; ];

View File

@ -21,9 +21,12 @@ import {
} from 'antdv-next'; } from 'antdv-next';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { submitGmGoldOperation } from '#/api/legacy/gm'; import { pageGmGoldOperation, submitGmGoldOperation } from '#/api/legacy/gm';
import { getClsGoldRunningWater } from '#/api/legacy/operate'; import {
import { listMembers } from '#/api/legacy/team'; exportClsGoldRunningWater,
getClsGoldRunningWater,
} from '#/api/legacy/operate';
import { listBackUserOptions } from '#/api/legacy/system';
import { getUserBaseInfoBySysOriginAccount } from '#/api/legacy/user'; import { getUserBaseInfoBySysOriginAccount } from '#/api/legacy/user';
import AccountInput from '#/components/account-input.vue'; import AccountInput from '#/components/account-input.vue';
import UserProfileLink from '#/views/operate/components/user-profile-link.vue'; import UserProfileLink from '#/views/operate/components/user-profile-link.vue';
@ -56,6 +59,8 @@ const DEFAULT_PAGE_SIZE = 100;
const open = ref(false); const open = ref(false);
const loading = ref(false); const loading = ref(false);
const loadMoreLoading = ref(false); const loadMoreLoading = ref(false);
const operatorsLoading = ref(false);
const exporting = ref(false);
const submitting = ref(false); const submitting = ref(false);
const list = ref<Array<Record<string, any>>>([]); const list = ref<Array<Record<string, any>>>([]);
const notMore = ref(false); const notMore = ref(false);
@ -64,7 +69,7 @@ const rangeDate = ref<[string, string] | null>(null);
const operators = ref<Array<Record<string, any>>>([]); const operators = ref<Array<Record<string, any>>>([]);
const query = reactive<Record<string, any>>({ const query = reactive<Record<string, any>>({
backOperationUser: undefined, backOperationUsers: [],
context: '', context: '',
endTime: '', endTime: '',
lastId: '', lastId: '',
@ -103,10 +108,14 @@ const showUsdQuantity = computed(
); );
const operatorOptions = computed(() => const operatorOptions = computed(() =>
operators.value.map((item) => ({ operators.value.map((item) => {
label: `${item.nickname || item.loginName || item.id} / ${item.loginName || item.id}`, const name = item.nickname || item.loginName || item.name || item.id;
const account = item.loginName && item.loginName !== name ? ` / ${item.loginName}` : '';
return {
label: `${name}${account}`,
value: item.id, value: item.id,
})), };
}),
); );
const currentPageList = computed(() => { const currentPageList = computed(() => {
@ -259,22 +268,81 @@ function filterOperatorOption(input: string, option: any) {
} }
async function loadOperators() { async function loadOperators() {
operators.value = (await listMembers()) || []; operatorsLoading.value = true;
try {
let backUsers: Array<Record<string, any>> = [];
try {
const result = await listBackUserOptions();
backUsers = Array.isArray(result) ? result : [];
} catch {
backUsers = [];
}
operators.value =
backUsers.length > 0
? normalizeOperators(backUsers)
: await loadRecentGmOperators().catch(() => []);
} finally {
operatorsLoading.value = false;
}
} }
async function loadData(reset = false) { function normalizeOperators(items: Array<Record<string, any>>) {
if (loading.value || loadMoreLoading.value) { const operatorMap = new Map<string, Record<string, any>>();
items.forEach((item) => {
const id = item.id || item.applicantId;
if (!id) {
return; return;
} }
operatorMap.set(String(id), {
id,
loginName: item.loginName,
name: item.name,
nickname: item.nickname || item.applicantName,
status: item.status,
});
});
return [...operatorMap.values()];
}
async function loadRecentGmOperators() {
const result = await pageGmGoldOperation({ cursor: 1, limit: 500 });
return normalizeOperators(result?.records || []);
}
function validateQueryTimeRange() {
if (!query.startTime || !query.endTime) { if (!query.startTime || !query.endTime) {
message.warning('请选择时间范围'); message.warning('请选择时间范围');
return; return false;
} }
const startYear = new Date(Number(query.startTime)).getFullYear(); const startYear = new Date(Number(query.startTime)).getFullYear();
const endYear = new Date(Number(query.endTime)).getFullYear(); const endYear = new Date(Number(query.endTime)).getFullYear();
if (startYear !== endYear) { if (startYear !== endYear) {
message.warning('不允许跨年查询'); message.warning('不允许跨年查询');
return false;
}
return true;
}
function buildRunningWaterParams() {
const backOperationUsers = Array.isArray(query.backOperationUsers)
? query.backOperationUsers.filter(Boolean).join(',')
: '';
return {
backOperationUsers: backOperationUsers || undefined,
endTime: query.endTime,
queryBackOperationUser: true,
startTime: query.startTime,
type: query.type,
userId: query.userId || undefined,
};
}
async function loadData(reset = false) {
if (loading.value || loadMoreLoading.value) {
return;
}
if (!validateQueryTimeRange()) {
return; return;
} }
@ -294,15 +362,10 @@ async function loadData(reset = false) {
try { try {
const result = await getClsGoldRunningWater({ const result = await getClsGoldRunningWater({
backOperationUser: query.backOperationUser || undefined, ...buildRunningWaterParams(),
context: query.context || undefined, context: query.context || undefined,
endTime: query.endTime,
lastId: query.lastId || undefined, lastId: query.lastId || undefined,
limit: query.limit, limit: query.limit,
queryBackOperationUser: true,
startTime: query.startTime,
type: query.type,
userId: query.userId || undefined,
}); });
const waters = result?.waters || []; const waters = result?.waters || [];
const nextList = reset ? waters : [...list.value, ...waters]; const nextList = reset ? waters : [...list.value, ...waters];
@ -352,6 +415,21 @@ async function handleSearch() {
await loadData(true); await loadData(true);
} }
async function handleExport() {
if (exporting.value || !validateQueryTimeRange()) {
return;
}
exporting.value = true;
try {
await exportClsGoldRunningWater(
buildRunningWaterParams(),
`GmGoldRunningWater_${dayjs(Number(query.startTime)).format('YYYYMMDD')}_${dayjs(Number(query.endTime)).format('YYYYMMDD')}`,
);
} finally {
exporting.value = false;
}
}
async function handleSubmit() { async function handleSubmit() {
if (!validateForm()) { if (!validateForm()) {
return; return;
@ -420,15 +498,19 @@ void loadData(true);
style="width: 240px" style="width: 240px"
/> />
<Select <Select
v-model:value="query.backOperationUser" v-model:value="query.backOperationUsers"
allow-clear allow-clear
mode="multiple"
show-search show-search
max-tag-count="responsive"
placeholder="操作人" placeholder="操作人"
style="width: 220px" style="width: 220px"
:filter-option="filterOperatorOption" :filter-option="filterOperatorOption"
:loading="operatorsLoading"
:options="operatorOptions" :options="operatorOptions"
/> />
<Button :loading="loading" type="primary" @click="handleSearch">查询</Button> <Button :loading="loading" type="primary" @click="handleSearch">查询</Button>
<Button :loading="exporting" @click="handleExport">导出Excel</Button>
</div> </div>
<Table <Table

View File

@ -1,6 +1,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue'; import { computed, reactive, ref, watch } from 'vue';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import { import {
OSS_FILE_BUCKETS, OSS_FILE_BUCKETS,
getAccessImgUrl, getAccessImgUrl,
@ -113,6 +116,8 @@ const selectCountryCodes = ref<string[]>([]);
const countriesLoading = ref(false); const countriesLoading = ref(false);
const regionsLoading = ref(false); const regionsLoading = ref(false);
const form = reactive<Record<string, any>>(createForm()); const form = reactive<Record<string, any>>(createForm());
const startTimePicker = ref<Dayjs | null>(null);
const expiredTimePicker = ref<Dayjs | null>(null);
const regionSelectOptions = computed(() => const regionSelectOptions = computed(() =>
regionOptions.value.map((item) => ({ regionOptions.value.map((item) => ({
@ -156,6 +161,27 @@ function normalizeMultiValue(value: any) {
.filter(Boolean); .filter(Boolean);
} }
function normalizePickerValue(value: any) {
if (value === '' || value === null || value === undefined) {
return null;
}
if (dayjs.isDayjs(value)) {
return value.isValid() ? value : null;
}
const raw = String(value).trim();
const numericValue = Number(raw);
const parsed = raw !== '' && Number.isFinite(numericValue) ? dayjs(numericValue) : dayjs(value);
return parsed.isValid() ? parsed : null;
}
function pickerToTimestamp(value: Dayjs | null) {
return value?.isValid() ? String(value.valueOf()) : '';
}
function getDatePickerPopupContainer(triggerNode: HTMLElement) {
return triggerNode.parentElement || document.body;
}
watch( watch(
() => ({ open: props.open, row: props.row, sysOrigin: props.sysOrigin }), () => ({ open: props.open, row: props.row, sysOrigin: props.sysOrigin }),
({ open, row, sysOrigin }) => { ({ open, row, sysOrigin }) => {
@ -174,6 +200,8 @@ watch(
roomSearchValue.value = String(form.params); roomSearchValue.value = String(form.params);
} }
} }
startTimePicker.value = normalizePickerValue(form.startTime);
expiredTimePicker.value = normalizePickerValue(form.expiredTime);
void Promise.all([ void Promise.all([
loadCountries(), loadCountries(),
sysOrigin ? loadRegions(sysOrigin) : Promise.resolve(), sysOrigin ? loadRegions(sysOrigin) : Promise.resolve(),
@ -204,6 +232,14 @@ watch(
}, },
); );
watch(startTimePicker, (value) => {
form.startTime = pickerToTimestamp(value);
});
watch(expiredTimePicker, (value) => {
form.expiredTime = pickerToTimestamp(value);
});
async function loadCountries() { async function loadCountries() {
countriesLoading.value = true; countriesLoading.value = true;
try { try {
@ -287,10 +323,18 @@ async function submitForm() {
message.warning('请选择系统'); message.warning('请选择系统');
return; return;
} }
if (!form.startTime) {
message.warning('请选择开始时间');
return;
}
if (!form.expiredTime) { if (!form.expiredTime) {
message.warning('请选择过期时间'); message.warning('请选择过期时间');
return; return;
} }
if (Number(form.expiredTime) <= Number(form.startTime)) {
message.warning('过期时间必须晚于开始时间');
return;
}
if (form.content === 'ENTER_ROOM' && !form.params) { if (form.content === 'ENTER_ROOM' && !form.params) {
message.warning('请选择房间'); message.warning('请选择房间');
return; return;
@ -486,20 +530,22 @@ async function submitForm() {
<div class="field"> <div class="field">
<div class="label">开始时间</div> <div class="label">开始时间</div>
<DatePicker <DatePicker
v-model:value="form.startTime" v-model:value="startTimePicker"
format="YYYY-MM-DD HH:mm:ss"
:get-popup-container="getDatePickerPopupContainer"
show-time show-time
style="width: 100%" style="width: 100%"
value-format="x"
/> />
</div> </div>
<div class="field"> <div class="field">
<div class="label">过期时间</div> <div class="label">过期时间</div>
<DatePicker <DatePicker
v-model:value="form.expiredTime" v-model:value="expiredTimePicker"
format="YYYY-MM-DD HH:mm:ss"
:get-popup-container="getDatePickerPopupContainer"
show-time show-time
style="width: 100%" style="width: 100%"
value-format="x"
/> />
</div> </div>

View File

@ -1,11 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue'; import { computed, reactive, ref, watch } from 'vue';
import {
addOrUpdatePayCommodity,
listCountrytSupportAmountChannels,
} from '#/api/legacy/pay';
import { import {
Collapse, Collapse,
CollapsePanel, CollapsePanel,
@ -13,11 +8,16 @@ import {
FormItem, FormItem,
Image, Image,
Input, Input,
message,
Modal, Modal,
Select, Select,
message,
} from 'antdv-next'; } from 'antdv-next';
import {
addOrUpdatePayCommodity,
listCountrytSupportAmountChannels,
} from '#/api/legacy/pay';
import { WEB_PRODUCT_TYPE_OPTIONS } from '../pay-shared'; import { WEB_PRODUCT_TYPE_OPTIONS } from '../pay-shared';
const props = withDefaults( const props = withDefaults(
@ -60,6 +60,7 @@ const form = reactive<Record<string, any>>({
content: '', content: '',
id: '', id: '',
payCountryId: '', payCountryId: '',
payCountryIds: [],
regionId: '', regionId: '',
shelf: true, shelf: true,
type: 'GOLD', type: 'GOLD',
@ -68,7 +69,27 @@ const form = reactive<Record<string, any>>({
const isUpdate = computed(() => Boolean(form.id)); const isUpdate = computed(() => Boolean(form.id));
const modalTitle = computed(() => (isUpdate.value ? '修改商品' : '创建商品')); const modalTitle = computed(() => (isUpdate.value ? '修改商品' : '创建商品'));
const selectedApp = computed(() => props.row?.selectApp || {}); const selectedApp = computed(() => props.row?.selectApp || {});
const selectedCountry = computed(() => props.row?.country || {}); const supportCountries = computed(() => props.row?.supportCountries || []);
const countryOptions = computed(() =>
supportCountries.value.map((item: Record<string, any>) => ({
label:
item.country?.aliasName ||
item.country?.enName ||
item.country?.countryName ||
'-',
value: item.id,
})),
);
const selectedCountry = computed(() => {
const currentPayCountryId = form.payCountryId || form.payCountryIds?.[0];
const current = supportCountries.value.find(
(item: Record<string, any>) => String(item.id) === String(currentPayCountryId),
);
if (!current) {
return props.row?.country || {};
}
return toCountryMeta(current);
});
const regionName = computed(() => props.row?.regionName || ''); const regionName = computed(() => props.row?.regionName || '');
watch( watch(
@ -84,6 +105,14 @@ watch(
form.content = row.content ?? ''; form.content = row.content ?? '';
form.id = row.id ?? ''; form.id = row.id ?? '';
form.payCountryId = row.payCountryId ?? ''; form.payCountryId = row.payCountryId ?? '';
form.payCountryIds = Array.isArray(row.payCountryIds)
? [...row.payCountryIds]
: (row.payCountryId
? [row.payCountryId]
: []);
if (props.mode === 'country' && !form.payCountryId && form.payCountryIds.length > 0) {
form.payCountryId = form.payCountryIds[0];
}
form.regionId = row.regionId ?? ''; form.regionId = row.regionId ?? '';
form.shelf = row.shelf ?? true; form.shelf = row.shelf ?? true;
form.type = row.type ?? 'GOLD'; form.type = row.type ?? 'GOLD';
@ -92,12 +121,26 @@ watch(
{ immediate: true }, { immediate: true },
); );
function toCountryMeta(country: Record<string, any>) {
return {
countryName:
country.country?.aliasName ||
country.country?.enName ||
country.country?.countryName ||
'-',
currency: country.currency || '',
exchangeRate: country.usdExchangeRate || '',
icon: country.country?.nationalFlag || '',
};
}
function isPositiveNumberPointTwo(value: string) { function isPositiveNumberPointTwo(value: string) {
return /^\d+(\.\d{1,2})?$/.test(value); return /^\d+(\.\d{1,2})?$/.test(value);
} }
async function loadSupportAmountChannels() { async function loadSupportAmountChannels() {
if (props.mode !== 'country' || !form.payCountryId || !isPositiveNumberPointTwo(String(form.amountUsd || ''))) { const currentPayCountryId = form.payCountryId || form.payCountryIds?.[0];
if (props.mode !== 'country' || !currentPayCountryId || !isPositiveNumberPointTwo(String(form.amountUsd || ''))) {
supportAmounts.value = []; supportAmounts.value = [];
computedAmount.value = ''; computedAmount.value = '';
amountUsdText.value = ''; amountUsdText.value = '';
@ -105,8 +148,9 @@ async function loadSupportAmountChannels() {
} }
associateLoading.value = true; associateLoading.value = true;
try { try {
// .
const result = await listCountrytSupportAmountChannels( const result = await listCountrytSupportAmountChannels(
form.payCountryId, currentPayCountryId,
form.amountUsd, form.amountUsd,
); );
supportAmounts.value = result?.channels || []; supportAmounts.value = result?.channels || [];
@ -124,11 +168,20 @@ function handleAmountChange() {
void loadSupportAmountChannels(); void loadSupportAmountChannels();
} }
function handleCountryIdsChange(value: Array<number | string>) {
form.payCountryId = value?.[0] || '';
void loadSupportAmountChannels();
}
function validateForm() { function validateForm() {
if (props.mode === 'country' && !form.payCountryId) { if (props.mode === 'country' && isUpdate.value && !form.payCountryId) {
message.warning('缺少支付国家'); message.warning('缺少支付国家');
return false; return false;
} }
if (props.mode === 'country' && !isUpdate.value && form.payCountryIds.length === 0) {
message.warning('请选择售卖国家');
return false;
}
if (props.mode === 'region' && !form.regionId) { if (props.mode === 'region' && !form.regionId) {
message.warning('缺少区域'); message.warning('缺少区域');
return false; return false;
@ -162,13 +215,16 @@ async function handleSubmit() {
} }
loading.value = true; loading.value = true;
try { try {
const createCountryIds =
props.mode === 'country' && !isUpdate.value ? form.payCountryIds : undefined;
await addOrUpdatePayCommodity({ await addOrUpdatePayCommodity({
amountUsd: form.amountUsd, amountUsd: form.amountUsd,
applicationId: form.applicationId, applicationId: form.applicationId,
awardContent: form.awardContent, awardContent: form.awardContent,
content: form.content, content: form.content,
id: form.id, id: form.id,
payCountryId: form.payCountryId, payCountryId: form.payCountryId || form.payCountryIds?.[0],
payCountryIds: createCountryIds,
regionId: form.regionId, regionId: form.regionId,
shelf: form.shelf, shelf: form.shelf,
type: form.type, type: form.type,
@ -199,7 +255,16 @@ async function handleSubmit() {
<Form layout="vertical"> <Form layout="vertical">
<FormItem v-if="mode === 'country'" label="售卖国家"> <FormItem v-if="mode === 'country'" label="售卖国家">
<div class="inline-meta"> <Select
v-if="!isUpdate"
v-model:value="form.payCountryIds"
mode="multiple"
:options="countryOptions"
option-label-prop="label"
placeholder="请选择售卖国家"
@change="handleCountryIdsChange"
/>
<div v-else class="inline-meta">
<Image <Image
v-if="selectedCountry.icon" v-if="selectedCountry.icon"
:preview="false" :preview="false"
@ -252,7 +317,7 @@ async function handleSubmit() {
<div v-else-if="supportAmounts.length === 0" class="support-empty"> <div v-else-if="supportAmounts.length === 0" class="support-empty">
暂无可展示渠道 暂无可展示渠道
</div> </div>
<Collapse v-else v-model:activeKey="activeKeys"> <Collapse v-else v-model:active-key="activeKeys">
<CollapsePanel <CollapsePanel
v-for="item in supportAmounts" v-for="item in supportAmounts"
:key="String(item.channel?.channelCode || '')" :key="String(item.channel?.channelCode || '')"

View File

@ -1,14 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue'; import { computed, reactive, ref, watch } from 'vue';
import { regionConfigTable } from '#/api/legacy/system';
import {
listPayOpenCountry,
pagePayCommodity,
switchShelfCommodity,
} from '#/api/legacy/pay';
import { formatDate } from '#/views/system/shared';
import { import {
Button, Button,
Card, Card,
@ -19,8 +11,15 @@ import {
Table, Table,
} from 'antdv-next'; } from 'antdv-next';
import { WEB_PRODUCT_TYPE_OPTIONS } from '../pay-shared'; import {
listPayOpenCountry,
pagePayCommodity,
switchShelfCommodity,
} from '#/api/legacy/pay';
import { regionConfigTable } from '#/api/legacy/system';
import { formatDate } from '#/views/system/shared';
import { WEB_PRODUCT_TYPE_OPTIONS } from '../pay-shared';
import PayCommodityEditModal from './pay-commodity-edit-modal.vue'; import PayCommodityEditModal from './pay-commodity-edit-modal.vue';
const props = withDefaults( const props = withDefaults(
@ -185,7 +184,7 @@ function handlePageChange(page: number, pageSize: number) {
void loadData(); void loadData();
} }
function handleApplicationChange(value: string | number) { function handleApplicationChange(value: number | string) {
selectedApp.value = selectedApp.value =
props.appInfo?.appList?.find((item: Record<string, any>) => item.id === value) || props.appInfo?.appList?.find((item: Record<string, any>) => item.id === value) ||
{}; {};
@ -193,7 +192,7 @@ function handleApplicationChange(value: string | number) {
void loadSupportData().then(() => loadData(true)); void loadSupportData().then(() => loadData(true));
} }
function handleDimensionChange(value: string | number) { function handleDimensionChange(value: number | string) {
selectedDimension.value = selectedDimension.value =
supportList.value.find((item) => item.id === value) || {}; supportList.value.find((item) => item.id === value) || {};
if (props.mode === 'country') { if (props.mode === 'country') {
@ -218,10 +217,12 @@ function openCreate() {
amountUsd: '', amountUsd: '',
applicationId: query.applicationId, applicationId: query.applicationId,
payCountryId: query.payCountryId, payCountryId: query.payCountryId,
payCountryIds: props.mode === 'country' ? [query.payCountryId].filter(Boolean) : [],
regionId: query.regionId, regionId: query.regionId,
regionName: selectedDimension.value.regionName || '', regionName: selectedDimension.value.regionName || '',
selectApp: selectedApp.value, selectApp: selectedApp.value,
shelf: true, shelf: true,
supportCountries: props.mode === 'country' ? supportList.value : [],
type: query.type, type: query.type,
...(props.mode === 'country' ...(props.mode === 'country'
? { ? {
@ -243,8 +244,11 @@ function openCreate() {
function openEdit(record: Record<string, any>) { function openEdit(record: Record<string, any>) {
activeRow.value = { activeRow.value = {
...record, ...record,
payCountryIds:
props.mode === 'country' ? [record.payCountryId || query.payCountryId].filter(Boolean) : [],
regionName: record.regionName || selectedDimension.value.regionName || '', regionName: record.regionName || selectedDimension.value.regionName || '',
selectApp: selectedApp.value, selectApp: selectedApp.value,
supportCountries: props.mode === 'country' ? supportList.value : [],
...(props.mode === 'country' ...(props.mode === 'country'
? { ? {
country: { country: {
@ -266,20 +270,23 @@ function openEdit(record: Record<string, any>) {
<template> <template>
<Card :title="title"> <Card :title="title">
<div class="toolbar"> <div class="toolbar">
<Select option-label-prop="label" <Select
option-label-prop="label"
v-model:value="query.type" v-model:value="query.type"
:options="productTypeOptions" :options="productTypeOptions"
style="width: 180px" style="width: 180px"
@change="handleSearch" @change="handleSearch"
/> />
<Select option-label-prop="label" <Select
option-label-prop="label"
v-model:value="query.applicationId" v-model:value="query.applicationId"
:options="applicationOptions" :options="applicationOptions"
placeholder="应用" placeholder="应用"
style="width: 200px" style="width: 200px"
@change="handleApplicationChange" @change="handleApplicationChange"
/> />
<Select option-label-prop="label" <Select
option-label-prop="label"
v-model:value="selectedDimensionId" v-model:value="selectedDimensionId"
:loading="supportLoading" :loading="supportLoading"
:options="supportOptions" :options="supportOptions"
@ -287,7 +294,8 @@ function openEdit(record: Record<string, any>) {
style="width: 200px" style="width: 200px"
@change="handleDimensionChange" @change="handleDimensionChange"
/> />
<Select option-label-prop="label" <Select
option-label-prop="label"
v-model:value="query.shelf" v-model:value="query.shelf"
:options="shelfOptions" :options="shelfOptions"
style="width: 140px" style="width: 140px"
@ -341,7 +349,7 @@ function openEdit(record: Record<string, any>) {
:total="total" :total="total"
show-size-changer show-size-changer
@change="handlePageChange" @change="handlePageChange"
@showSizeChange="handlePageChange" @show-size-change="handlePageChange"
/> />
</div> </div>

View File

@ -0,0 +1,468 @@
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { useAccessStore } from '@vben/stores';
import {
activateHotgameProviderProfile,
fetchHotgameCatalog,
getHotgameProviderConfig,
importHotgameCatalog,
pageHotgameCatalog,
saveHotgameProviderConfig,
} 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: 'OperateHotgameConfig' });
function createProviderForm(sysOrigin = '') {
return {
appKey: '',
callbackBaseUrl: '',
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 importShowcaseOptions = [
{ label: '默认上架', value: true as any },
{ label: '默认下架', value: false as any },
];
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 importDefaultShowcase = ref(true as any);
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: 520 },
{ 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,
profile: contextQuery.profile,
sysOrigin: contextQuery.sysOrigin,
});
}
async function loadProviderConfig() {
if (!contextQuery.sysOrigin) {
return;
}
providerLoading.value = true;
try {
const result = await getHotgameProviderConfig({
profile: contextQuery.profile,
sysOrigin: contextQuery.sysOrigin,
});
applyProviderForm(result || {});
} finally {
providerLoading.value = false;
}
}
async function persistProviderConfig(showSuccess = true) {
if (!contextQuery.sysOrigin || !providerForm.appKey) {
message.warning('请补全热游签名 Key');
return false;
}
providerSaving.value = true;
try {
const result = await saveHotgameProviderConfig({
...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 pageHotgameCatalog({
...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 activateHotgameProviderProfile({
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 fetchHotgameCatalog({
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 importHotgameCatalog({
profile: contextQuery.profile,
sysOrigin: contextQuery.sysOrigin,
vendorGameIds: selectedCatalogKeys.value,
defaultShowcase: importDefaultShowcase.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.callbackBaseUrl"
placeholder="https://api.global-interaction.com/"
/>
</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>
<Select
v-model:value="importDefaultShowcase"
:options="importShowcaseOptions"
style="width: 120px"
/>
<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: 1500 }"
>
<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'">
<a
v-if="record.downloadUrl"
:href="record.downloadUrl"
class="link-cell"
rel="noreferrer"
target="_blank"
>
{{ record.downloadUrl }}
</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: 500px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 960px) {
.form-grid,
.provider-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -6,6 +6,8 @@ import {
watch, watch,
} from 'vue'; } from 'vue';
import dayjs from 'dayjs';
import { Page } from '@vben/common-ui'; import { Page } from '@vben/common-ui';
import { useAccessStore } from '@vben/stores'; import { useAccessStore } from '@vben/stores';
@ -21,6 +23,7 @@ import { getAllowedSysOrigins } from '#/views/system/shared';
import { import {
Button, Button,
Card, Card,
DatePicker,
Modal, Modal,
Select, Select,
Space, Space,
@ -59,6 +62,7 @@ const salarySortKey = ref<'' | SalarySortKey>('');
const salarySortOrder = ref<SalarySortOrder>(''); const salarySortOrder = ref<SalarySortOrder>('');
const query = reactive({ const query = reactive({
billBelong: dayjs().format('YYYYMM'),
countryCode: '', countryCode: '',
cursor: 1, cursor: 1,
limit: 20, limit: 20,
@ -75,13 +79,13 @@ const columns = [
{ dataIndex: 'currentTarget', key: 'currentTarget', title: '本次积分', width: 130 }, { dataIndex: 'currentTarget', key: 'currentTarget', title: '本次积分', width: 130 },
{ dataIndex: 'policyLevel', key: 'policyLevel', title: '本次积分档位', width: 130 }, { dataIndex: 'policyLevel', key: 'policyLevel', title: '本次积分档位', width: 130 },
{ dataIndex: 'expectedMemberSalary', key: 'expectedMemberSalary', title: '主播应发', width: 120 }, { dataIndex: 'expectedMemberSalary', key: 'expectedMemberSalary', title: '主播应发', width: 120 },
{ dataIndex: 'expectedAgentSalary', key: 'expectedAgentSalary', title: '代理应发', width: 120 }, { dataIndex: 'expectedAgentSalary', key: 'expectedAgentSalary', title: '上级代理应发', width: 130 },
{ dataIndex: 'expectedSalary', key: 'expectedSalary', title: '应发合计', width: 120 }, { dataIndex: 'expectedSalary', key: 'expectedSalary', title: '应发合计', width: 120 },
{ dataIndex: 'payableMemberSalary', key: 'payableMemberSalary', title: '主播差额', width: 120 }, { dataIndex: 'payableMemberSalary', key: 'payableMemberSalary', title: '主播差额', width: 120 },
{ dataIndex: 'payableAgentSalary', key: 'payableAgentSalary', title: '代理差额', width: 120 }, { dataIndex: 'payableAgentSalary', key: 'payableAgentSalary', title: '上级代理差额', width: 130 },
{ dataIndex: 'payableSalary', key: 'payableSalary', title: '合计差额', width: 140 }, { dataIndex: 'payableSalary', key: 'payableSalary', title: '合计待发', width: 140 },
{ dataIndex: 'memberIssuedSalary', key: 'memberIssuedSalary', title: '主播实收', width: 140 }, { dataIndex: 'memberIssuedSalary', key: 'memberIssuedSalary', title: '主播实收', width: 140 },
{ dataIndex: 'agentIssuedSalary', key: 'agentIssuedSalary', title: '代理实收', width: 140 }, { dataIndex: 'agentIssuedSalary', key: 'agentIssuedSalary', title: '上级代理实收', width: 140 },
{ dataIndex: 'totalIssuedSalary', key: 'totalIssuedSalary', title: '总发工资', width: 140 }, { dataIndex: 'totalIssuedSalary', key: 'totalIssuedSalary', title: '总发工资', width: 140 },
{ dataIndex: 'currentSalaryBalance', key: 'currentSalaryBalance', title: '当前工资', width: 140 }, { dataIndex: 'currentSalaryBalance', key: 'currentSalaryBalance', title: '当前工资', width: 140 },
{ dataIndex: 'action', fixed: 'right', key: 'action', title: '操作', width: 110 }, { dataIndex: 'action', fixed: 'right', key: 'action', title: '操作', width: 110 },
@ -168,7 +172,7 @@ watch(
); );
watch( watch(
[() => query.sysOrigin, () => query.countryCode, () => query.userId], [() => query.sysOrigin, () => query.countryCode, () => query.userId, () => query.billBelong],
() => { () => {
selectedUserIds.value = []; selectedUserIds.value = [];
}, },
@ -226,19 +230,8 @@ function formatMoney(value?: number | string) {
return `$${amount.toFixed(2)}`; return `$${amount.toFixed(2)}`;
} }
function shouldShowAgentSalary(record: Record<string, any>) {
if (record.agentMember !== undefined && record.agentMember !== null) {
return record.agentMember === true;
}
return (
!!record.userId &&
!!record.teamOwnId &&
String(record.userId) === String(record.teamOwnId)
);
}
function formatAgentMoney(record: Record<string, any>, key: string) { function formatAgentMoney(record: Record<string, any>, key: string) {
return shouldShowAgentSalary(record) ? formatMoney(record[key]) : '-'; return formatMoney(record[key]);
} }
function formatInteger(value?: number | string) { function formatInteger(value?: number | string) {
@ -256,6 +249,13 @@ function countryLabel(code?: string) {
return `${country.phonePrefix || ''} ${country.countryName || country.aliasName || code}`; return `${country.phonePrefix || ''} ${country.countryName || country.aliasName || code}`;
} }
function billBelongLabel() {
if (!query.billBelong || query.billBelong.length !== 6) {
return '当前账期';
}
return `${query.billBelong.slice(0, 4)}-${query.billBelong.slice(4, 6)}`;
}
function getRowKey(record: Record<string, any>) { function getRowKey(record: Record<string, any>) {
return String(record.id || record.userProfile?.account || record.userId || ''); return String(record.id || record.userProfile?.account || record.userId || '');
} }
@ -327,6 +327,7 @@ function toggleSalarySort(key?: number | string) {
async function submitManualSalary(userIds?: string[]) { async function submitManualSalary(userIds?: string[]) {
const hasSelectedUsers = !!userIds?.length; const hasSelectedUsers = !!userIds?.length;
resultRows.value = await confirmManualSalary({ resultRows.value = await confirmManualSalary({
billBelong: Number(query.billBelong),
countryCode: query.countryCode, countryCode: query.countryCode,
sysOrigin: query.sysOrigin, sysOrigin: query.sysOrigin,
...(hasSelectedUsers ? { userIds } : {}), ...(hasSelectedUsers ? { userIds } : {}),
@ -354,8 +355,8 @@ function confirmPay() {
? '本次只发放已勾选的用户。' ? '本次只发放已勾选的用户。'
: '未勾选用户,本次将发放当前筛选国家的所有可发工资用户。', : '未勾选用户,本次将发放当前筛选国家的所有可发工资用户。',
title: hasSelectedUsers title: hasSelectedUsers
? `确认发放已选择的 ${userIds.length}当前账期工资?` ? `确认发放已选择的 ${userIds.length} ${billBelongLabel()} 工资?`
: `确认发放 ${countryLabel(query.countryCode)} 当前账期工资?`, : `确认发放 ${countryLabel(query.countryCode)} ${billBelongLabel()} 工资?`,
async onOk() { async onOk() {
paying.value = true; paying.value = true;
try { try {
@ -383,7 +384,7 @@ function settleUser(record: Record<string, any>) {
content: payableSalary < 0 content: payableSalary < 0
? '该用户应发小于实收本次发放0并置为已结算不会对用户扣款。' ? '该用户应发小于实收本次发放0并置为已结算不会对用户扣款。'
: '本次只结算当前这一行用户的工资。', : '本次只结算当前这一行用户的工资。',
title: `确认结算用户 ${record.userProfile?.account || userId}当前账期工资?`, title: `确认结算用户 ${record.userProfile?.account || userId} ${billBelongLabel()} 工资?`,
async onOk() { async onOk() {
settlingUserId.value = userId; settlingUserId.value = userId;
try { try {
@ -428,6 +429,16 @@ void loadCountries();
@change="loadData(true)" @change="loadData(true)"
@search="countryKeyword = $event" @search="countryKeyword = $event"
/> />
<DatePicker
v-model:value="query.billBelong"
:allow-clear="false"
format="YYYYMM"
picker="month"
placeholder="账期月份"
style="width: 140px"
value-format="YYYYMM"
@change="loadData(true)"
/>
<AccountInput <AccountInput
v-model:value="query.userId" v-model:value="query.userId"
:sys-origin="query.sysOrigin" :sys-origin="query.sysOrigin"
@ -546,7 +557,7 @@ void loadCountries();
<template v-else-if="column.key === 'agentIssuedSalary'"> <template v-else-if="column.key === 'agentIssuedSalary'">
<span>{{ formatAgentMoney(record, 'agentIssuedSalary') }}</span> <span>{{ formatAgentMoney(record, 'agentIssuedSalary') }}</span>
<span <span
v-if="shouldShowAgentSalary(record) && Number(record.agentOverIssuedSalary || 0) > 0" v-if="Number(record.agentOverIssuedSalary || 0) > 0"
class="over-issued-salary" class="over-issued-salary"
> >
({{ formatMoney(record.agentOverIssuedSalary) }}) ({{ formatMoney(record.agentOverIssuedSalary) }})

View File

@ -1,33 +1,30 @@
<script lang="ts" setup> <script lang="ts" setup>
import { import { computed, reactive, ref, watch } from 'vue';
computed,
reactive,
ref,
watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui'; import { Page } from '@vben/common-ui';
import { useAccessStore } from '@vben/stores'; import { useAccessStore } from '@vben/stores';
import { import {
exportInAppPurchaseDetails,
getOrderAmount, getOrderAmount,
listInAppPurchase, listInAppPurchase,
} from '#/api/legacy/operate'; } from '#/api/legacy/operate';
import { regionConfigTable } from '#/api/legacy/system'; import { regionConfigTable } from '#/api/legacy/system';
import AccountInput from '#/components/account-input.vue'; import AccountInput from '#/components/account-input.vue';
import SysOriginTag from '#/components/sys-origin-tag.vue'; import SysOriginTag from '#/components/sys-origin-tag.vue';
import { formatDate, import { formatDate, getAllowedSysOrigins } from '#/views/system/shared';
getAllowedSysOrigins } from '#/views/system/shared';
import { import {
Button, Button,
Card, Card,
DatePicker, DatePicker,
Input, Input,
message,
Select, Select,
Space, Space,
Table, Table,
Tag Tag,
} from 'antdv-next'; } from 'antdv-next';
import { import {
@ -50,6 +47,7 @@ const sysOriginOptions = computed(() => {
const loading = ref(false); const loading = ref(false);
const loadMoreLoading = ref(false); const loadMoreLoading = ref(false);
const exporting = ref(false);
const list = ref<Array<Record<string, any>>>([]); const list = ref<Array<Record<string, any>>>([]);
const regions = ref<Array<Record<string, any>>>([]); const regions = ref<Array<Record<string, any>>>([]);
const totalInfo = ref<Record<string, any>>({}); const totalInfo = ref<Record<string, any>>({});
@ -137,10 +135,15 @@ async function loadData(reset = false) {
} }
} }
function buildQueryParams() { function buildQueryParams(includePaging = true) {
query.sysOrigins = getEffectiveSysOrigin(); query.sysOrigins = getEffectiveSysOrigin();
const params = { ...query };
if (!includePaging) {
delete params.lastId;
delete params.limit;
}
return Object.fromEntries( return Object.fromEntries(
Object.entries({ ...query }).filter(([, value]) => { Object.entries(params).filter(([, value]) => {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value.length > 0; return value.length > 0;
} }
@ -153,6 +156,16 @@ function handleSearch() {
void loadData(true); void loadData(true);
} }
async function handleExport() {
exporting.value = true;
try {
await exportInAppPurchaseDetails(buildQueryParams(false));
message.success('导出成功');
} finally {
exporting.value = false;
}
}
function openDetails(record: Record<string, any>) { function openDetails(record: Record<string, any>) {
activeOrderId.value = record.details?.id || ''; activeOrderId.value = record.details?.id || '';
detailsOpen.value = true; detailsOpen.value = true;
@ -233,6 +246,9 @@ void loadData(true);
<Button :loading="loading" type="primary" @click="handleSearch"> <Button :loading="loading" type="primary" @click="handleSearch">
搜索 搜索
</Button> </Button>
<Button :loading="exporting" @click="handleExport">
导出
</Button>
</div> </div>
<div class="summary"> <div class="summary">

View File

@ -8,6 +8,7 @@ import { Page } from '@vben/common-ui';
import { import {
pagePayOpenCounty, pagePayOpenCounty,
syncPayOpenCountryCurrencyRates,
updatePayOpenCountryCurrent, updatePayOpenCountryCurrent,
updatePayOpenCountryExchangeRate, updatePayOpenCountryExchangeRate,
updatePayOpenCountrySort, updatePayOpenCountrySort,
@ -41,6 +42,9 @@ const activeRow = ref<Record<string, any>>({});
const editOpen = ref(false); const editOpen = ref(false);
const editLoading = ref(false); const editLoading = ref(false);
const editValue = ref(''); const editValue = ref('');
const syncOpen = ref(false);
const syncLoading = ref(false);
const syncMarkupPercent = ref('');
const editConfig = reactive({ const editConfig = reactive({
field: '' as 'currency' | 'rate' | 'sort' | '', field: '' as 'currency' | 'rate' | 'sort' | '',
title: '', title: '',
@ -172,6 +176,34 @@ function openAssociated(row: Record<string, any>) {
associatedOpen.value = true; associatedOpen.value = true;
} }
function openSyncRates() {
syncMarkupPercent.value = '';
syncOpen.value = true;
}
function validateSyncMarkupPercent() {
return /^(100(\.0{1,4})?|([1-9]\d?|0)(\.\d{1,4})?)$/.test(syncMarkupPercent.value.trim());
}
async function submitSyncRates() {
if (!validateSyncMarkupPercent()) {
message.warning('请输入0~100之间的上浮比例最多四位小数');
return;
}
syncLoading.value = true;
try {
const result = await syncPayOpenCountryCurrencyRates({
markupPercent: syncMarkupPercent.value.trim(),
});
const skippedText = result?.skippedCount ? `,跳过${result.skippedCount}` : '';
message.success(`同步成功,更新${result?.updatedCount || 0}个国家${skippedText}`);
syncOpen.value = false;
await loadData();
} finally {
syncLoading.value = false;
}
}
void loadData(true); void loadData(true);
</script> </script>
@ -188,6 +220,7 @@ void loadData(true);
/> />
<Button type="primary" @click="countrySelectOpen = true">添加</Button> <Button type="primary" @click="countrySelectOpen = true">添加</Button>
<Button type="primary" @click="handleSearch">刷新</Button> <Button type="primary" @click="handleSearch">刷新</Button>
<Button :loading="syncLoading" @click="openSyncRates">同步全球汇率</Button>
</div> </div>
<Table <Table
@ -268,6 +301,19 @@ void loadData(true);
> >
<Input v-model:value="editValue" /> <Input v-model:value="editValue" />
</Modal> </Modal>
<Modal
:confirm-loading="syncLoading"
:open="syncOpen"
title="同步全球汇率"
@cancel="syncOpen = false"
@ok="submitSyncRates"
>
<Input
v-model:value="syncMarkupPercent"
placeholder="请输入上浮比例5表示上浮5%"
/>
</Modal>
</Page> </Page>
</template> </template>

View File

@ -1,9 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { import { computed, reactive, ref, watch } from 'vue';
computed,
reactive,
ref,
watch } from 'vue';
import { Page } from '@vben/common-ui'; import { Page } from '@vben/common-ui';
import { useAccessStore } from '@vben/stores'; import { useAccessStore } from '@vben/stores';
@ -12,9 +8,9 @@ import {
exportFreightWaters, exportFreightWaters,
pageFreightRunningWater, pageFreightRunningWater,
} from '#/api/legacy/user'; } from '#/api/legacy/user';
import { listBackUserOptions } from '#/api/legacy/system';
import AccountInput from '#/components/account-input.vue'; import AccountInput from '#/components/account-input.vue';
import { formatDate, import { formatDate, getAllowedSysOrigins } from '#/views/system/shared';
getAllowedSysOrigins } from '#/views/system/shared';
import { import {
Button, Button,
@ -23,7 +19,7 @@ import {
Pagination, Pagination,
Select, Select,
Table, Table,
message message,
} from 'antdv-next'; } from 'antdv-next';
import UserProfileLink from './components/user-profile-link.vue'; import UserProfileLink from './components/user-profile-link.vue';
@ -50,11 +46,14 @@ const loading = ref(false);
const total = ref(0); const total = ref(0);
const list = ref<Array<Record<string, any>>>([]); const list = ref<Array<Record<string, any>>>([]);
const exporting = ref(false); const exporting = ref(false);
const operatorsLoading = ref(false);
const operators = ref<Array<Record<string, any>>>([]);
const rangeDate = ref<[string, string] | null>(null); const rangeDate = ref<[string, string] | null>(null);
const query = reactive<Record<string, any>>({ const query = reactive<Record<string, any>>({
acceptUserId: '', acceptUserId: '',
cursor: 1, cursor: 1,
createUsers: [],
endTime: '', endTime: '',
limit: 20, limit: 20,
origin: undefined, origin: undefined,
@ -76,6 +75,17 @@ const columns = [
{ dataIndex: 'createTime', key: 'createTime', title: '创建时间', width: 180 }, { dataIndex: 'createTime', key: 'createTime', title: '创建时间', width: 180 },
]; ];
const operatorOptions = computed(() =>
operators.value.map((item) => {
const name = item.nickname || item.loginName || item.name || item.id;
const account = item.loginName && item.loginName !== name ? ` / ${item.loginName}` : '';
return {
label: `${name}${account}`,
value: item.id,
};
}),
);
watch( watch(
sysOriginOptions, sysOriginOptions,
(options) => { (options) => {
@ -103,6 +113,63 @@ watch(
{ immediate: true }, { immediate: true },
); );
function normalizeOperators(items: Array<Record<string, any>>) {
const operatorMap = new Map<string, Record<string, any>>();
items.forEach((item) => {
const id = item.id || item.applicantId;
if (!id) {
return;
}
operatorMap.set(String(id), {
id,
loginName: item.loginName,
name: item.name,
nickname: item.nickname || item.applicantName,
status: item.status,
});
});
return [...operatorMap.values()];
}
function filterOperatorOption(input: string, option: any) {
return String(option?.label || '')
.toLowerCase()
.includes(input.toLowerCase());
}
async function loadOperators() {
operatorsLoading.value = true;
try {
const result = await listBackUserOptions();
operators.value = normalizeOperators(Array.isArray(result) ? result : []);
} catch {
operators.value = [];
} finally {
operatorsLoading.value = false;
}
}
function buildFreightRunningWaterParams(withPage = true) {
const createUsers = Array.isArray(query.createUsers)
? query.createUsers.filter(Boolean).join(',')
: '';
const params: Record<string, any> = {
acceptUserId: query.acceptUserId || undefined,
createUsers: createUsers || undefined,
endTime: query.endTime || undefined,
origin: query.origin || undefined,
queryBackOperationUser: query.queryBackOperationUser,
startTime: query.startTime || undefined,
sysOrigin: query.sysOrigin,
userId: query.userId || undefined,
};
if (withPage) {
params.cursor = query.cursor;
params.limit = query.limit;
}
return params;
}
async function loadData(reset = false) { async function loadData(reset = false) {
if (!query.sysOrigin) { if (!query.sysOrigin) {
return; return;
@ -112,7 +179,7 @@ async function loadData(reset = false) {
} }
loading.value = true; loading.value = true;
try { try {
const result = await pageFreightRunningWater({ ...query }); const result = await pageFreightRunningWater(buildFreightRunningWaterParams());
list.value = result.records || []; list.value = result.records || [];
total.value = result.total || 0; total.value = result.total || 0;
} finally { } finally {
@ -137,19 +204,26 @@ async function handleExport() {
} }
exporting.value = true; exporting.value = true;
try { try {
await exportFreightWaters({ ...query }); await exportFreightWaters(
buildFreightRunningWaterParams(false),
`ExportFreightWaters_${query.startTime || 'all'}_${query.endTime || 'all'}`,
);
message.success('导出成功'); message.success('导出成功');
} finally { } finally {
exporting.value = false; exporting.value = false;
} }
} }
void loadOperators();
void loadData(true); void loadData(true);
</script> </script>
<template> <template>
<Page v-if="!queryBackOperation" title="货运代理流水"> <component
<Card> :is="queryBackOperation ? 'div' : Page"
:title="queryBackOperation ? undefined : '货运代理流水'"
>
<component :is="queryBackOperation ? 'div' : Card">
<div class="toolbar"> <div class="toolbar">
<SysOriginSelect <SysOriginSelect
v-model:value="query.sysOrigin" v-model:value="query.sysOrigin"
@ -175,9 +249,21 @@ void loadData(true);
allow-clear allow-clear
placeholder="来源" placeholder="来源"
style="width: 140px" style="width: 140px"
:options="FREIGHT_BALANCE_ORIGIN_OPTIONS.map((item) => ({ label: `${item.name}`, value: item.value as any }))" :options="FREIGHT_BALANCE_ORIGIN_OPTIONS.map((item) => ({ label: `${item.name}`, value: item.value as any }))"
/> />
<Select
v-model:value="query.createUsers"
allow-clear
mode="multiple"
show-search
max-tag-count="responsive"
option-label-prop="label"
placeholder="操作人"
style="width: 220px"
:filter-option="filterOperatorOption"
:loading="operatorsLoading"
:options="operatorOptions"
/>
<DatePicker.RangePicker <DatePicker.RangePicker
v-model:value="rangeDate" v-model:value="rangeDate"
show-time show-time
@ -234,8 +320,8 @@ void loadData(true);
@showSizeChange="handlePageChange" @showSizeChange="handlePageChange"
/> />
</div> </div>
</Card> </component>
</Page> </component>
</template> </template>
<style scoped> <style scoped>

View File

@ -42,6 +42,7 @@ function createRowKey(seed?: number | string) {
function createLevel(level = 1, rechargeAmount = 10) { function createLevel(level = 1, rechargeAmount = 10) {
return { return {
enabled: true, enabled: true,
googleProductId: '',
id: null, id: null,
level, level,
rechargeAmount, rechargeAmount,
@ -85,6 +86,7 @@ const form = reactive<Record<string, any>>({
enabled: false, enabled: false,
id: 0, id: 0,
levelConfigs: defaultLevels(), levelConfigs: defaultLevels(),
minAppVersion: '',
sysOrigin: '', sysOrigin: '',
updateTime: '', updateTime: '',
}); });
@ -93,6 +95,7 @@ const levelColumns = [
{ dataIndex: 'enabled', key: 'enabled', title: '启用', width: 90 }, { dataIndex: 'enabled', key: 'enabled', title: '启用', width: 90 },
{ dataIndex: 'level', key: 'level', title: '档位', width: 110 }, { dataIndex: 'level', key: 'level', title: '档位', width: 110 },
{ dataIndex: 'rechargeAmount', key: 'rechargeAmount', title: '首冲金额', width: 180 }, { dataIndex: 'rechargeAmount', key: 'rechargeAmount', title: '首冲金额', width: 180 },
{ dataIndex: 'googleProductId', key: 'googleProductId', title: '谷歌商品ID', width: 240 },
{ dataIndex: 'rewardGroup', key: 'rewardGroup', title: '奖励资源组', width: 420 }, { dataIndex: 'rewardGroup', key: 'rewardGroup', title: '奖励资源组', width: 420 },
{ dataIndex: 'actions', key: 'actions', title: '操作', width: 100 }, { dataIndex: 'actions', key: 'actions', title: '操作', width: 100 },
]; ];
@ -163,6 +166,7 @@ function normalizeConfig(result: null | Record<string, any> | undefined) {
form.configured = Boolean(value.configured); form.configured = Boolean(value.configured);
form.enabled = Boolean(value.enabled); form.enabled = Boolean(value.enabled);
form.id = value.id || 0; form.id = value.id || 0;
form.minAppVersion = String(value.minAppVersion || '');
form.sysOrigin = String(value.sysOrigin || selectedSysOrigin.value || ''); form.sysOrigin = String(value.sysOrigin || selectedSysOrigin.value || '');
form.updateTime = String(value.updateTime || ''); form.updateTime = String(value.updateTime || '');
const levels = Array.isArray(value.levelConfigs) && value.levelConfigs.length > 0 const levels = Array.isArray(value.levelConfigs) && value.levelConfigs.length > 0
@ -175,6 +179,7 @@ function normalizeConfig(result: null | Record<string, any> | undefined) {
); );
return { return {
enabled: item.enabled !== false, enabled: item.enabled !== false,
googleProductId: String(item.googleProductId || ''),
id: item.id ?? null, id: item.id ?? null,
level: Number(item.level || index + 1), level: Number(item.level || index + 1),
rechargeAmount: Number.isFinite(rechargeAmount) ? rechargeAmount : 0, rechargeAmount: Number.isFinite(rechargeAmount) ? rechargeAmount : 0,
@ -277,6 +282,7 @@ function validateBeforeSave() {
} }
const seenLevels = new Set<string>(); const seenLevels = new Set<string>();
const seenAmounts = new Set<string>(); const seenAmounts = new Set<string>();
const seenGoogleProductIds = new Set<string>();
for (const item of levels) { for (const item of levels) {
const level = Number(item.level || 0); const level = Number(item.level || 0);
const amountCents = toAmountCents(item.rechargeAmount); const amountCents = toAmountCents(item.rechargeAmount);
@ -300,6 +306,14 @@ function validateBeforeSave() {
message.warning('首冲金额不能重复'); message.warning('首冲金额不能重复');
return false; return false;
} }
const googleProductId = String(item.googleProductId || '').trim();
if (googleProductId) {
if (seenGoogleProductIds.has(googleProductId)) {
message.warning('谷歌商品ID不能重复');
return false;
}
seenGoogleProductIds.add(googleProductId);
}
seenLevels.add(String(level)); seenLevels.add(String(level));
seenAmounts.add(String(amountCents)); seenAmounts.add(String(amountCents));
} }
@ -307,6 +321,10 @@ function validateBeforeSave() {
message.warning('启用配置时至少要启用一个首冲奖励档位'); message.warning('启用配置时至少要启用一个首冲奖励档位');
return false; return false;
} }
if (form.enabled && !String(form.minAppVersion || '').trim()) {
message.warning('启用配置时需要填写最低App版本');
return false;
}
return true; return true;
} }
@ -321,6 +339,7 @@ async function submitForm() {
id: form.id || 0, id: form.id || 0,
levelConfigs: (form.levelConfigs || []).map((item: Record<string, any>) => ({ levelConfigs: (form.levelConfigs || []).map((item: Record<string, any>) => ({
enabled: item.enabled !== false, enabled: item.enabled !== false,
googleProductId: String(item.googleProductId || '').trim(),
id: item.id || 0, id: item.id || 0,
level: Number(item.level || 0), level: Number(item.level || 0),
rechargeAmount: formatAmount(item.rechargeAmount), rechargeAmount: formatAmount(item.rechargeAmount),
@ -328,6 +347,7 @@ async function submitForm() {
rewardGroupId: normalizeID(item.rewardGroupId) || null, rewardGroupId: normalizeID(item.rewardGroupId) || null,
rewardGroupName: String(item.rewardGroupName || ''), rewardGroupName: String(item.rewardGroupName || ''),
})), })),
minAppVersion: String(form.minAppVersion || '').trim(),
sysOrigin: selectedSysOrigin.value, sysOrigin: selectedSysOrigin.value,
}); });
message.success('保存成功'); message.success('保存成功');
@ -421,6 +441,13 @@ watch(activeTab, (value) => {
<Space align="center" wrap> <Space align="center" wrap>
<span class="field-label">启用</span> <span class="field-label">启用</span>
<Switch v-model:checked="form.enabled" /> <Switch v-model:checked="form.enabled" />
<span class="field-label">最低App版本</span>
<Input
v-model:value="form.minAppVersion"
allow-clear
placeholder="例如 1.5.0"
style="width: 160px"
/>
<span class="sub-text">更新时间{{ form.updateTime || '-' }}</span> <span class="sub-text">更新时间{{ form.updateTime || '-' }}</span>
</Space> </Space>
</div> </div>
@ -431,7 +458,7 @@ watch(activeTab, (value) => {
:loading="loading" :loading="loading"
:pagination="false" :pagination="false"
row-key="rowKey" row-key="rowKey"
:scroll="{ x: 900 }" :scroll="{ x: 1140 }"
> >
<template #bodyCell="{ column, index, record }"> <template #bodyCell="{ column, index, record }">
<template v-if="column.key === 'enabled'"> <template v-if="column.key === 'enabled'">
@ -453,6 +480,14 @@ watch(activeTab, (value) => {
style="width: 140px" style="width: 140px"
/> />
</template> </template>
<template v-else-if="column.key === 'googleProductId'">
<Input
v-model:value="record.googleProductId"
allow-clear
placeholder="Google商品ID"
style="width: 200px"
/>
</template>
<template v-else-if="column.key === 'rewardGroup'"> <template v-else-if="column.key === 'rewardGroup'">
<div class="reward-group-cell"> <div class="reward-group-cell">
<Space wrap> <Space wrap>

View File

@ -196,7 +196,9 @@ const recordSummary = reactive<Record<string, any>>({
endTime: '', endTime: '',
participantCount: 0, participantCount: 0,
startTime: '', startTime: '',
totalDecorationRewardGold: 0,
totalDrawTimes: 0, totalDrawTimes: 0,
totalGiftRewardGold: 0,
totalPaidGold: 0, totalPaidGold: 0,
totalRewardGold: 0, totalRewardGold: 0,
}); });
@ -418,7 +420,9 @@ function normalizeRecordPage(result: null | Record<string, any> | undefined) {
endTime: String(value.endTime || ''), endTime: String(value.endTime || ''),
participantCount: Number(value.participantCount || 0), participantCount: Number(value.participantCount || 0),
startTime: String(value.startTime || ''), startTime: String(value.startTime || ''),
totalDecorationRewardGold: Number(value.totalDecorationRewardGold || 0),
totalDrawTimes: Number(value.totalDrawTimes || 0), totalDrawTimes: Number(value.totalDrawTimes || 0),
totalGiftRewardGold: Number(value.totalGiftRewardGold || 0),
totalPaidGold: Number(value.totalPaidGold || 0), totalPaidGold: Number(value.totalPaidGold || 0),
totalRewardGold: Number(value.totalRewardGold || 0), totalRewardGold: Number(value.totalRewardGold || 0),
}); });
@ -1173,8 +1177,12 @@ watch(activePageTab, (value) => {
<strong>{{ numberText(recordSummary.totalPaidGold) }}</strong> <strong>{{ numberText(recordSummary.totalPaidGold) }}</strong>
</div> </div>
<div class="record-summary-item"> <div class="record-summary-item">
<span>礼物发放金币总价值</span> <span>礼物价值</span>
<strong>{{ numberText(recordSummary.totalRewardGold) }}</strong> <strong>{{ numberText(recordSummary.totalGiftRewardGold) }}</strong>
</div>
<div class="record-summary-item">
<span>装扮价值</span>
<strong>{{ numberText(recordSummary.totalDecorationRewardGold) }}</strong>
</div> </div>
<div class="record-summary-item"> <div class="record-summary-item">
<span>用户总抽奖次数</span> <span>用户总抽奖次数</span>
@ -1318,7 +1326,7 @@ watch(activePageTab, (value) => {
.record-summary { .record-summary {
display: grid; display: grid;
gap: 10px; gap: 10px;
grid-template-columns: repeat(4, minmax(160px, 1fr)); grid-template-columns: repeat(5, minmax(160px, 1fr));
margin-bottom: 16px; margin-bottom: 16px;
} }

View File

@ -164,6 +164,7 @@ const metricColumns = new Set([
'd7RetentionRate', 'd7RetentionRate',
'd30RetentionRate', 'd30RetentionRate',
'dailyActiveUser', 'dailyActiveUser',
'dailyRechargeUser',
'dealerRecharge', 'dealerRecharge',
'gamePayout', 'gamePayout',
'gamePayoutRate', 'gamePayoutRate',
@ -181,15 +182,18 @@ const metricColumns = new Set([
'luckyGiftUser', 'luckyGiftUser',
'luckyGiftUserRate', 'luckyGiftUserRate',
'mifapayRecharge', 'mifapayRecharge',
'newDealerUserRecharge',
'newUserRecharge', 'newUserRecharge',
'salaryExchange', 'salaryExchange',
'salaryTransfer', 'salaryTransfer',
'totalRecharge', 'totalRecharge',
'userRecharge',
]); ]);
const integerMetricColumns = new Set([ const integerMetricColumns = new Set([
'countryNewUser', 'countryNewUser',
'dailyActiveUser', 'dailyActiveUser',
'dailyRechargeUser',
'gameUser', 'gameUser',
'luckyGiftUser', 'luckyGiftUser',
]); ]);
@ -208,8 +212,11 @@ const percentMetricColumns = new Set([
const summaryMetricKeys = [ const summaryMetricKeys = [
'countryNewUser', 'countryNewUser',
'dailyActiveUser', 'dailyActiveUser',
'dailyRechargeUser',
'newUserRecharge', 'newUserRecharge',
'dealerRecharge', 'dealerRecharge',
'newDealerUserRecharge',
'userRecharge',
'mifapayRecharge', 'mifapayRecharge',
'googleRecharge', 'googleRecharge',
'salaryExchange', 'salaryExchange',
@ -235,8 +242,11 @@ const summaryMetricKeys = [
const summaryMetricDefinitions = [ const summaryMetricDefinitions = [
{ key: 'countryNewUser', label: '新增' }, { key: 'countryNewUser', label: '新增' },
{ key: 'dailyActiveUser', label: '当日活跃' }, { key: 'dailyActiveUser', label: '当日活跃' },
{ key: 'newUserRecharge', label: '新增充值' }, { key: 'dailyRechargeUser', label: '新增当日充值用户数' },
{ key: 'dealerRecharge', label: '币商充值' }, { key: 'newUserRecharge', label: '新增用户充值' },
{ key: 'dealerRecharge', label: '币商平台充值' },
{ key: 'newDealerUserRecharge', label: '新增币商用户充值' },
{ key: 'userRecharge', label: '用户充值' },
{ key: 'mifapayRecharge', label: 'MifaPay 充值' }, { key: 'mifapayRecharge', label: 'MifaPay 充值' },
{ key: 'googleRecharge', label: 'Google 充值' }, { key: 'googleRecharge', label: 'Google 充值' },
{ key: 'salaryExchange', label: '工资兑换' }, { key: 'salaryExchange', label: '工资兑换' },
@ -284,8 +294,11 @@ const overviewColumns: any[] = [
{ align: 'right', dataIndex: 'd1RetentionRate', key: 'd1RetentionRate', title: '次日留存率', width: 140 }, { align: 'right', dataIndex: 'd1RetentionRate', key: 'd1RetentionRate', title: '次日留存率', width: 140 },
{ align: 'right', dataIndex: 'd7RetentionRate', key: 'd7RetentionRate', 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: 'd30RetentionRate', key: 'd30RetentionRate', title: '30 日留存率', width: 150 },
{ align: 'right', dataIndex: 'newUserRecharge', key: 'newUserRecharge', title: '新增充值', width: 130 }, { align: 'right', dataIndex: 'dailyRechargeUser', key: 'dailyRechargeUser', title: '新增当日充值用户数', width: 180 },
{ align: 'right', dataIndex: 'dealerRecharge', key: 'dealerRecharge', title: '币商充值', width: 130 }, { align: 'right', dataIndex: 'newUserRecharge', key: 'newUserRecharge', title: '新增用户充值', width: 140 },
{ align: 'right', dataIndex: 'dealerRecharge', key: 'dealerRecharge', title: '币商平台充值', width: 140 },
{ align: 'right', dataIndex: 'newDealerUserRecharge', key: 'newDealerUserRecharge', title: '新增币商用户充值', width: 170 },
{ align: 'right', dataIndex: 'userRecharge', key: 'userRecharge', title: '用户充值', width: 130 },
{ align: 'right', dataIndex: 'mifapayRecharge', key: 'mifapayRecharge', title: 'MifaPay 充值', width: 150 }, { align: 'right', dataIndex: 'mifapayRecharge', key: 'mifapayRecharge', title: 'MifaPay 充值', width: 150 },
{ align: 'right', dataIndex: 'googleRecharge', key: 'googleRecharge', title: 'Google 充值', width: 140 }, { align: 'right', dataIndex: 'googleRecharge', key: 'googleRecharge', title: 'Google 充值', width: 140 },
{ align: 'right', dataIndex: 'salaryExchange', key: 'salaryExchange', title: '工资兑换', width: 130 }, { align: 'right', dataIndex: 'salaryExchange', key: 'salaryExchange', title: '工资兑换', width: 130 },
@ -315,7 +328,9 @@ const rechargeColumns: any[] = [
{ dataIndex: 'countryName', fixed: 'left' as const, key: 'countryName', title: '国家', width: 150 }, { dataIndex: 'countryName', fixed: 'left' as const, key: 'countryName', title: '国家', width: 150 },
{ dataIndex: 'countryCode', key: 'countryCode', title: 'Code', width: 86 }, { dataIndex: 'countryCode', key: 'countryCode', title: 'Code', width: 86 },
{ dataIndex: 'periodName', key: 'periodName', title: '周期', width: 110 }, { dataIndex: 'periodName', key: 'periodName', title: '周期', width: 110 },
{ align: 'right', dataIndex: 'dealerRecharge', key: 'dealerRecharge', title: '币商充值', width: 140 }, { align: 'right', dataIndex: 'dealerRecharge', key: 'dealerRecharge', title: '币商平台充值', width: 150 },
{ align: 'right', dataIndex: 'newDealerUserRecharge', key: 'newDealerUserRecharge', title: '新增币商用户充值', width: 170 },
{ align: 'right', dataIndex: 'userRecharge', key: 'userRecharge', title: '用户充值', width: 140 },
{ align: 'right', dataIndex: 'mifapayRecharge', key: 'mifapayRecharge', title: 'MifaPay 充值', width: 160 }, { align: 'right', dataIndex: 'mifapayRecharge', key: 'mifapayRecharge', title: 'MifaPay 充值', width: 160 },
{ align: 'right', dataIndex: 'googleRecharge', key: 'googleRecharge', title: 'Google 充值', width: 150 }, { align: 'right', dataIndex: 'googleRecharge', key: 'googleRecharge', title: 'Google 充值', width: 150 },
{ align: 'right', dataIndex: 'totalRecharge', key: 'totalRecharge', title: '总充值', width: 140 }, { align: 'right', dataIndex: 'totalRecharge', key: 'totalRecharge', title: '总充值', width: 140 },
@ -385,7 +400,7 @@ const rechargeDetailScrollX = rechargeDetailColumns.reduce(
0, 0,
); );
const rechargeSourceNameMap: Record<string, string> = { const rechargeSourceNameMap: Record<string, string> = {
DEALER: '币商充值', DEALER: '币商平台充值',
MIFAPAY: 'MifaPay 充值', MIFAPAY: 'MifaPay 充值',
OFFICIAL: '官方充值', OFFICIAL: '官方充值',
}; };
@ -446,6 +461,7 @@ const appDataCards = computed(() => {
const arpuCards = computed(() => { const arpuCards = computed(() => {
return [ return [
{ label: '日 ARPU', value: formatArpu(arpuDashboards.value.day) }, { label: '日 ARPU', value: formatArpu(arpuDashboards.value.day) },
{ label: '充值用户日 ARPU', value: formatRechargeUserDayArpu(arpuDashboards.value.day) },
{ label: '3 日 ARPU', value: formatArpu(arpuDashboards.value.threeDay) }, { label: '3 日 ARPU', value: formatArpu(arpuDashboards.value.threeDay) },
{ label: '周 ARPU', value: formatArpu(arpuDashboards.value.week) }, { label: '周 ARPU', value: formatArpu(arpuDashboards.value.week) },
{ label: '筛选 ARPU', value: formatAmount(calcArpu(visibleSummary.value)) }, { label: '筛选 ARPU', value: formatAmount(calcArpu(visibleSummary.value)) },
@ -455,6 +471,10 @@ const totalRechargeCard = computed(() => ({
label: '总充值', label: '总充值',
value: formatAmount(visibleSummary.value.totalRecharge), value: formatAmount(visibleSummary.value.totalRecharge),
})); }));
const userRechargeCard = computed(() => ({
label: '用户充值',
value: formatAmount(visibleSummary.value.userRecharge),
}));
function hasPermission(code: string) { function hasPermission(code: string) {
const codes = accessCodes.value; const codes = accessCodes.value;
@ -502,36 +522,18 @@ function buildParams() {
return params; return params;
} }
function buildRecentDayParams(dayCount: number) {
const end = dayjs().tz(DEFAULT_STAT_TIMEZONE);
const start = end.subtract(dayCount - 1, 'day');
return {
periodType: 'DAY',
startDate: start.format('YYYY-MM-DD'),
endDate: end.format('YYYY-MM-DD'),
statTimezone: DEFAULT_STAT_TIMEZONE,
sysOrigin: query.sysOrigin || undefined,
};
}
async function loadDashboard() { async function loadDashboard() {
if (!canQuery.value) { if (!canQuery.value) {
return; return;
} }
loading.value = true; loading.value = true;
try { try {
const [mainDashboard, dayArpuDashboard, threeDayArpuDashboard, weekArpuDashboard] = const mainDashboard = await countryDashboard(buildParams());
await Promise.all([
countryDashboard(buildParams()),
countryDashboard(buildRecentDayParams(1)),
countryDashboard(buildRecentDayParams(3)),
countryDashboard(buildRecentDayParams(7)),
]);
dashboard.value = mainDashboard; dashboard.value = mainDashboard;
arpuDashboards.value = { arpuDashboards.value = {
day: dayArpuDashboard, day: mainDashboard.arpuSummary?.day || null,
threeDay: threeDayArpuDashboard, threeDay: mainDashboard.arpuSummary?.threeDay || null,
week: weekArpuDashboard, week: mainDashboard.arpuSummary?.week || null,
}; };
if (activeTableTab.value === 'game') { if (activeTableTab.value === 'game') {
await loadGameMetrics(); await loadGameMetrics();
@ -818,11 +820,24 @@ function calcArpu(summary: Record<string, any>) {
return toAmount(summary.totalRecharge) / activeUser; return toAmount(summary.totalRecharge) / activeUser;
} }
function calcRechargeUserDayArpu(summary: Record<string, any>) {
const dailyRechargeUser = toAmount(summary.dailyRechargeUser);
if (dailyRechargeUser === 0) {
return 0;
}
return toAmount(summary.userRecharge) / dailyRechargeUser;
}
function formatArpu(result?: CountryDashboardResult | null) { function formatArpu(result?: CountryDashboardResult | null) {
const summary = buildVisibleSummary(filterRecordsBySelectedCountries(result?.records || [])); const summary = buildVisibleSummary(filterRecordsBySelectedCountries(result?.records || []));
return formatAmount(calcArpu(summary)); return formatAmount(calcArpu(summary));
} }
function formatRechargeUserDayArpu(result?: CountryDashboardResult | null) {
const summary = buildVisibleSummary(filterRecordsBySelectedCountries(result?.records || []));
return formatAmount(calcRechargeUserDayArpu(summary));
}
function emptySummary(): SummaryMetric { function emptySummary(): SummaryMetric {
return { return {
countryNewUser: 0, countryNewUser: 0,
@ -836,6 +851,7 @@ function emptySummary(): SummaryMetric {
d30RetentionRate: 0, d30RetentionRate: 0,
d30RetentionUser: 0, d30RetentionUser: 0,
dailyActiveUser: 0, dailyActiveUser: 0,
dailyRechargeUser: 0,
dealerRecharge: 0, dealerRecharge: 0,
googleRecharge: 0, googleRecharge: 0,
gamePayout: 0, gamePayout: 0,
@ -853,10 +869,12 @@ function emptySummary(): SummaryMetric {
luckyGiftUser: 0, luckyGiftUser: 0,
luckyGiftUserRate: 0, luckyGiftUserRate: 0,
mifapayRecharge: 0, mifapayRecharge: 0,
newDealerUserRecharge: 0,
newUserRecharge: 0, newUserRecharge: 0,
salaryExchange: 0, salaryExchange: 0,
salaryTransfer: 0, salaryTransfer: 0,
totalRecharge: 0, totalRecharge: 0,
userRecharge: 0,
}; };
} }
@ -1075,8 +1093,11 @@ async function renderVisualCharts() {
} }
renderUserRechargeChart(baseChartOption('新增用户与充值', list, [ renderUserRechargeChart(baseChartOption('新增用户与充值', list, [
amountSeries('新增用户', 'countryNewUser', list), amountSeries('新增用户', 'countryNewUser', list),
amountSeries('新增充值', 'newUserRecharge', list), amountSeries('当日充值用户数', 'dailyRechargeUser', list),
amountSeries('币商充值', 'dealerRecharge', list), amountSeries('新增用户充值', 'newUserRecharge', list),
amountSeries('币商平台充值', 'dealerRecharge', list),
amountSeries('新增币商用户充值', 'newDealerUserRecharge', list),
amountSeries('用户充值', 'userRecharge', list),
amountSeries('MifaPay 充值', 'mifapayRecharge', list), amountSeries('MifaPay 充值', 'mifapayRecharge', list),
amountSeries('Google 充值', 'googleRecharge', list), amountSeries('Google 充值', 'googleRecharge', list),
amountSeries('工资兑换', 'salaryExchange', list), amountSeries('工资兑换', 'salaryExchange', list),
@ -1242,6 +1263,10 @@ onBeforeUnmount(() => {
<div class="metric-card__label">{{ totalRechargeCard.label }}</div> <div class="metric-card__label">{{ totalRechargeCard.label }}</div>
<div class="metric-card__value">{{ totalRechargeCard.value }}</div> <div class="metric-card__value">{{ totalRechargeCard.value }}</div>
</div> </div>
<div class="metric-card">
<div class="metric-card__label">{{ userRechargeCard.label }}</div>
<div class="metric-card__value">{{ userRechargeCard.value }}</div>
</div>
</div> </div>
</section> </section>
@ -1583,7 +1608,7 @@ onBeforeUnmount(() => {
} }
.metric-card-grid--recharge { .metric-card-grid--recharge {
grid-template-columns: minmax(180px, 260px); grid-template-columns: repeat(2, minmax(180px, 260px));
} }
.metric-card { .metric-card {

View File

@ -13,6 +13,7 @@ import {
FormItem, FormItem,
Input, Input,
Modal, Modal,
Switch,
message, message,
} from 'antdv-next'; } from 'antdv-next';
@ -29,7 +30,19 @@ const emit = defineEmits<{
const saving = ref(false); const saving = ref(false);
type FeatureSwitchKey =
| 'canAddBdLeader'
| 'canBanUser'
| 'canChangeCountry'
| 'canGiftProps'
| 'canUnbanUser';
const form = reactive({ const form = reactive({
canAddBdLeader: true,
canBanUser: true,
canChangeCountry: true,
canGiftProps: true,
canUnbanUser: true,
contact: '', contact: '',
id: '', id: '',
regionId: '', regionId: '',
@ -40,6 +53,46 @@ const form = reactive({
const isUpdate = computed(() => Boolean(props.row?.id)); const isUpdate = computed(() => Boolean(props.row?.id));
const title = computed(() => (isUpdate.value ? '修改经理' : '添加经理')); const title = computed(() => (isUpdate.value ? '修改经理' : '添加经理'));
function normalizeFeatureEnabled(value: unknown) {
if (value === undefined || value === null || value === '') {
return true;
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
const normalized = String(value).trim().toLowerCase();
return !['0', 'false', 'n', 'no', 'off', '关', '关闭'].includes(normalized);
}
function camelToSnake(key: string) {
return key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
}
function featurePayloadKey(key: FeatureSwitchKey) {
const bodyKey = key.replace(/^can/, '');
return bodyKey ? `${bodyKey[0]?.toLowerCase()}${bodyKey.slice(1)}` : key;
}
function rowFeatureValue(row: null | Record<string, any>, key: FeatureSwitchKey) {
const snakeKey = camelToSnake(key);
const value = Object.prototype.hasOwnProperty.call(row || {}, key)
? row?.[key]
: undefined;
if (value !== undefined) {
return value;
}
if (Object.prototype.hasOwnProperty.call(row || {}, snakeKey)) {
return row?.[snakeKey];
}
const payloadKey = featurePayloadKey(key);
return Object.prototype.hasOwnProperty.call(row?.features || {}, payloadKey)
? row?.features?.[payloadKey]
: row?.features?.[key];
}
watch( watch(
() => props.open, () => props.open,
(open) => { (open) => {
@ -51,10 +104,23 @@ watch(
form.sysOrigin = props.row?.sysOrigin || props.sysOrigin || 'LIKEI'; form.sysOrigin = props.row?.sysOrigin || props.sysOrigin || 'LIKEI';
form.contact = props.row?.contact || ''; form.contact = props.row?.contact || '';
form.regionId = String(props.row?.regionId || ''); form.regionId = String(props.row?.regionId || '');
form.canAddBdLeader = normalizeFeatureEnabled(rowFeatureValue(props.row, 'canAddBdLeader'));
form.canGiftProps = normalizeFeatureEnabled(rowFeatureValue(props.row, 'canGiftProps'));
form.canBanUser = normalizeFeatureEnabled(rowFeatureValue(props.row, 'canBanUser'));
form.canUnbanUser = normalizeFeatureEnabled(rowFeatureValue(props.row, 'canUnbanUser'));
form.canChangeCountry = normalizeFeatureEnabled(rowFeatureValue(props.row, 'canChangeCountry'));
}, },
{ immediate: true }, { immediate: true },
); );
function setFeature(key: FeatureSwitchKey, checked: unknown) {
form[key] = normalizeFeatureEnabled(checked);
}
function toggleFeature(key: FeatureSwitchKey) {
form[key] = !form[key];
}
async function handleSubmit() { async function handleSubmit() {
if (!isUpdate.value && !String(form.userId || '').trim()) { if (!isUpdate.value && !String(form.userId || '').trim()) {
message.warning('请输入经理用户ID'); message.warning('请输入经理用户ID');
@ -67,6 +133,11 @@ async function handleSubmit() {
saving.value = true; saving.value = true;
try { try {
const payload = { const payload = {
canAddBdLeader: form.canAddBdLeader,
canBanUser: form.canBanUser,
canChangeCountry: form.canChangeCountry,
canGiftProps: form.canGiftProps,
canUnbanUser: form.canUnbanUser,
contact: form.contact.trim(), contact: form.contact.trim(),
id: form.id || undefined, id: form.id || undefined,
regionId: form.regionId, regionId: form.regionId,
@ -114,6 +185,123 @@ async function handleSubmit() {
<FormItem label="联系方式"> <FormItem label="联系方式">
<Input v-model:value="form.contact" placeholder="联系方式" /> <Input v-model:value="form.contact" placeholder="联系方式" />
</FormItem> </FormItem>
<FormItem label="功能开关">
<div class="feature-switches">
<div
class="feature-switch-row"
role="switch"
tabindex="0"
:aria-checked="form.canAddBdLeader"
@click="toggleFeature('canAddBdLeader')"
@keydown.enter.prevent="toggleFeature('canAddBdLeader')"
@keydown.space.prevent="toggleFeature('canAddBdLeader')"
>
<span>加BD Leader</span>
<span class="feature-switch-control" @click.stop>
<Switch
:checked="form.canAddBdLeader"
@change="(checked) => setFeature('canAddBdLeader', checked)"
/>
</span>
</div>
<div
class="feature-switch-row"
role="switch"
tabindex="0"
:aria-checked="form.canGiftProps"
@click="toggleFeature('canGiftProps')"
@keydown.enter.prevent="toggleFeature('canGiftProps')"
@keydown.space.prevent="toggleFeature('canGiftProps')"
>
<span>赠送道具</span>
<span class="feature-switch-control" @click.stop>
<Switch
:checked="form.canGiftProps"
@change="(checked) => setFeature('canGiftProps', checked)"
/>
</span>
</div>
<div
class="feature-switch-row"
role="switch"
tabindex="0"
:aria-checked="form.canBanUser"
@click="toggleFeature('canBanUser')"
@keydown.enter.prevent="toggleFeature('canBanUser')"
@keydown.space.prevent="toggleFeature('canBanUser')"
>
<span>封禁用户</span>
<span class="feature-switch-control" @click.stop>
<Switch
:checked="form.canBanUser"
@change="(checked) => setFeature('canBanUser', checked)"
/>
</span>
</div>
<div
class="feature-switch-row"
role="switch"
tabindex="0"
:aria-checked="form.canUnbanUser"
@click="toggleFeature('canUnbanUser')"
@keydown.enter.prevent="toggleFeature('canUnbanUser')"
@keydown.space.prevent="toggleFeature('canUnbanUser')"
>
<span>解封用户</span>
<span class="feature-switch-control" @click.stop>
<Switch
:checked="form.canUnbanUser"
@change="(checked) => setFeature('canUnbanUser', checked)"
/>
</span>
</div>
<div
class="feature-switch-row"
role="switch"
tabindex="0"
:aria-checked="form.canChangeCountry"
@click="toggleFeature('canChangeCountry')"
@keydown.enter.prevent="toggleFeature('canChangeCountry')"
@keydown.space.prevent="toggleFeature('canChangeCountry')"
>
<span>改国家</span>
<span class="feature-switch-control" @click.stop>
<Switch
:checked="form.canChangeCountry"
@change="(checked) => setFeature('canChangeCountry', checked)"
/>
</span>
</div>
</div>
</FormItem>
</Form> </Form>
</Modal> </Modal>
</template> </template>
<style scoped>
.feature-switches {
display: grid;
gap: 10px;
}
.feature-switch-row {
align-items: center;
border: 1px solid #e5e7eb;
border-radius: 6px;
cursor: pointer;
display: flex;
justify-content: space-between;
padding: 10px 12px;
}
.feature-switch-row:focus-visible {
border-color: #1677ff;
outline: 2px solid rgb(22 119 255 / 20%);
outline-offset: 2px;
}
.feature-switch-control {
align-items: center;
display: inline-flex;
}
</style>

View File

@ -23,6 +23,7 @@ import {
Pagination, Pagination,
Space, Space,
Table, Table,
Tag,
message, message,
} from 'antdv-next'; } from 'antdv-next';
@ -55,12 +56,21 @@ const columns = [
{ dataIndex: 'userProfile', key: 'userProfile', title: '经理', width: 240 }, { dataIndex: 'userProfile', key: 'userProfile', title: '经理', width: 240 },
{ dataIndex: 'regionName', key: 'regionName', title: '区域', width: 140 }, { dataIndex: 'regionName', key: 'regionName', title: '区域', width: 140 },
{ dataIndex: 'contact', key: 'contact', title: '联系方式', width: 180 }, { dataIndex: 'contact', key: 'contact', title: '联系方式', width: 180 },
{ dataIndex: 'features', key: 'features', title: '功能开关', width: 340 },
{ dataIndex: 'createUserName', key: 'createUserName', title: '创建人', width: 140 }, { dataIndex: 'createUserName', key: 'createUserName', title: '创建人', width: 140 },
{ dataIndex: 'updateUserName', key: 'updateUserName', title: '修改人', width: 140 }, { dataIndex: 'updateUserName', key: 'updateUserName', title: '修改人', width: 140 },
{ dataIndex: 'createTime', key: 'createTime', title: '创建时间', width: 180 }, { dataIndex: 'createTime', key: 'createTime', title: '创建时间', width: 180 },
{ dataIndex: 'actions', key: 'actions', title: '操作', width: 120 }, { dataIndex: 'actions', key: 'actions', title: '操作', width: 120 },
]; ];
const managerFeatureSwitches = [
{ key: 'canAddBdLeader', label: '加BD Leader' },
{ key: 'canGiftProps', label: '赠送道具' },
{ key: 'canBanUser', label: '封禁用户' },
{ key: 'canUnbanUser', label: '解封用户' },
{ key: 'canChangeCountry', label: '改国家' },
];
function hasPermission(code: string) { function hasPermission(code: string) {
const codes = accessStore.accessCodes || []; const codes = accessStore.accessCodes || [];
return codes.length === 0 || codes.includes(code); return codes.length === 0 || codes.includes(code);
@ -75,6 +85,46 @@ function getUserShortId(profile?: Record<string, any> | null) {
return profile?.actualAccount || profile?.account || profile?.id || '-'; return profile?.actualAccount || profile?.account || profile?.id || '-';
} }
function normalizeFeatureEnabled(value: unknown) {
if (value === undefined || value === null || value === '') {
return true;
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
const normalized = String(value).trim().toLowerCase();
return !['0', 'false', 'n', 'no', 'off', '关', '关闭'].includes(normalized);
}
function camelToSnake(key: string) {
return key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
}
function featurePayloadKey(key: string) {
const bodyKey = key.replace(/^can/, '');
return bodyKey ? `${bodyKey[0]?.toLowerCase()}${bodyKey.slice(1)}` : key;
}
function isFeatureEnabled(record: Record<string, any>, key: string) {
const snakeKey = camelToSnake(key);
let value = Object.prototype.hasOwnProperty.call(record || {}, key)
? record?.[key]
: undefined;
if (value === undefined && Object.prototype.hasOwnProperty.call(record || {}, snakeKey)) {
value = record?.[snakeKey];
}
if (value === undefined && record?.features) {
const payloadKey = featurePayloadKey(key);
value = Object.prototype.hasOwnProperty.call(record.features, payloadKey)
? record.features[payloadKey]
: record.features[key];
}
return normalizeFeatureEnabled(value);
}
async function loadData(reset = false) { async function loadData(reset = false) {
if (reset) { if (reset) {
query.cursor = 1; query.cursor = 1;
@ -186,6 +236,17 @@ loadData(true);
<template v-else-if="column.key === 'contact'"> <template v-else-if="column.key === 'contact'">
{{ canEdit ? record.contact || '-' : '-' }} {{ canEdit ? record.contact || '-' : '-' }}
</template> </template>
<template v-else-if="column.key === 'features'">
<Space wrap :size="[4, 4]">
<Tag
v-for="item in managerFeatureSwitches"
:key="item.key"
:color="isFeatureEnabled(record, item.key) ? 'green' : 'red'"
>
{{ item.label }}{{ isFeatureEnabled(record, item.key) ? '开' : '关' }}
</Tag>
</Space>
</template>
<template v-else-if="column.key === 'createTime'"> <template v-else-if="column.key === 'createTime'">
{{ formatDate(record.createTime) }} {{ formatDate(record.createTime) }}
</template> </template>

View File

@ -5,8 +5,7 @@ const DEV_API_INTERNAL_PREFIX = '/__dev_api_internal__';
const DEV_API_PROD_PREFIX = '/__dev_api_prod__'; const DEV_API_PROD_PREFIX = '/__dev_api_prod__';
const DEV_INTERNAL_CONSOLE_TARGET = 'http://127.0.0.1:2700'; const DEV_INTERNAL_CONSOLE_TARGET = 'http://127.0.0.1:2700';
const DEV_INTERNAL_GOLANG_TARGET = 'http://127.0.0.1:2900'; const DEV_INTERNAL_GOLANG_TARGET = 'http://127.0.0.1:2900';
const DEV_PROD_GOLANG_TARGET = 'https://jvapi.haiyihy.com'; const DEV_PROD_TARGET = 'https://yumiadmin-acc.global-interaction.com';
const DEV_PROD_TARGET = 'https://yumi-admin.haiyihy.com';
function getBundledAssetFileName(assetInfo: { function getBundledAssetFileName(assetInfo: {
name?: string; name?: string;
@ -79,8 +78,11 @@ export default defineConfig(async () => {
[`${DEV_API_PROD_PREFIX}/go`]: { [`${DEV_API_PROD_PREFIX}/go`]: {
changeOrigin: true, changeOrigin: true,
rewrite: (path) => rewrite: (path) =>
path.replace(createPrefixRegExp(DEV_API_PROD_PREFIX), ''), path.replace(
target: DEV_PROD_GOLANG_TARGET, createPrefixRegExp(`${DEV_API_PROD_PREFIX}/go`),
`${DEV_CONSOLE_PREFIX}/go`,
),
target: DEV_PROD_TARGET,
ws: true, ws: true,
}, },
[DEV_API_PROD_PREFIX]: { [DEV_API_PROD_PREFIX]: {

View File

@ -650,6 +650,71 @@ SET
router = 'baishun-config' router = 'baishun-config'
WHERE alias = 'OperateBaishunGameManage'; WHERE alias = 'OperateBaishunGameManage';
INSERT INTO sys_menu (
id,
parent_id,
menu_name,
path,
menu_type,
icon,
create_user,
update_user,
sort,
status,
router,
alias
)
SELECT
next_id,
(
SELECT game_manager.id
FROM (
SELECT id
FROM sys_menu
WHERE alias = 'OperateGameManager'
LIMIT 1
) AS game_manager
),
'热游配置',
'operate/hotgame-config',
2,
'form',
'0',
'0',
4,
0,
'hotgame-config',
'OperateHotgameConfig'
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 = 'OperateHotgameConfig'
);
UPDATE sys_menu
SET
parent_id = (
SELECT game_manager.id
FROM (
SELECT id
FROM sys_menu
WHERE alias = 'OperateGameManager'
LIMIT 1
) AS game_manager
),
menu_name = '热游配置',
path = 'operate/hotgame-config',
menu_type = 2,
icon = 'form',
create_user = '0',
update_user = '0',
sort = 4,
status = 0,
router = 'hotgame-config'
WHERE alias = 'OperateHotgameConfig';
INSERT INTO sys_role_menu (role_id, menu_id) INSERT INTO sys_role_menu (role_id, menu_id)
SELECT DISTINCT SELECT DISTINCT
parent_role.role_id, parent_role.role_id,
@ -669,7 +734,8 @@ JOIN sys_menu AS target_menu
'ResidentDailyTaskConfig', 'ResidentDailyTaskConfig',
'OperateGameManager', 'OperateGameManager',
'GameConfig', 'GameConfig',
'OperateBaishunGameManage' 'OperateBaishunGameManage',
'OperateHotgameConfig'
) )
LEFT JOIN sys_role_menu AS existing_role_menu 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
@ -677,6 +743,21 @@ LEFT JOIN sys_role_menu AS existing_role_menu
WHERE parent_role.menu_id = 1700000057 WHERE parent_role.menu_id = 1700000057
AND existing_role_menu.id IS NULL; AND existing_role_menu.id IS NULL;
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT DISTINCT
source_role.role_id,
target_menu.id
FROM sys_role_menu AS source_role
JOIN sys_menu AS source_menu
ON source_menu.id = source_role.menu_id
JOIN sys_menu AS target_menu
ON target_menu.alias = 'OperateHotgameConfig'
LEFT JOIN sys_role_menu AS existing_role_menu
ON existing_role_menu.role_id = source_role.role_id
AND existing_role_menu.menu_id = target_menu.id
WHERE source_menu.alias IN ('OperateGameManager', 'GameConfig', 'OperateBaishunGameManage', 'OperateLingxianConfig')
AND existing_role_menu.id IS NULL;
INSERT INTO sys_role_menu (role_id, menu_id) INSERT INTO sys_role_menu (role_id, menu_id)
SELECT DISTINCT SELECT DISTINCT
parent_role.role_id, parent_role.role_id,
@ -831,6 +912,10 @@ UPDATE sys_menu
SET menu_name = CONVERT(0xE799BEE9A1BAE9858DE7BDAE USING utf8mb4) SET menu_name = CONVERT(0xE799BEE9A1BAE9858DE7BDAE USING utf8mb4)
WHERE alias = 'OperateBaishunGameManage'; WHERE alias = 'OperateBaishunGameManage';
UPDATE sys_menu
SET menu_name = CONVERT(0xE783ADE6B8B8E9858DE7BDAE USING utf8mb4)
WHERE alias = 'OperateHotgameConfig';
SET @resident_activity_id := ( SET @resident_activity_id := (
SELECT id FROM sys_menu WHERE alias = 'ResidentActivityManager' LIMIT 1 SELECT id FROM sys_menu WHERE alias = 'ResidentActivityManager' LIMIT 1
); );

View File

@ -0,0 +1,81 @@
SET NAMES utf8mb4;
INSERT INTO sys_menu (
id,
parent_id,
menu_name,
path,
menu_type,
icon,
create_user,
update_user,
sort,
status,
router,
alias
)
SELECT
next_id,
(
SELECT game_manager.id
FROM (
SELECT id
FROM sys_menu
WHERE alias = 'OperateGameManager'
LIMIT 1
) AS game_manager
),
'热游配置',
'operate/hotgame-config',
2,
'form',
'0',
'0',
4,
0,
'hotgame-config',
'OperateHotgameConfig'
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 = 'OperateHotgameConfig'
);
UPDATE sys_menu
SET
parent_id = (
SELECT game_manager.id
FROM (
SELECT id
FROM sys_menu
WHERE alias = 'OperateGameManager'
LIMIT 1
) AS game_manager
),
menu_name = '热游配置',
path = 'operate/hotgame-config',
menu_type = 2,
icon = 'form',
create_user = '0',
update_user = '0',
sort = 4,
status = 0,
router = 'hotgame-config'
WHERE alias = 'OperateHotgameConfig';
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT DISTINCT
source_role.role_id,
target_menu.id
FROM sys_role_menu AS source_role
JOIN sys_menu AS source_menu
ON source_menu.id = source_role.menu_id
JOIN sys_menu AS target_menu
ON target_menu.alias = 'OperateHotgameConfig'
LEFT JOIN sys_role_menu AS existing_role_menu
ON existing_role_menu.role_id = source_role.role_id
AND existing_role_menu.menu_id = target_menu.id
WHERE source_menu.alias IN ('OperateGameManager', 'GameConfig', 'OperateBaishunGameManage', 'OperateLingxianConfig')
AND existing_role_menu.id IS NULL;