yumi-admin/apps/src/views/resident-activity/room-turnover-reward.vue
2026-05-19 15:36:32 +08:00

674 lines
20 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, reactive, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { useAccessStore } from '@vben/stores';
import {
getRoomTurnoverRewardCurrentRank,
getRoomTurnoverRewardConfig,
pageRoomTurnoverRewardRecords,
saveRoomTurnoverRewardConfig,
} from '#/api/legacy/resident-activity';
import RoomDetailsDrawer from '#/views/app-system/components/room-details-drawer.vue';
import { getAllowedSysOrigins } from '#/views/system/shared';
import {
Button,
Card,
Input,
InputNumber,
Modal,
Pagination,
Select,
Space,
Switch,
Table,
Tag,
message,
} from 'antdv-next';
defineOptions({ name: 'ResidentRoomTurnoverRewardPage' });
function createLevel(level = 1, threshold = 100_000, rewardGold = 2_000) {
return {
enabled: true,
id: null,
level,
rewardGold,
turnoverThreshold: threshold,
};
}
function defaultLevels() {
return [
createLevel(1, 100_000, 2_000),
createLevel(2, 500_000, 15_000),
createLevel(3, 2_000_000, 80_000),
createLevel(4, 5_000_000, 250_000),
];
}
const accessStore = useAccessStore();
const sysOriginOptions = computed(() => {
const options = getAllowedSysOrigins(accessStore.accessCodes || []);
return options.length > 0 ? options : getAllowedSysOrigins([]);
});
const selectedSysOrigin = ref('');
const loading = ref(false);
const saving = ref(false);
const recordLoading = ref(false);
const recordTotal = ref(0);
const recordList = ref<Array<Record<string, any>>>([]);
const rankLoading = ref(false);
const rankList = ref<Array<Record<string, any>>>([]);
const rankMeta = reactive({
cycleKey: '',
dateNumber: '',
periodEndAt: '',
periodStartAt: '',
timezone: 'Asia/Riyadh',
});
const roomDetailsOpen = ref(false);
const activeRoomId = ref<number | string>('');
const form = reactive<Record<string, any>>({
autoRewardEnabled: true,
configured: false,
enabled: false,
id: 0,
levelConfigs: defaultLevels(),
sysOrigin: '',
timezone: 'Asia/Riyadh',
updateTime: '',
});
const recordQuery = reactive({
cursor: 1,
cycleKey: '',
limit: 20,
status: '',
});
const rankQuery = reactive({
limit: 20,
});
const autoRewardEnabledCache = new Map<string, boolean>();
const statusOptions = [
{ label: '全部状态', value: '' },
{ label: '成功', value: 'SUCCESS' },
{ label: '失败', value: 'FAILED' },
{ label: '待发放', value: 'PENDING' },
];
const levelColumns = [
{ dataIndex: 'enabled', key: 'enabled', title: '启用', width: 90 },
{ dataIndex: 'level', key: 'level', title: '等级', width: 110 },
{ dataIndex: 'turnoverThreshold', key: 'turnoverThreshold', title: '达到流水', width: 220 },
{ dataIndex: 'rewardGold', key: 'rewardGold', title: '奖励房主金币', width: 220 },
{ dataIndex: 'actions', key: 'actions', title: '操作', width: 100 },
];
const recordColumns = [
{ dataIndex: 'cycleKey', key: 'cycleKey', title: '周期', width: 130 },
{ dataIndex: 'room', key: 'room', title: '房间', width: 280 },
{ dataIndex: 'ownerUserId', key: 'ownerUserId', title: '房主ID', width: 150 },
{ dataIndex: 'turnoverAmount', key: 'turnoverAmount', title: '流水', width: 140 },
{ dataIndex: 'level', key: 'level', title: '等级', width: 100 },
{ dataIndex: 'rewardGold', key: 'rewardGold', title: '奖励金币', width: 140 },
{ dataIndex: 'status', key: 'status', title: '状态', width: 110 },
{ dataIndex: 'retryCount', key: 'retryCount', title: '重试次数', width: 110 },
{ dataIndex: 'sentAt', key: 'sentAt', title: '发放时间', width: 180 },
{ dataIndex: 'lastError', key: 'lastError', title: '失败原因', width: 260 },
];
const rankColumns = [
{ dataIndex: 'rank', key: 'rank', title: '排名', width: 90 },
{ dataIndex: 'room', key: 'room', title: '房间', width: 280 },
{ dataIndex: 'ownerUserId', key: 'ownerUserId', title: '房主ID', width: 150 },
{ dataIndex: 'countryName', key: 'countryName', title: '国家', width: 120 },
{ dataIndex: 'turnoverAmount', key: 'turnoverAmount', title: '当前流水', width: 150 },
{ dataIndex: 'matchedLevel', key: 'matchedLevel', title: '命中等级', width: 120 },
{ dataIndex: 'turnoverThreshold', key: 'turnoverThreshold', title: '命中门槛', width: 150 },
{ dataIndex: 'expectedRewardGold', key: 'expectedRewardGold', title: '预计奖励金币', width: 150 },
];
const enabledLevelCount = computed(() =>
(form.levelConfigs || []).filter((item: Record<string, any>) => item.enabled !== false).length,
);
function normalizeConfig(result: Record<string, any> | null | undefined) {
const value = result || {};
const sysOrigin = String(value.sysOrigin || selectedSysOrigin.value || '');
if (Object.prototype.hasOwnProperty.call(value, 'autoRewardEnabled')) {
form.autoRewardEnabled = value.autoRewardEnabled !== false;
autoRewardEnabledCache.set(sysOrigin, form.autoRewardEnabled);
} else if (autoRewardEnabledCache.has(sysOrigin)) {
form.autoRewardEnabled = autoRewardEnabledCache.get(sysOrigin);
} else {
form.autoRewardEnabled = true;
}
form.configured = Boolean(value.configured);
form.enabled = Boolean(value.enabled);
form.id = value.id || 0;
form.sysOrigin = sysOrigin;
form.timezone = String(value.timezone || 'Asia/Riyadh');
form.updateTime = String(value.updateTime || '');
const levels = Array.isArray(value.levelConfigs) && value.levelConfigs.length > 0
? value.levelConfigs
: defaultLevels();
form.levelConfigs = levels
.map((item: Record<string, any>) => ({
enabled: item.enabled !== false,
id: item.id ?? null,
level: Number(item.level || 0),
rewardGold: Number(item.rewardGold || 0),
turnoverThreshold: Number(item.turnoverThreshold || 0),
}))
.sort((left: Record<string, any>, right: Record<string, any>) =>
Number(left.turnoverThreshold || 0) - Number(right.turnoverThreshold || 0),
);
}
async function loadConfig() {
if (!selectedSysOrigin.value) {
return;
}
loading.value = true;
try {
const result = await getRoomTurnoverRewardConfig(selectedSysOrigin.value);
normalizeConfig(result);
} finally {
loading.value = false;
}
}
async function loadRecords(reset = false) {
if (!selectedSysOrigin.value) {
recordList.value = [];
recordTotal.value = 0;
return;
}
if (reset) {
recordQuery.cursor = 1;
}
recordLoading.value = true;
try {
const result = await pageRoomTurnoverRewardRecords({
...recordQuery,
sysOrigin: selectedSysOrigin.value,
});
recordList.value = Array.isArray(result.records) ? result.records : [];
recordTotal.value = Number(result.total || 0);
} finally {
recordLoading.value = false;
}
}
async function loadCurrentRank() {
if (!selectedSysOrigin.value) {
rankList.value = [];
Object.assign(rankMeta, {
cycleKey: '',
dateNumber: '',
periodEndAt: '',
periodStartAt: '',
timezone: 'Asia/Riyadh',
});
return;
}
rankLoading.value = true;
try {
const result = await getRoomTurnoverRewardCurrentRank({
limit: rankQuery.limit,
sysOrigin: selectedSysOrigin.value,
});
rankList.value = Array.isArray(result.records) ? result.records : [];
rankMeta.cycleKey = String(result.cycleKey || '');
rankMeta.dateNumber = String(result.dateNumber || '');
rankMeta.periodEndAt = String(result.periodEndAt || '');
rankMeta.periodStartAt = String(result.periodStartAt || '');
rankMeta.timezone = String(result.timezone || 'Asia/Riyadh');
} finally {
rankLoading.value = false;
}
}
async function reloadAll() {
await Promise.all([loadConfig(), loadCurrentRank(), loadRecords(true)]);
}
function addLevel() {
const levels = form.levelConfigs as Array<Record<string, any>>;
const maxLevel = Math.max(0, ...levels.map((item) => Number(item.level || 0)));
const maxThreshold = Math.max(0, ...levels.map((item) => Number(item.turnoverThreshold || 0)));
levels.push(createLevel(maxLevel + 1, maxThreshold + 100_000, 0));
}
function removeLevel(index: number) {
(form.levelConfigs as Array<Record<string, any>>).splice(index, 1);
}
function validateBeforeSave() {
const levels = form.levelConfigs as Array<Record<string, any>>;
if (levels.length === 0) {
message.warning('请至少配置一个奖励等级');
return false;
}
const seenLevels = new Set<string>();
const seenThresholds = new Set<string>();
for (const item of levels) {
const level = Number(item.level || 0);
const threshold = Number(item.turnoverThreshold || 0);
const rewardGold = Number(item.rewardGold || 0);
if (level <= 0 || threshold <= 0 || rewardGold <= 0) {
message.warning('等级、流水门槛和奖励金币都必须大于 0');
return false;
}
if (seenLevels.has(String(level))) {
message.warning('奖励等级不能重复');
return false;
}
if (seenThresholds.has(String(threshold))) {
message.warning('流水门槛不能重复');
return false;
}
seenLevels.add(String(level));
seenThresholds.add(String(threshold));
}
if (form.enabled && enabledLevelCount.value === 0) {
message.warning('启用配置时至少要启用一个奖励等级');
return false;
}
return true;
}
async function submitForm() {
if (!validateBeforeSave()) {
return;
}
saving.value = true;
try {
const autoRewardEnabled = Boolean(form.autoRewardEnabled);
await saveRoomTurnoverRewardConfig({
autoRewardEnabled,
enabled: Boolean(form.enabled),
id: form.id || 0,
levelConfigs: (form.levelConfigs || []).map((item: Record<string, any>) => ({
enabled: item.enabled !== false,
id: item.id || 0,
level: Number(item.level || 0),
rewardGold: Number(item.rewardGold || 0),
turnoverThreshold: Number(item.turnoverThreshold || 0),
})),
sysOrigin: selectedSysOrigin.value,
timezone: String(form.timezone || '').trim(),
});
autoRewardEnabledCache.set(selectedSysOrigin.value, autoRewardEnabled);
message.success('保存成功');
await reloadAll();
} finally {
saving.value = false;
}
}
function statusColor(status: string) {
switch (status) {
case 'SUCCESS':
return 'success';
case 'FAILED':
return 'error';
case 'PENDING':
return 'processing';
default:
return 'default';
}
}
function formatNumber(value: number | string | undefined) {
const numeric = Number(value || 0);
return Number.isFinite(numeric) ? numeric.toLocaleString() : '0';
}
function coverBackgroundStyle(url?: string) {
const coverUrl = String(url || '').trim();
return coverUrl ? { backgroundImage: `url(${JSON.stringify(coverUrl)})` } : {};
}
function handleRecordPageChange(page: number, pageSize: number) {
recordQuery.cursor = page;
recordQuery.limit = pageSize;
void loadRecords();
}
function handleRankLimitChange() {
void loadCurrentRank();
}
function openRoomDetails(roomId?: number | string) {
if (!roomId) {
return;
}
activeRoomId.value = roomId;
roomDetailsOpen.value = true;
}
function confirmRemoveLevel(index: number) {
Modal.confirm({
onOk: () => removeLevel(index),
title: '确认删除该奖励等级?',
});
}
watch(
sysOriginOptions,
(options) => {
if (!selectedSysOrigin.value) {
selectedSysOrigin.value = String(options[0]?.value || '');
}
},
{ immediate: true },
);
watch(
selectedSysOrigin,
(value) => {
if (value) {
void reloadAll();
}
},
{ immediate: true },
);
</script>
<template>
<Page auto-content-height>
<Card :bordered="false" title="房间流水奖励配置">
<div class="toolbar">
<Select
v-model:value="selectedSysOrigin"
:options="sysOriginOptions"
option-filter-prop="label"
option-label-prop="label"
placeholder="请选择系统"
show-search
style="width: 180px"
/>
<Tag :color="form.enabled ? 'success' : 'default'">
{{ form.enabled ? '已启用' : '已关闭' }}
</Tag>
<Tag :color="form.autoRewardEnabled ? 'success' : 'warning'">
{{ form.autoRewardEnabled ? '自动发放' : '手动领取' }}
</Tag>
<Tag color="processing">按达到的最高档发放一次</Tag>
<Button :loading="loading" @click="reloadAll">刷新</Button>
</div>
<div class="config-row">
<Space align="center" wrap>
<span class="field-label">启用</span>
<Switch v-model:checked="form.enabled" />
<span class="field-label">自动发放</span>
<Switch v-model:checked="form.autoRewardEnabled" />
<span class="field-label">结算时区</span>
<Input v-model:value="form.timezone" style="width: 180px" />
<span class="sub-text">更新时间{{ form.updateTime || '-' }}</span>
</Space>
</div>
<Table
:columns="levelColumns"
:data-source="form.levelConfigs"
:loading="loading"
:pagination="false"
row-key="level"
:scroll="{ x: 760 }"
>
<template #bodyCell="{ column, index, record }">
<template v-if="column.key === 'enabled'">
<Switch v-model:checked="record.enabled" />
</template>
<template v-else-if="column.key === 'level'">
<InputNumber v-model:value="record.level" :min="1" :precision="0" style="width: 90px" />
</template>
<template v-else-if="column.key === 'turnoverThreshold'">
<InputNumber
v-model:value="record.turnoverThreshold"
:min="1"
:precision="0"
style="width: 180px"
/>
</template>
<template v-else-if="column.key === 'rewardGold'">
<InputNumber
v-model:value="record.rewardGold"
:min="1"
:precision="0"
style="width: 180px"
/>
</template>
<template v-else-if="column.key === 'actions'">
<Button danger type="link" @click="confirmRemoveLevel(index)">删除</Button>
</template>
</template>
</Table>
<div class="actions">
<Button @click="addLevel">新增等级</Button>
<Button :loading="saving" type="primary" @click="submitForm">保存配置</Button>
</div>
</Card>
<Card :bordered="false" class="record-card" title="当前房间排行榜">
<div class="toolbar">
<Tag color="processing">{{ rankMeta.cycleKey || '-' }}</Tag>
<Tag>{{ rankMeta.timezone || '-' }}</Tag>
<span class="sub-text">
{{ rankMeta.periodStartAt || '-' }} - {{ rankMeta.periodEndAt || '-' }}
</span>
<span class="sub-text">统计周{{ rankMeta.dateNumber || '-' }}</span>
<Select
v-model:value="rankQuery.limit"
:options="[
{ label: 'Top 20', value: 20 },
{ label: 'Top 50', value: 50 },
{ label: 'Top 100', value: 100 },
]"
style="width: 120px"
@change="handleRankLimitChange"
/>
<Button :loading="rankLoading" type="primary" @click="loadCurrentRank">刷新排行</Button>
</div>
<Table
:columns="rankColumns"
:data-source="rankList"
:loading="rankLoading"
:pagination="false"
row-key="roomId"
:scroll="{ x: 1410 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'room'">
<div class="room-cell">
<div
v-if="record.roomCover"
class="room-cover"
:style="coverBackgroundStyle(record.roomCover)"
/>
<div v-else class="room-cover placeholder">R</div>
<div class="room-copy">
<Button type="link" @click="openRoomDetails(record.roomId)">
{{ record.roomName || '-' }}
</Button>
<div class="sub-text">{{ record.roomAccount || record.roomId || '-' }}</div>
</div>
</div>
</template>
<template v-else-if="column.key === 'turnoverAmount'">
{{ formatNumber(record.turnoverAmount) }}
</template>
<template v-else-if="column.key === 'matchedLevel'">
<Tag v-if="record.matchedLevel" color="success">Lv {{ record.matchedLevel }}</Tag>
<Tag v-else>未命中</Tag>
</template>
<template v-else-if="column.key === 'turnoverThreshold'">
{{ record.turnoverThreshold ? formatNumber(record.turnoverThreshold) : '-' }}
</template>
<template v-else-if="column.key === 'expectedRewardGold'">
{{ record.expectedRewardGold ? formatNumber(record.expectedRewardGold) : '-' }}
</template>
</template>
</Table>
</Card>
<Card :bordered="false" class="record-card" title="发奖记录">
<div class="toolbar">
<Input
v-model:value="recordQuery.cycleKey"
allow-clear
placeholder="周期 key"
style="width: 180px"
@press-enter="loadRecords(true)"
/>
<Select
v-model:value="recordQuery.status"
:options="statusOptions"
style="width: 140px"
@change="loadRecords(true)"
/>
<Button :loading="recordLoading" type="primary" @click="loadRecords(true)">查询</Button>
</div>
<Table
:columns="recordColumns"
:data-source="recordList"
:loading="recordLoading"
:pagination="false"
row-key="id"
:scroll="{ x: 1560 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'room'">
<div class="room-cell">
<div
v-if="record.roomCover"
class="room-cover"
:style="coverBackgroundStyle(record.roomCover)"
/>
<div v-else class="room-cover placeholder">R</div>
<div class="room-copy">
<Button type="link" @click="openRoomDetails(record.roomId)">
{{ record.roomName || '-' }}
</Button>
<div class="sub-text">{{ record.roomAccount || record.roomId || '-' }}</div>
</div>
</div>
</template>
<template v-else-if="column.key === 'turnoverAmount'">
{{ formatNumber(record.turnoverAmount) }}
</template>
<template v-else-if="column.key === 'rewardGold'">
{{ formatNumber(record.rewardGold) }}
</template>
<template v-else-if="column.key === 'status'">
<Tag :color="statusColor(record.status)">{{ record.status || '-' }}</Tag>
</template>
</template>
</Table>
<div v-if="recordTotal > 0" class="pager">
<Pagination
:current="recordQuery.cursor"
:page-size="recordQuery.limit"
:total="recordTotal"
show-size-changer
@change="handleRecordPageChange"
@showSizeChange="handleRecordPageChange"
/>
</div>
</Card>
<RoomDetailsDrawer
:open="roomDetailsOpen"
:room-id="activeRoomId"
@close="roomDetailsOpen = false"
/>
</Page>
</template>
<style scoped>
.toolbar {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.config-row {
margin-bottom: 16px;
}
.field-label {
color: rgb(71 85 105);
font-size: 13px;
}
.sub-text {
color: rgb(100 116 139);
font-size: 12px;
}
.actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 16px;
}
.record-card {
margin-top: 16px;
}
.room-cell {
align-items: center;
display: flex;
gap: 12px;
}
.room-cover {
background-position: center;
background-repeat: no-repeat;
background-size: cover;
border-radius: 8px;
flex: 0 0 40px;
height: 40px;
width: 40px;
}
.room-cover.placeholder {
align-items: center;
background: rgb(226 232 240);
color: rgb(100 116 139);
display: flex;
font-size: 13px;
font-weight: 600;
justify-content: center;
}
.room-copy {
min-width: 0;
}
.pager {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>