626 lines
18 KiB
Vue
626 lines
18 KiB
Vue
<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,
|
||
Image,
|
||
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>>({
|
||
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 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 || {};
|
||
form.configured = Boolean(value.configured);
|
||
form.enabled = Boolean(value.enabled);
|
||
form.id = value.id || 0;
|
||
form.sysOrigin = String(value.sysOrigin || selectedSysOrigin.value || '');
|
||
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 {
|
||
await saveRoomTurnoverRewardConfig({
|
||
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(),
|
||
});
|
||
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 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="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>
|
||
<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">
|
||
<Image :preview="false" :src="record.roomCover" class="room-cover" />
|
||
<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">
|
||
<Image :preview="false" :src="record.roomCover" class="room-cover" />
|
||
<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 {
|
||
border-radius: 8px;
|
||
height: 48px;
|
||
width: 48px;
|
||
}
|
||
|
||
.room-copy {
|
||
min-width: 0;
|
||
}
|
||
|
||
.pager {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-top: 16px;
|
||
}
|
||
</style>
|