2026-05-19 19:39:29 +08:00

1242 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>