1242 lines
40 KiB
Vue
1242 lines
40 KiB
Vue
<script lang="ts" setup>
|
||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||
|
||
import { Page } from '@vben/common-ui';
|
||
import { useAccessStore } from '@vben/stores';
|
||
|
||
import {
|
||
Button,
|
||
Card,
|
||
Input,
|
||
InputNumber,
|
||
message,
|
||
Select,
|
||
Space,
|
||
Spin,
|
||
Switch,
|
||
Table,
|
||
TabPane,
|
||
Tabs,
|
||
Tag,
|
||
} from 'antdv-next';
|
||
|
||
import {
|
||
getResidentWheelConfig,
|
||
pageResidentWheelDrawRecords,
|
||
retryResidentWheelDrawRecord,
|
||
saveResidentWheelCategoryConfig,
|
||
} from '#/api/legacy/resident-activity';
|
||
import PropsSourceSelectDrawer from '#/views/operate/components/props-source-select-drawer.vue';
|
||
import { getAllowedSysOrigins } from '#/views/system/shared';
|
||
|
||
defineOptions({ name: 'ResidentWheelConfigPage' });
|
||
|
||
const PROBABILITY_TOTAL = 1_000_000;
|
||
const WHEEL_CATEGORIES = [
|
||
{
|
||
category: 'CLASSIC',
|
||
label: 'Classic',
|
||
priceFiftyGold: 25_000,
|
||
priceOneGold: 500,
|
||
priceTenGold: 5000,
|
||
rewardCount: 8,
|
||
},
|
||
{
|
||
category: 'LUXURY',
|
||
label: 'Luxury',
|
||
priceFiftyGold: 100_000,
|
||
priceOneGold: 2000,
|
||
priceTenGold: 20_000,
|
||
rewardCount: 8,
|
||
},
|
||
{
|
||
category: 'ADVANCED',
|
||
label: 'Advanced',
|
||
priceFiftyGold: 500_000,
|
||
priceOneGold: 10_000,
|
||
priceTenGold: 100_000,
|
||
rewardCount: 12,
|
||
},
|
||
] as const;
|
||
type WheelCategoryMeta = (typeof WHEEL_CATEGORIES)[number];
|
||
|
||
const CATEGORY_OPTIONS = WHEEL_CATEGORIES.map((item) => ({
|
||
label: `${item.label} (${item.rewardCount})`,
|
||
value: item.category,
|
||
}));
|
||
|
||
const REWARD_TYPE_OPTIONS = [
|
||
{ label: '资源', value: 'RESOURCE' },
|
||
{ label: '金币', value: 'GOLD' },
|
||
];
|
||
|
||
let localRowId = 0;
|
||
|
||
function normalizeRewardTypeValue(value: any) {
|
||
return String(value || 'RESOURCE').toUpperCase() === 'GOLD' ? 'GOLD' : 'RESOURCE';
|
||
}
|
||
|
||
function createRowKey(seed?: number | string) {
|
||
localRowId += 1;
|
||
return `wheel-reward-${seed || localRowId}-${localRowId}`;
|
||
}
|
||
|
||
function findCategoryMeta(category: string): WheelCategoryMeta {
|
||
return (
|
||
WHEEL_CATEGORIES.find((item) => item.category === String(category || '').toUpperCase()) ||
|
||
WHEEL_CATEGORIES[0]
|
||
);
|
||
}
|
||
|
||
function createReward(type = 'RESOURCE', sort = 1) {
|
||
return {
|
||
coverUrl: '',
|
||
displayGoldAmount: type === 'RESOURCE' ? 0 : 0,
|
||
durationDays: type === 'RESOURCE' ? 7 : 0,
|
||
enabled: true,
|
||
goldAmount: type === 'GOLD' ? 100 : 0,
|
||
id: 0,
|
||
issuedCount: 0,
|
||
probability: 0,
|
||
rewardType: type,
|
||
resourceId: null as null | number | string,
|
||
resourceName: '',
|
||
resourceType: 'AVATAR_FRAME',
|
||
resourceUrl: '',
|
||
rowKey: createRowKey(sort),
|
||
sort,
|
||
totalLimit: 0,
|
||
userLimit: 0,
|
||
};
|
||
}
|
||
|
||
function createCategory(meta: WheelCategoryMeta = WHEEL_CATEGORIES[0]) {
|
||
return {
|
||
category: meta.category,
|
||
enabled: false,
|
||
label: meta.label,
|
||
priceFiftyGold: meta.priceFiftyGold,
|
||
priceOneGold: meta.priceOneGold,
|
||
priceTenGold: meta.priceTenGold,
|
||
rewardCount: meta.rewardCount,
|
||
rewards: Array.from({ length: meta.rewardCount }, (_, index) =>
|
||
createReward('RESOURCE', index + 1),
|
||
),
|
||
};
|
||
}
|
||
|
||
function createEmptyForm(sysOrigin = '') {
|
||
return {
|
||
categories: WHEEL_CATEGORIES.map((item) => createCategory(item)),
|
||
configured: false,
|
||
enabled: false,
|
||
id: 0,
|
||
sysOrigin,
|
||
timezone: 'Asia/Riyadh',
|
||
updateTime: '',
|
||
};
|
||
}
|
||
|
||
const accessStore = useAccessStore();
|
||
const sysOriginOptions = computed(() => {
|
||
const options = getAllowedSysOrigins(accessStore.accessCodes || []);
|
||
return options.length > 0 ? options : getAllowedSysOrigins([]);
|
||
});
|
||
|
||
const selectedSysOrigin = ref('');
|
||
const activePageTab = ref('config');
|
||
const activeCategoryTab = ref('CLASSIC');
|
||
const loading = ref(false);
|
||
const savingCategory = ref('');
|
||
const recordLoading = ref(false);
|
||
const resourcePickerOpen = ref(false);
|
||
const retryingRecordId = ref<null | number | string>(null);
|
||
const pickerCategory = ref('CLASSIC');
|
||
const pickerRewardIndex = ref(-1);
|
||
const pickerRewardRecord = ref<null | Record<string, any>>(null);
|
||
|
||
const form = reactive<Record<string, any>>(createEmptyForm());
|
||
const recordQuery = reactive<Record<string, any>>({
|
||
category: '',
|
||
cursor: 1,
|
||
limit: 20,
|
||
status: '',
|
||
userId: '',
|
||
});
|
||
const recordPage = reactive<Record<string, any>>({
|
||
current: 1,
|
||
records: [],
|
||
size: 20,
|
||
total: 0,
|
||
});
|
||
|
||
const rewardColumns = [
|
||
{ dataIndex: 'enabled', key: 'enabled', title: '启用', width: 80 },
|
||
{ dataIndex: 'sort', key: 'sort', title: '位置', width: 90 },
|
||
{ dataIndex: 'rewardType', key: 'rewardType', title: '类型', width: 130 },
|
||
{ dataIndex: 'reward', key: 'reward', title: '奖励', width: 560 },
|
||
{ dataIndex: 'probability', key: 'probability', title: '概率', width: 180 },
|
||
{ dataIndex: 'limit', key: 'limit', title: '止损线', width: 280 },
|
||
{ dataIndex: 'actions', key: 'actions', title: '操作', width: 90 },
|
||
];
|
||
|
||
const recordColumns = [
|
||
{ dataIndex: 'drawNo', key: 'drawNo', title: '抽奖单号', width: 190 },
|
||
{ dataIndex: 'category', key: 'category', title: '转盘', width: 120 },
|
||
{ dataIndex: 'userId', key: 'userId', title: '用户ID', width: 140 },
|
||
{ dataIndex: 'drawTimes', key: 'drawTimes', title: '次数', width: 90 },
|
||
{ dataIndex: 'paidGold', key: 'paidGold', title: '消耗金币', width: 120 },
|
||
{ dataIndex: 'reward', key: 'reward', title: '中奖奖励', width: 360 },
|
||
{ dataIndex: 'probability', key: 'probability', title: '命中概率', width: 110 },
|
||
{ dataIndex: 'status', key: 'status', title: '状态', width: 110 },
|
||
{ dataIndex: 'createTime', key: 'createTime', title: '时间', width: 180 },
|
||
{ dataIndex: 'errorMessage', key: 'errorMessage', title: '失败原因', width: 260 },
|
||
{ dataIndex: 'actions', key: 'actions', title: '操作', width: 110 },
|
||
];
|
||
|
||
const statusMeta = computed(() => {
|
||
if (!form.configured) {
|
||
return { color: 'processing', text: '未配置' };
|
||
}
|
||
const enabledCount = (form.categories || []).filter(
|
||
(item: Record<string, any>) => item.enabled !== false,
|
||
).length;
|
||
if (enabledCount > 0) {
|
||
return { color: 'success', text: `已开启 ${enabledCount} 个奖池` };
|
||
}
|
||
return { color: 'default', text: '已关闭' };
|
||
});
|
||
|
||
function getCategory(category: string) {
|
||
return (
|
||
(form.categories || []).find(
|
||
(item: Record<string, any>) => item.category === String(category || '').toUpperCase(),
|
||
) || form.categories?.[0]
|
||
);
|
||
}
|
||
|
||
function categoryLabel(category: string) {
|
||
return findCategoryMeta(category).label;
|
||
}
|
||
|
||
function categoryProbability(category: Record<string, any>) {
|
||
return (category?.rewards || [])
|
||
.filter((item: Record<string, any>) => item.enabled !== false)
|
||
.reduce((sum: number, item: Record<string, any>) => sum + Number(item.probability || 0), 0);
|
||
}
|
||
|
||
function categoryProbabilityColor(category: Record<string, any>) {
|
||
return categoryProbability(category) === PROBABILITY_TOTAL ? 'success' : 'error';
|
||
}
|
||
|
||
function probabilityPercent(value: any) {
|
||
return ((Number(value || 0) * 100) / PROBABILITY_TOTAL).toFixed(4);
|
||
}
|
||
|
||
function normalizeReward(item: Record<string, any> | undefined, index: number) {
|
||
const value = item || {};
|
||
const rewardType = normalizeRewardTypeValue(value.rewardType);
|
||
const resourceId = firstPresent(value, [
|
||
'resourceId',
|
||
'resourceID',
|
||
'resource_id',
|
||
'sourceId',
|
||
'propsId',
|
||
'propsSourceId',
|
||
'badgeConfigId',
|
||
'giftId',
|
||
'groupId',
|
||
]);
|
||
return {
|
||
coverUrl: String(value.coverUrl || ''),
|
||
displayGoldAmount: Number(value.displayGoldAmount || 0),
|
||
durationDays: Number(value.durationDays || (rewardType === 'RESOURCE' ? 7 : 0)),
|
||
enabled: value.enabled !== false,
|
||
goldAmount: Number(value.goldAmount || 0),
|
||
id: Number(value.id || 0),
|
||
issuedCount: Number(value.issuedCount || 0),
|
||
probability: Number(value.probability || 0),
|
||
rewardType,
|
||
resourceId: resourceId || null,
|
||
resourceName: String(value.resourceName || ''),
|
||
resourceType: String(value.resourceType || 'AVATAR_FRAME'),
|
||
resourceUrl: String(value.resourceUrl || ''),
|
||
rowKey: createRowKey(value.id || index + 1),
|
||
sort: Number(value.sort || index + 1),
|
||
totalLimit: Number(value.totalLimit || 0),
|
||
userLimit: Number(value.userLimit || 0),
|
||
};
|
||
}
|
||
|
||
function normalizeCategory(
|
||
value: Record<string, any> | undefined,
|
||
meta: WheelCategoryMeta = WHEEL_CATEGORIES[0],
|
||
) {
|
||
const sourceRewards = Array.isArray(value?.rewards) ? value.rewards : [];
|
||
const rewards = sourceRewards
|
||
.slice(0, meta.rewardCount)
|
||
.map((item: Record<string, any>, index: number) => normalizeReward(item, index));
|
||
while (rewards.length < meta.rewardCount) {
|
||
rewards.push(createReward('RESOURCE', rewards.length + 1));
|
||
}
|
||
return {
|
||
category: meta.category,
|
||
enabled: value ? value.enabled !== false : false,
|
||
label: meta.label,
|
||
priceFiftyGold: Number(value?.priceFiftyGold || meta.priceFiftyGold),
|
||
priceOneGold: Number(value?.priceOneGold || meta.priceOneGold),
|
||
priceTenGold: Number(value?.priceTenGold || meta.priceTenGold),
|
||
rewardCount: meta.rewardCount,
|
||
rewards,
|
||
};
|
||
}
|
||
|
||
function unwrapResponsePayload(result: null | Record<string, any> | undefined) {
|
||
const value = result || {};
|
||
if (Array.isArray(value.categories)) {
|
||
return value;
|
||
}
|
||
if (value.body && Array.isArray(value.body.categories)) {
|
||
return value.body;
|
||
}
|
||
if (value.data && Array.isArray(value.data.categories)) {
|
||
return value.data;
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function syncRewardRows(
|
||
targetRewards: Array<Record<string, any>>,
|
||
nextRewards: Array<Record<string, any>>,
|
||
) {
|
||
const syncedRewards = nextRewards.map((reward, index) => {
|
||
const target = targetRewards[index] || {};
|
||
Object.assign(target, reward);
|
||
return target;
|
||
});
|
||
targetRewards.splice(0, targetRewards.length, ...syncedRewards);
|
||
}
|
||
|
||
function syncCategories(nextCategories: Array<Record<string, any>>) {
|
||
const currentByCategory = new Map<string, Record<string, any>>(
|
||
(form.categories || []).map((category: Record<string, any>) => [
|
||
String(category.category || '').toUpperCase(),
|
||
category,
|
||
]),
|
||
);
|
||
const syncedCategories = nextCategories.map((category) => {
|
||
const current: Record<string, any> =
|
||
currentByCategory.get(String(category.category || '').toUpperCase()) ||
|
||
(createCategory(findCategoryMeta(category.category)) as Record<string, any>);
|
||
const currentRewards = Array.isArray(current.rewards) ? current.rewards : [];
|
||
const nextRewards = Array.isArray(category.rewards) ? category.rewards : [];
|
||
Object.assign(current, category, { rewards: currentRewards });
|
||
syncRewardRows(currentRewards, nextRewards);
|
||
return current;
|
||
});
|
||
form.categories.splice(0, form.categories.length, ...syncedCategories);
|
||
}
|
||
|
||
function applyConfig(result: null | Record<string, any> | undefined) {
|
||
const value = unwrapResponsePayload(result);
|
||
const sysOrigin = String(
|
||
value.sysOrigin || selectedSysOrigin.value || sysOriginOptions.value[0]?.value || '',
|
||
);
|
||
const categories = Array.isArray(value.categories) ? value.categories : [];
|
||
const normalizedCategories = WHEEL_CATEGORIES.map((meta) => {
|
||
const source = categories.find(
|
||
(item: Record<string, any>) => String(item.category || '').toUpperCase() === meta.category,
|
||
);
|
||
return normalizeCategory(source, meta);
|
||
});
|
||
|
||
form.configured = Boolean(value.configured);
|
||
form.enabled = Boolean(value.enabled);
|
||
form.id = Number(value.id || 0);
|
||
form.sysOrigin = sysOrigin;
|
||
form.timezone = String(value.timezone || 'Asia/Riyadh');
|
||
form.updateTime = String(value.updateTime || '');
|
||
syncCategories(normalizedCategories);
|
||
}
|
||
|
||
async function loadConfig() {
|
||
if (!selectedSysOrigin.value) {
|
||
return;
|
||
}
|
||
loading.value = true;
|
||
try {
|
||
const result = await getResidentWheelConfig(selectedSysOrigin.value);
|
||
applyConfig(result);
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
function normalizeRecordPage(result: null | Record<string, any> | undefined) {
|
||
const value = result || {};
|
||
recordPage.records = Array.isArray(value.records) ? value.records : [];
|
||
recordPage.total = Number(value.total || 0);
|
||
recordPage.current = Number(value.current || 1);
|
||
recordPage.size = Number(value.size || 20);
|
||
}
|
||
|
||
async function loadRecords(reset = false) {
|
||
if (!selectedSysOrigin.value) {
|
||
return;
|
||
}
|
||
if (reset) {
|
||
recordQuery.cursor = 1;
|
||
}
|
||
recordLoading.value = true;
|
||
try {
|
||
const result = await pageResidentWheelDrawRecords({
|
||
...recordQuery,
|
||
sysOrigin: selectedSysOrigin.value,
|
||
});
|
||
normalizeRecordPage(result);
|
||
} finally {
|
||
recordLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function firstPresent(record: Record<string, any>, keys: string[]) {
|
||
for (const key of keys) {
|
||
const value = record?.[key];
|
||
if (value !== undefined && value !== null && String(value).trim() !== '') {
|
||
return value;
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function normalizeSelectedResource(row: Record<string, any>, fallbackType = 'AVATAR_FRAME') {
|
||
const resourceId = firstPresent(row, [
|
||
'id',
|
||
'resourceId',
|
||
'sourceId',
|
||
'propsId',
|
||
'propsSourceId',
|
||
'badgeConfigId',
|
||
'giftId',
|
||
'groupId',
|
||
]);
|
||
return {
|
||
coverUrl: String(
|
||
firstPresent(row, [
|
||
'cover',
|
||
'coverUrl',
|
||
'resourceCover',
|
||
'resourceCoverUrl',
|
||
'giftPhoto',
|
||
'selectUrl',
|
||
'icon',
|
||
'smallIcon',
|
||
]),
|
||
),
|
||
resourceId,
|
||
resourceName: String(
|
||
firstPresent(row, [
|
||
'name',
|
||
'resourceName',
|
||
'sourceName',
|
||
'giftName',
|
||
'badgeName',
|
||
'groupName',
|
||
'title',
|
||
'remark',
|
||
]) || (resourceId ? `资源 ${resourceId}` : ''),
|
||
),
|
||
resourcePriceGold: Number(firstPresent(row, ['amount', 'giftCandy', 'price', 'goldAmount']) || 0),
|
||
resourceType: String(
|
||
firstPresent(row, ['type', 'resourceType', 'propsType']) || fallbackType || 'AVATAR_FRAME',
|
||
),
|
||
resourceUrl: String(
|
||
firstPresent(row, [
|
||
'sourceUrl',
|
||
'resourceUrl',
|
||
'url',
|
||
'giftSourceUrl',
|
||
'animationUrl',
|
||
'playUrl',
|
||
]),
|
||
),
|
||
};
|
||
}
|
||
|
||
function normalizeRewardResourceId(item: Record<string, any>) {
|
||
return String(
|
||
firstPresent(item, [
|
||
'resourceId',
|
||
'resourceID',
|
||
'resource_id',
|
||
'sourceId',
|
||
'propsId',
|
||
'propsSourceId',
|
||
'badgeConfigId',
|
||
'giftId',
|
||
'groupId',
|
||
]),
|
||
).trim();
|
||
}
|
||
|
||
function hasRewardResourceId(item: Record<string, any>) {
|
||
const resourceId = normalizeRewardResourceId(item);
|
||
return resourceId !== '' && resourceId !== '0';
|
||
}
|
||
|
||
function openResourcePicker(category: string, index: number, record?: Record<string, any>) {
|
||
pickerCategory.value = category;
|
||
pickerRewardIndex.value = index;
|
||
pickerRewardRecord.value = record || null;
|
||
resourcePickerOpen.value = true;
|
||
}
|
||
|
||
function closeResourcePicker() {
|
||
resourcePickerOpen.value = false;
|
||
pickerRewardRecord.value = null;
|
||
}
|
||
|
||
function handleResourceSelect(row: Record<string, any>) {
|
||
const category = getCategory(pickerCategory.value);
|
||
const target = pickerRewardRecord.value || category?.rewards?.[pickerRewardIndex.value];
|
||
if (!target) {
|
||
closeResourcePicker();
|
||
return;
|
||
}
|
||
const selected = normalizeSelectedResource(row, target.resourceType || 'AVATAR_FRAME');
|
||
if (!selected.resourceId) {
|
||
message.warning('资源数据缺少 ID,无法选择');
|
||
closeResourcePicker();
|
||
return;
|
||
}
|
||
target.rewardType = 'RESOURCE';
|
||
target.resourceId = String(selected.resourceId).trim();
|
||
target.resourceType = selected.resourceType;
|
||
target.resourceName = selected.resourceName;
|
||
target.resourceUrl = selected.resourceUrl;
|
||
target.coverUrl = selected.coverUrl || target.coverUrl || '';
|
||
target.displayGoldAmount =
|
||
selected.resourcePriceGold > 0
|
||
? selected.resourcePriceGold
|
||
: Number(target.displayGoldAmount || 0);
|
||
target.durationDays = Number(target.durationDays || 7);
|
||
target.goldAmount = 0;
|
||
closeResourcePicker();
|
||
}
|
||
|
||
function clearResource(record: Record<string, any>) {
|
||
record.resourceId = null;
|
||
record.resourceName = '';
|
||
record.resourceUrl = '';
|
||
record.coverUrl = '';
|
||
record.displayGoldAmount = 0;
|
||
record.durationDays = normalizeRewardTypeValue(record.rewardType) === 'RESOURCE' ? 7 : 0;
|
||
}
|
||
|
||
function setCategoryEnabled(category: Record<string, any>, checked: any) {
|
||
category.enabled = checked === true;
|
||
}
|
||
|
||
function setRewardEnabled(record: Record<string, any>, checked: any) {
|
||
record.enabled = checked === true;
|
||
}
|
||
|
||
function handleRewardTypeChange(record: Record<string, any>, value?: string) {
|
||
record.rewardType = normalizeRewardTypeValue(value || record.rewardType);
|
||
if (record.rewardType === 'GOLD') {
|
||
record.resourceId = null;
|
||
record.resourceName = '';
|
||
record.resourceType = '';
|
||
record.resourceUrl = '';
|
||
record.coverUrl = '';
|
||
record.displayGoldAmount = 0;
|
||
record.durationDays = 0;
|
||
record.goldAmount = Number(record.goldAmount || 100);
|
||
return;
|
||
}
|
||
record.goldAmount = 0;
|
||
record.displayGoldAmount = Number(record.displayGoldAmount || 0);
|
||
record.resourceType = record.resourceType || 'AVATAR_FRAME';
|
||
record.durationDays = Number(record.durationDays || 7);
|
||
}
|
||
|
||
function rewardValidateLabel(category: Record<string, any>, item: Record<string, any>) {
|
||
const sort = Number(item.sort || 0);
|
||
return `${category.label}${sort > 0 ? ` 第 ${sort} 位` : ''}`;
|
||
}
|
||
|
||
function validateReward(category: Record<string, any>, item: Record<string, any>) {
|
||
const rewardType = normalizeRewardTypeValue(item.rewardType);
|
||
item.rewardType = rewardType;
|
||
const label = rewardValidateLabel(category, item);
|
||
const requiresContent = category.enabled !== false && item.enabled !== false;
|
||
if (rewardType === 'RESOURCE') {
|
||
const resourceId = normalizeRewardResourceId(item);
|
||
if (resourceId) {
|
||
item.resourceId = resourceId;
|
||
}
|
||
if (requiresContent && !hasRewardResourceId(item)) {
|
||
message.warning(`${label} 资源奖励必须选择资源`);
|
||
return false;
|
||
}
|
||
if (requiresContent && !item.resourceType) {
|
||
message.warning(`${label} 资源奖励必须有资源类型`);
|
||
return false;
|
||
}
|
||
if (requiresContent && Number(item.durationDays || 0) <= 0) {
|
||
message.warning(`${label} 资源奖励有效天数必须大于 0`);
|
||
return false;
|
||
}
|
||
if (requiresContent && Number(item.displayGoldAmount || 0) <= 0) {
|
||
message.warning(`${label} 资源奖励展示金币价格必须大于 0`);
|
||
return false;
|
||
}
|
||
}
|
||
if (rewardType === 'GOLD' && requiresContent && Number(item.goldAmount || 0) <= 0) {
|
||
message.warning(`${label} 金币奖励必须设置金币数量`);
|
||
return false;
|
||
}
|
||
if (Number(item.probability || 0) < 0) {
|
||
message.warning(`${label} 概率不能小于 0`);
|
||
return false;
|
||
}
|
||
if (Number(item.totalLimit || 0) < 0) {
|
||
message.warning(`${label} 总量上限不能小于 0`);
|
||
return false;
|
||
}
|
||
if (Number(item.userLimit || 0) < 0) {
|
||
message.warning(`${label} 单用户上限不能小于 0`);
|
||
return false;
|
||
}
|
||
if (
|
||
category.enabled !== false &&
|
||
item.enabled !== false &&
|
||
Number(item.probability || 0) <= 0
|
||
) {
|
||
message.warning(`${category.label} 已启用奖励概率必须大于 0`);
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function validateCategory(category: Record<string, any>) {
|
||
const meta = findCategoryMeta(category.category);
|
||
if (
|
||
Number(category.priceOneGold || 0) <= 0 ||
|
||
Number(category.priceTenGold || 0) <= 0 ||
|
||
Number(category.priceFiftyGold || 0) <= 0
|
||
) {
|
||
message.warning(`${category.label} 抽奖价格必须大于 0`);
|
||
return false;
|
||
}
|
||
const rewards = category.rewards as Array<Record<string, any>>;
|
||
if (rewards.length !== meta.rewardCount) {
|
||
message.warning(`${category.label} 必须固定配置 ${meta.rewardCount} 个奖励`);
|
||
return false;
|
||
}
|
||
for (const item of rewards) {
|
||
if (!validateReward(category, item)) {
|
||
return false;
|
||
}
|
||
}
|
||
if (category.enabled !== false) {
|
||
const enabledCount = rewards.filter((item) => item.enabled !== false).length;
|
||
if (enabledCount !== meta.rewardCount) {
|
||
message.warning(`${category.label} 启用时必须启用 ${meta.rewardCount} 个奖励`);
|
||
return false;
|
||
}
|
||
if (categoryProbability(category) !== PROBABILITY_TOTAL) {
|
||
message.warning(`${category.label} 启用时概率总和必须等于 10000`);
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function validateBeforeSave(category?: Record<string, any>) {
|
||
if (!selectedSysOrigin.value) {
|
||
message.warning('请选择系统');
|
||
return false;
|
||
}
|
||
const targetCategory = category || getCategory(activeCategoryTab.value);
|
||
if (!targetCategory) {
|
||
message.warning('请选择奖池');
|
||
return false;
|
||
}
|
||
return validateCategory(targetCategory);
|
||
}
|
||
|
||
function categoryPayload(category: Record<string, any>) {
|
||
return {
|
||
category: String(category.category || ''),
|
||
enabled: category.enabled !== false,
|
||
priceFiftyGold: Number(category.priceFiftyGold || 0),
|
||
priceOneGold: Number(category.priceOneGold || 0),
|
||
priceTenGold: Number(category.priceTenGold || 0),
|
||
rewards: (category.rewards || []).map((item: Record<string, any>) => {
|
||
const rewardType = normalizeRewardTypeValue(item.rewardType);
|
||
const resourceId = normalizeRewardResourceId(item);
|
||
return {
|
||
coverUrl: rewardType === 'RESOURCE' ? String(item.coverUrl || '') : '',
|
||
displayGoldAmount:
|
||
rewardType === 'RESOURCE' ? Number(item.displayGoldAmount || 0) : 0,
|
||
durationDays: rewardType === 'RESOURCE' ? Number(item.durationDays || 0) : 0,
|
||
enabled: item.enabled !== false,
|
||
goldAmount: rewardType === 'GOLD' ? Number(item.goldAmount || 0) : 0,
|
||
id: Number(item.id || 0),
|
||
probability: Number(item.probability || 0),
|
||
rewardType,
|
||
resourceId: rewardType === 'RESOURCE' && resourceId ? resourceId : null,
|
||
resourceName: rewardType === 'RESOURCE' ? String(item.resourceName || '') : '',
|
||
resourceType: rewardType === 'RESOURCE' ? String(item.resourceType || '') : '',
|
||
resourceUrl: rewardType === 'RESOURCE' ? String(item.resourceUrl || '') : '',
|
||
sort: Number(item.sort || 0),
|
||
totalLimit: Number(item.totalLimit || 0),
|
||
userLimit: Number(item.userLimit || 0),
|
||
};
|
||
}),
|
||
};
|
||
}
|
||
|
||
async function submitCategory(category: Record<string, any>) {
|
||
await nextTick();
|
||
if (!validateBeforeSave(category)) {
|
||
return;
|
||
}
|
||
if (!category) {
|
||
return;
|
||
}
|
||
savingCategory.value = String(category.category || '');
|
||
try {
|
||
const result = await saveResidentWheelCategoryConfig({
|
||
category: categoryPayload(category),
|
||
sysOrigin: selectedSysOrigin.value,
|
||
timezone: String(form.timezone || '').trim(),
|
||
});
|
||
const savedValue = unwrapResponsePayload(result);
|
||
const savedCategory = (savedValue?.categories || []).find(
|
||
(item: Record<string, any>) =>
|
||
String(item.category || '').toUpperCase() === String(category.category || '').toUpperCase(),
|
||
);
|
||
if (savedCategory?.enabled === false) {
|
||
message.warning(`${category.label} 已保存为关闭状态,H5 不展示这个转盘`);
|
||
} else {
|
||
message.success(`${category.label} 保存成功`);
|
||
}
|
||
await loadConfig();
|
||
} finally {
|
||
savingCategory.value = '';
|
||
}
|
||
}
|
||
|
||
function handleRecordSearch() {
|
||
void loadRecords(true);
|
||
}
|
||
|
||
function handleRecordTableChange(pagination: Record<string, any>) {
|
||
recordQuery.cursor = Number(pagination.current || 1);
|
||
recordQuery.limit = Number(pagination.pageSize || 20);
|
||
void loadRecords();
|
||
}
|
||
|
||
async function retryRecord(record: Record<string, any>) {
|
||
const id = record?.id;
|
||
if (!id) {
|
||
return;
|
||
}
|
||
retryingRecordId.value = id;
|
||
try {
|
||
await retryResidentWheelDrawRecord(id);
|
||
message.success('补发成功');
|
||
await loadRecords();
|
||
} finally {
|
||
retryingRecordId.value = null;
|
||
}
|
||
}
|
||
|
||
function rewardRecordName(record: Record<string, any>) {
|
||
if (record.resourceName) {
|
||
return record.resourceName;
|
||
}
|
||
if (normalizeRewardTypeValue(record.rewardType) === 'GOLD') {
|
||
return `${Number(record.goldAmount || 0).toLocaleString()} 金币`;
|
||
}
|
||
return '-';
|
||
}
|
||
|
||
function resourceDisplayName(record: Record<string, any>) {
|
||
if (record.resourceName) {
|
||
return record.resourceName;
|
||
}
|
||
if (record.resourceId) {
|
||
return `资源 ${record.resourceId}`;
|
||
}
|
||
return '未选择资源';
|
||
}
|
||
|
||
function statusColor(status: string) {
|
||
switch (String(status || '').toUpperCase()) {
|
||
case 'FAILED': {
|
||
return 'error';
|
||
}
|
||
case 'PAY_FAILED': {
|
||
return 'warning';
|
||
}
|
||
case 'SUCCESS': {
|
||
return 'success';
|
||
}
|
||
default: {
|
||
return 'processing';
|
||
}
|
||
}
|
||
}
|
||
|
||
function coverStyle(url: string) {
|
||
return {
|
||
backgroundImage: `url("${String(url || '').replaceAll('"', '%22')}")`,
|
||
};
|
||
}
|
||
|
||
watch(
|
||
sysOriginOptions,
|
||
(options) => {
|
||
if (!selectedSysOrigin.value) {
|
||
selectedSysOrigin.value = String(options[0]?.value || '');
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
|
||
watch(
|
||
selectedSysOrigin,
|
||
(value) => {
|
||
if (value) {
|
||
void loadConfig();
|
||
void loadRecords(true);
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
|
||
watch(activePageTab, (value) => {
|
||
if (value === 'records') {
|
||
void loadRecords();
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<Page auto-content-height>
|
||
<Spin :spinning="loading">
|
||
<Card :bordered="false" title="转盘配置">
|
||
<div class="toolbar">
|
||
<Space wrap>
|
||
<Select
|
||
v-model:value="selectedSysOrigin"
|
||
:options="sysOriginOptions"
|
||
option-filter-prop="label"
|
||
option-label-prop="label"
|
||
placeholder="请选择系统"
|
||
show-search
|
||
style="width: 180px"
|
||
/>
|
||
<Tag :color="statusMeta.color">{{ statusMeta.text }}</Tag>
|
||
<Tag>概率基数:{{ PROBABILITY_TOTAL }} = 100%</Tag>
|
||
<Button :loading="loading" @click="loadConfig">刷新</Button>
|
||
</Space>
|
||
</div>
|
||
|
||
<Tabs v-model:active-key="activePageTab">
|
||
<TabPane key="config" tab="奖励配置">
|
||
<div class="config-row">
|
||
<Space align="center" wrap>
|
||
<span class="field-label">时区</span>
|
||
<Input v-model:value="form.timezone" style="width: 180px" />
|
||
<span class="sub-text">更新时间:{{ form.updateTime || '-' }}</span>
|
||
</Space>
|
||
</div>
|
||
|
||
<Tabs v-model:active-key="activeCategoryTab">
|
||
<TabPane
|
||
v-for="category in form.categories"
|
||
:key="category.category"
|
||
:tab="`${category.label} (${category.rewardCount})`"
|
||
>
|
||
<div class="category-header">
|
||
<Space align="center" wrap>
|
||
<span class="field-label">{{ category.label }} 开关</span>
|
||
<Switch
|
||
:checked="category.enabled !== false"
|
||
@change="(checked: boolean) => setCategoryEnabled(category, checked)"
|
||
/>
|
||
<Tag :color="categoryProbabilityColor(category)">
|
||
概率合计:{{ categoryProbability(category) }} / {{ PROBABILITY_TOTAL }}
|
||
</Tag>
|
||
<span class="sub-text">
|
||
保存当前奖池;开关打开后 H5 才展示这个 tab
|
||
</span>
|
||
<Button
|
||
:loading="savingCategory === category.category"
|
||
type="primary"
|
||
@click="submitCategory(category)"
|
||
>
|
||
保存 {{ category.label }}
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
|
||
<div class="config-row">
|
||
<Space align="center" wrap>
|
||
<span class="field-label">抽 1 次</span>
|
||
<InputNumber
|
||
v-model:value="category.priceOneGold"
|
||
:min="1"
|
||
:precision="0"
|
||
style="width: 130px"
|
||
/>
|
||
<span class="field-label">抽 10 次</span>
|
||
<InputNumber
|
||
v-model:value="category.priceTenGold"
|
||
:min="1"
|
||
:precision="0"
|
||
style="width: 130px"
|
||
/>
|
||
<span class="field-label">抽 50 次</span>
|
||
<InputNumber
|
||
v-model:value="category.priceFiftyGold"
|
||
:min="1"
|
||
:precision="0"
|
||
style="width: 130px"
|
||
/>
|
||
</Space>
|
||
</div>
|
||
|
||
<Table
|
||
:columns="rewardColumns"
|
||
:data-source="category.rewards"
|
||
:loading="loading"
|
||
:pagination="false"
|
||
row-key="rowKey"
|
||
:scroll="{ x: 1460 }"
|
||
>
|
||
<template #bodyCell="{ column, index, record }">
|
||
<template v-if="column.key === 'enabled'">
|
||
<Switch
|
||
:checked="record.enabled !== false"
|
||
@change="(checked: boolean) => setRewardEnabled(record, checked)"
|
||
/>
|
||
</template>
|
||
<template v-else-if="column.key === 'sort'">
|
||
<InputNumber
|
||
v-model:value="record.sort"
|
||
:min="1"
|
||
:precision="0"
|
||
style="width: 72px"
|
||
/>
|
||
</template>
|
||
<template v-else-if="column.key === 'rewardType'">
|
||
<Select
|
||
v-model:value="record.rewardType"
|
||
:options="REWARD_TYPE_OPTIONS"
|
||
style="width: 112px"
|
||
@change="(value: string) => handleRewardTypeChange(record, value)"
|
||
/>
|
||
</template>
|
||
<template v-else-if="column.key === 'reward'">
|
||
<div
|
||
v-if="
|
||
normalizeRewardTypeValue(record.rewardType) === 'RESOURCE'
|
||
"
|
||
class="reward-cell"
|
||
>
|
||
<Space wrap>
|
||
<Button
|
||
size="small"
|
||
@click="
|
||
openResourcePicker(category.category, index, record)
|
||
"
|
||
>
|
||
{{ record.resourceId ? '更换资源' : '选择资源' }}
|
||
</Button>
|
||
<Button
|
||
v-if="record.resourceId"
|
||
danger
|
||
size="small"
|
||
type="link"
|
||
@click="clearResource(record)"
|
||
>
|
||
清空
|
||
</Button>
|
||
</Space>
|
||
<div class="resource-summary">
|
||
<div
|
||
v-if="record.coverUrl"
|
||
class="cover-thumb"
|
||
:style="coverStyle(record.coverUrl)"
|
||
></div>
|
||
<div>
|
||
<div class="reward-title">
|
||
{{ resourceDisplayName(record) }}
|
||
<span v-if="record.resourceId">
|
||
ID: {{ record.resourceId }}
|
||
</span>
|
||
<span v-if="record.resourceType">
|
||
{{ record.resourceType }}
|
||
</span>
|
||
</div>
|
||
<InputNumber
|
||
v-model:value="record.durationDays"
|
||
:min="1"
|
||
:precision="0"
|
||
addon-after="天"
|
||
style="width: 130px"
|
||
/>
|
||
<InputNumber
|
||
v-model:value="record.displayGoldAmount"
|
||
:min="1"
|
||
:precision="0"
|
||
addon-after="金币"
|
||
placeholder="展示价格"
|
||
style="width: 160px"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="reward-cell">
|
||
<InputNumber
|
||
v-model:value="record.goldAmount"
|
||
:min="1"
|
||
:precision="0"
|
||
addon-after="金币"
|
||
style="width: 180px"
|
||
/>
|
||
</div>
|
||
</template>
|
||
<template v-else-if="column.key === 'probability'">
|
||
<Space align="center">
|
||
<InputNumber
|
||
v-model:value="record.probability"
|
||
:max="PROBABILITY_TOTAL"
|
||
:min="0"
|
||
:precision="0"
|
||
style="width: 120px"
|
||
/>
|
||
<span class="sub-text">
|
||
{{ probabilityPercent(record.probability) }}%
|
||
</span>
|
||
</Space>
|
||
</template>
|
||
<template v-else-if="column.key === 'limit'">
|
||
<Space direction="vertical" size="small">
|
||
<Space align="center">
|
||
<span class="sub-text">总量</span>
|
||
<InputNumber
|
||
v-model:value="record.totalLimit"
|
||
:min="0"
|
||
:precision="0"
|
||
placeholder="不限"
|
||
style="width: 110px"
|
||
/>
|
||
<span class="sub-text">已占 {{ Number(record.issuedCount || 0) }}</span>
|
||
</Space>
|
||
<Space align="center">
|
||
<span class="sub-text">单用户</span>
|
||
<InputNumber
|
||
v-model:value="record.userLimit"
|
||
:min="0"
|
||
:precision="0"
|
||
placeholder="不限"
|
||
style="width: 110px"
|
||
/>
|
||
</Space>
|
||
</Space>
|
||
</template>
|
||
<template v-else-if="column.key === 'actions'">
|
||
<Button danger type="link" @click="clearResource(record)">
|
||
清空
|
||
</Button>
|
||
</template>
|
||
</template>
|
||
</Table>
|
||
</TabPane>
|
||
</Tabs>
|
||
</TabPane>
|
||
|
||
<TabPane key="records" tab="抽奖记录">
|
||
<div class="toolbar">
|
||
<Space wrap>
|
||
<Select
|
||
v-model:value="recordQuery.category"
|
||
allow-clear
|
||
:options="CATEGORY_OPTIONS"
|
||
placeholder="转盘分类"
|
||
style="width: 150px"
|
||
/>
|
||
<Input
|
||
v-model:value="recordQuery.userId"
|
||
allow-clear
|
||
placeholder="用户ID"
|
||
style="width: 170px"
|
||
/>
|
||
<Select
|
||
v-model:value="recordQuery.status"
|
||
allow-clear
|
||
:options="[
|
||
{ label: '成功', value: 'SUCCESS' },
|
||
{ label: '发奖失败', value: 'FAILED' },
|
||
{ label: '扣款失败', value: 'PAY_FAILED' },
|
||
{ label: '发奖中', value: 'GRANTING' },
|
||
{ label: '处理中', value: 'PENDING' },
|
||
]"
|
||
placeholder="状态"
|
||
style="width: 130px"
|
||
/>
|
||
<Button :loading="recordLoading" type="primary" @click="handleRecordSearch">
|
||
查询
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
|
||
<Table
|
||
:columns="recordColumns"
|
||
:data-source="recordPage.records"
|
||
:loading="recordLoading"
|
||
:pagination="{
|
||
current: recordPage.current,
|
||
pageSize: recordPage.size,
|
||
showSizeChanger: true,
|
||
total: recordPage.total,
|
||
}"
|
||
row-key="id"
|
||
:scroll="{ x: 1790 }"
|
||
@change="handleRecordTableChange"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key === 'category'">
|
||
<Tag>{{ categoryLabel(record.category) }}</Tag>
|
||
</template>
|
||
<template v-else-if="column.key === 'reward'">
|
||
<div class="record-reward">
|
||
<div
|
||
v-if="record.coverUrl"
|
||
class="cover-thumb"
|
||
:style="coverStyle(record.coverUrl)"
|
||
></div>
|
||
<div>
|
||
<div>{{ rewardRecordName(record) }}</div>
|
||
<div class="sub-text">
|
||
{{ record.rewardType }}
|
||
<span v-if="record.durationDays"> / {{ record.durationDays }} 天</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<template v-else-if="column.key === 'probability'">
|
||
{{ probabilityPercent(record.probability) }}%
|
||
</template>
|
||
<template v-else-if="column.key === 'status'">
|
||
<Tag :color="statusColor(record.status)">{{ record.status || '-' }}</Tag>
|
||
</template>
|
||
<template v-else-if="column.key === 'errorMessage'">
|
||
{{ record.errorMessage || '-' }}
|
||
</template>
|
||
<template v-else-if="column.key === 'actions'">
|
||
<Button
|
||
v-if="String(record.status || '').toUpperCase() === 'FAILED'"
|
||
:loading="retryingRecordId === record.id"
|
||
size="small"
|
||
type="link"
|
||
@click="retryRecord(record)"
|
||
>
|
||
补发
|
||
</Button>
|
||
<span v-else class="sub-text">-</span>
|
||
</template>
|
||
</template>
|
||
</Table>
|
||
</TabPane>
|
||
</Tabs>
|
||
</Card>
|
||
|
||
<PropsSourceSelectDrawer
|
||
:default-type="
|
||
pickerRewardRecord?.resourceType ||
|
||
getCategory(pickerCategory)?.rewards?.[pickerRewardIndex]?.resourceType ||
|
||
'AVATAR_FRAME'
|
||
"
|
||
:open="resourcePickerOpen"
|
||
:sys-origin="selectedSysOrigin"
|
||
@close="closeResourcePicker"
|
||
@select="handleResourceSelect"
|
||
/>
|
||
</Spin>
|
||
</Page>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.category-header,
|
||
.config-row {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.cover-thumb {
|
||
background-position: center;
|
||
background-repeat: no-repeat;
|
||
background-size: contain;
|
||
border: 1px solid rgb(226 232 240);
|
||
border-radius: 6px;
|
||
flex: 0 0 auto;
|
||
height: 48px;
|
||
width: 48px;
|
||
}
|
||
|
||
.field-label {
|
||
color: rgb(51 65 85);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.record-reward,
|
||
.resource-summary {
|
||
align-items: center;
|
||
display: flex;
|
||
gap: 10px;
|
||
}
|
||
|
||
.resource-summary {
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.reward-cell {
|
||
min-width: 340px;
|
||
}
|
||
|
||
.reward-title {
|
||
color: rgb(15 23 42);
|
||
font-weight: 600;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.reward-title span {
|
||
color: rgb(100 116 139);
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
margin-left: 8px;
|
||
}
|
||
|
||
.sub-text {
|
||
color: rgb(100 116 139);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.toolbar {
|
||
margin-bottom: 16px;
|
||
}
|
||
</style>
|