yumi-admin/apps/src/views/resident-activity/first-recharge-reward-config.vue
2026-05-28 15:01:01 +08:00

684 lines
21 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 {
Button,
Card,
Input,
InputNumber,
message,
Modal,
Select,
Space,
Spin,
Switch,
Table,
TabPane,
Tabs,
Tag,
} from 'antdv-next';
import {
getResidentFirstRechargeRewardConfig,
pageResidentFirstRechargeRewardGrantRecords,
saveResidentFirstRechargeRewardConfig,
} from '#/api/legacy/resident-activity';
import ActivityResourceGroupSelectDrawer from '#/views/props/components/activity-resource-group-select-drawer.vue';
import RewardRow from '#/views/props/components/reward-row.vue';
import { getAllowedSysOrigins } from '#/views/system/shared';
defineOptions({ name: 'ResidentFirstRechargeRewardConfigPage' });
let localRowId = 0;
function createRowKey(seed?: number | string) {
localRowId += 1;
return `first-recharge-reward-${seed || localRowId}-${localRowId}`;
}
function createLevel(level = 1, rechargeAmount = 10) {
return {
enabled: true,
googleProductId: '',
id: null,
level,
rechargeAmount,
rechargeAmountCents: 0,
rewardGroupId: null,
rewardGroupName: '',
rewardItems: [] as Array<Record<string, any>>,
rowKey: createRowKey(),
};
}
function normalizeID(value: unknown) {
const text = String(value ?? '').trim();
return text && text !== '0' ? text : '';
}
function hasPositiveID(value: unknown) {
return /^\d+$/.test(normalizeID(value));
}
function defaultLevels() {
return [createLevel(1, 10), createLevel(2, 50), createLevel(3, 100)];
}
const accessStore = useAccessStore();
const sysOriginOptions = computed(() => {
const options = getAllowedSysOrigins(accessStore.accessCodes || []);
return options.length > 0 ? options : getAllowedSysOrigins([]);
});
const selectedSysOrigin = ref('');
const activeTab = ref('config');
const loading = ref(false);
const recordLoading = ref(false);
const saving = ref(false);
const groupPickerOpen = ref(false);
const pickerLevelIndex = ref(-1);
const form = reactive<Record<string, any>>({
configured: false,
enabled: false,
id: 0,
levelConfigs: defaultLevels(),
minAppVersion: '',
sysOrigin: '',
updateTime: '',
});
const levelColumns = [
{ dataIndex: 'enabled', key: 'enabled', title: '启用', width: 90 },
{ dataIndex: 'level', key: 'level', title: '档位', width: 110 },
{ dataIndex: 'rechargeAmount', key: 'rechargeAmount', title: '首冲金额', width: 180 },
{ dataIndex: 'googleProductId', key: 'googleProductId', title: '谷歌商品ID', width: 240 },
{ dataIndex: 'rewardGroup', key: 'rewardGroup', title: '奖励资源组', width: 420 },
{ dataIndex: 'actions', key: 'actions', title: '操作', width: 100 },
];
const enabledLevelCount = computed(() =>
(form.levelConfigs || []).filter((item: Record<string, any>) => item.enabled !== false).length,
);
const statusMeta = computed(() => {
if (!form.configured) {
return { color: 'processing', text: '未配置' };
}
if (form.enabled) {
return { color: 'success', text: '已启用' };
}
return { color: 'default', text: '已关闭' };
});
const recordQuery = reactive<Record<string, any>>({
cursor: 1,
limit: 20,
status: '',
userId: '',
});
const recordPage = reactive<Record<string, any>>({
current: 1,
records: [],
size: 20,
total: 0,
});
const statusOptions = [
{ label: '全部状态', value: '' },
{ label: '发放中', value: 'PENDING' },
{ label: '已发放', value: 'SUCCESS' },
{ label: '发放失败', value: 'FAILED' },
];
const recordColumns = [
{ dataIndex: 'user', key: 'user', title: '用户', width: 260 },
{ dataIndex: 'rechargeAmount', key: 'rechargeAmount', title: '充值金额', width: 120 },
{ dataIndex: 'rechargeThreshold', key: 'rechargeThreshold', title: '达标金额', width: 120 },
{ dataIndex: 'level', key: 'level', title: '档位', width: 90 },
{ dataIndex: 'payPlatform', key: 'payPlatform', title: '充值方式', width: 180 },
{ dataIndex: 'sourceOrderId', key: 'sourceOrderId', title: '订单', width: 180 },
{ dataIndex: 'rewardGroup', key: 'rewardGroup', title: '奖励资源组', width: 360 },
{ dataIndex: 'status', key: 'status', title: '发放状态', width: 120 },
{ dataIndex: 'sentAt', key: 'sentAt', title: '发放时间', width: 180 },
{ dataIndex: 'lastError', key: 'lastError', title: '失败原因', width: 220 },
];
function toAmountCents(value: number | string | undefined) {
const numeric = Number(value || 0);
if (!Number.isFinite(numeric)) {
return 0;
}
return Math.round(numeric * 100);
}
function formatAmount(value: number | string | undefined) {
const numeric = Number(value || 0);
return Number.isFinite(numeric) ? numeric.toFixed(2) : '0.00';
}
function normalizeConfig(result: null | Record<string, any> | undefined) {
const value = result || {};
form.configured = Boolean(value.configured);
form.enabled = Boolean(value.enabled);
form.id = value.id || 0;
form.minAppVersion = String(value.minAppVersion || '');
form.sysOrigin = String(value.sysOrigin || selectedSysOrigin.value || '');
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>, index: number) => {
const rechargeAmount = Number(
item.rechargeAmount ?? Number(item.rechargeAmountCents || 0) / 100,
);
return {
enabled: item.enabled !== false,
googleProductId: String(item.googleProductId || ''),
id: item.id ?? null,
level: Number(item.level || index + 1),
rechargeAmount: Number.isFinite(rechargeAmount) ? rechargeAmount : 0,
rechargeAmountCents: Number(item.rechargeAmountCents || 0),
rewardGroupId: normalizeID(item.rewardGroupId) || null,
rewardGroupName: String(item.rewardGroupName || ''),
rewardItems: Array.isArray(item.rewardItems) ? item.rewardItems : [],
rowKey: createRowKey(item.id || index),
};
})
.toSorted((left: Record<string, any>, right: Record<string, any>) =>
toAmountCents(left.rechargeAmount) - toAmountCents(right.rechargeAmount),
);
}
async function loadConfig() {
if (!selectedSysOrigin.value) {
return;
}
loading.value = true;
try {
const result = await getResidentFirstRechargeRewardConfig(selectedSysOrigin.value);
normalizeConfig(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 loadGrantRecords() {
if (!selectedSysOrigin.value) {
return;
}
recordLoading.value = true;
try {
const result = await pageResidentFirstRechargeRewardGrantRecords({
...recordQuery,
sysOrigin: selectedSysOrigin.value,
});
normalizeRecordPage(result);
} finally {
recordLoading.value = false;
}
}
function addLevel() {
const levels = form.levelConfigs as Array<Record<string, any>>;
const maxLevel = Math.max(0, ...levels.map((item) => Number(item.level || 0)));
const maxAmount = Math.max(0, ...levels.map((item) => Number(item.rechargeAmount || 0)));
levels.push(createLevel(maxLevel + 1, maxAmount + 10));
}
function removeLevel(index: number) {
(form.levelConfigs as Array<Record<string, any>>).splice(index, 1);
}
function confirmRemoveLevel(index: number) {
Modal.confirm({
onOk: () => removeLevel(index),
title: '确认删除该首冲奖励档位?',
});
}
function openRewardPicker(index: number) {
pickerLevelIndex.value = index;
groupPickerOpen.value = true;
}
function clearRewardGroup(record: Record<string, any>) {
record.rewardGroupId = null;
record.rewardGroupName = '';
record.rewardItems = [];
}
function handleRewardGroupSelect(row: Record<string, any>) {
const levels = form.levelConfigs as Array<Record<string, any>>;
const target = levels[pickerLevelIndex.value];
if (!target) {
groupPickerOpen.value = false;
return;
}
target.rewardGroupId = normalizeID(row.id) || null;
target.rewardGroupName = String(row.name || '');
target.rewardItems = Array.isArray(row.rewardConfigList) ? row.rewardConfigList : [];
groupPickerOpen.value = false;
}
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 seenAmounts = new Set<string>();
const seenGoogleProductIds = new Set<string>();
for (const item of levels) {
const level = Number(item.level || 0);
const amountCents = toAmountCents(item.rechargeAmount);
if (level <= 0) {
message.warning('档位必须大于 0');
return false;
}
if (amountCents <= 0) {
message.warning('首冲金额必须大于 0');
return false;
}
if (!hasPositiveID(item.rewardGroupId)) {
message.warning('每个档位都需要配置奖励资源组');
return false;
}
if (seenLevels.has(String(level))) {
message.warning('档位不能重复');
return false;
}
if (seenAmounts.has(String(amountCents))) {
message.warning('首冲金额不能重复');
return false;
}
const googleProductId = String(item.googleProductId || '').trim();
if (googleProductId) {
if (seenGoogleProductIds.has(googleProductId)) {
message.warning('谷歌商品ID不能重复');
return false;
}
seenGoogleProductIds.add(googleProductId);
}
seenLevels.add(String(level));
seenAmounts.add(String(amountCents));
}
if (form.enabled && enabledLevelCount.value === 0) {
message.warning('启用配置时至少要启用一个首冲奖励档位');
return false;
}
if (form.enabled && !String(form.minAppVersion || '').trim()) {
message.warning('启用配置时需要填写最低App版本');
return false;
}
return true;
}
async function submitForm() {
if (!validateBeforeSave()) {
return;
}
saving.value = true;
try {
await saveResidentFirstRechargeRewardConfig({
enabled: Boolean(form.enabled),
id: form.id || 0,
levelConfigs: (form.levelConfigs || []).map((item: Record<string, any>) => ({
enabled: item.enabled !== false,
googleProductId: String(item.googleProductId || '').trim(),
id: item.id || 0,
level: Number(item.level || 0),
rechargeAmount: formatAmount(item.rechargeAmount),
rechargeAmountCents: toAmountCents(item.rechargeAmount),
rewardGroupId: normalizeID(item.rewardGroupId) || null,
rewardGroupName: String(item.rewardGroupName || ''),
})),
minAppVersion: String(form.minAppVersion || '').trim(),
sysOrigin: selectedSysOrigin.value,
});
message.success('保存成功');
await loadConfig();
if (activeTab.value === 'records') {
await loadGrantRecords();
}
} finally {
saving.value = false;
}
}
function searchGrantRecords() {
recordQuery.cursor = 1;
void loadGrantRecords();
}
function handleGrantTableChange(pagination: Record<string, any>) {
recordQuery.cursor = Number(pagination.current || 1);
recordQuery.limit = Number(pagination.pageSize || 20);
void loadGrantRecords();
}
function avatarBackgroundStyle(url: string) {
return {
backgroundImage: `url("${String(url || '').replaceAll('"', '%22')}")`,
};
}
function grantStatusMeta(status: string) {
if (status === 'SUCCESS') {
return { color: 'success', text: '已发放' };
}
if (status === 'FAILED') {
return { color: 'error', text: '发放失败' };
}
return { color: 'processing', text: '发放中' };
}
watch(
sysOriginOptions,
(options) => {
if (!selectedSysOrigin.value) {
selectedSysOrigin.value = String(options[0]?.value || '');
}
},
{ immediate: true },
);
watch(
selectedSysOrigin,
(value) => {
if (value) {
recordQuery.cursor = 1;
void loadConfig();
void loadGrantRecords();
}
},
{ immediate: true },
);
watch(activeTab, (value) => {
if (value === 'records') {
void loadGrantRecords();
}
});
</script>
<template>
<Page auto-content-height>
<Spin :spinning="loading">
<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="statusMeta.color">{{ statusMeta.text }}</Tag>
<Tag color="processing"> Google / 三方充值</Tag>
<Button :loading="loading" @click="loadConfig">刷新</Button>
</div>
<Tabs v-model:active-key="activeTab">
<TabPane key="config" tab="奖励配置">
<div class="config-row">
<Space align="center" wrap>
<span class="field-label">启用</span>
<Switch v-model:checked="form.enabled" />
<span class="field-label">最低App版本</span>
<Input
v-model:value="form.minAppVersion"
allow-clear
placeholder="例如 1.5.0"
style="width: 160px"
/>
<span class="sub-text">更新时间{{ form.updateTime || '-' }}</span>
</Space>
</div>
<Table
:columns="levelColumns"
:data-source="form.levelConfigs"
:loading="loading"
:pagination="false"
row-key="rowKey"
:scroll="{ x: 1140 }"
>
<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 === 'rechargeAmount'">
<InputNumber
v-model:value="record.rechargeAmount"
:min="0.01"
:precision="2"
style="width: 140px"
/>
</template>
<template v-else-if="column.key === 'googleProductId'">
<Input
v-model:value="record.googleProductId"
allow-clear
placeholder="Google商品ID"
style="width: 200px"
/>
</template>
<template v-else-if="column.key === 'rewardGroup'">
<div class="reward-group-cell">
<Space wrap>
<Button size="small" @click="openRewardPicker(index)">
{{ record.rewardGroupId ? '更换奖励组' : '选择奖励组' }}
</Button>
<Button
v-if="record.rewardGroupId"
danger
size="small"
type="link"
@click="clearRewardGroup(record)"
>
清空
</Button>
</Space>
<div v-if="record.rewardGroupId" class="reward-group-name">
{{ record.rewardGroupName || '-' }} (ID: {{ record.rewardGroupId }})
</div>
<div v-else class="sub-text">未选择奖励组</div>
<RewardRow v-if="record.rewardItems?.length" :list="record.rewardItems" />
</div>
</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>
</TabPane>
<TabPane key="records" tab="发奖记录">
<div class="toolbar">
<Input
v-model:value="recordQuery.userId"
allow-clear
placeholder="用户长ID"
style="width: 180px"
/>
<Select
v-model:value="recordQuery.status"
:options="statusOptions"
style="width: 140px"
/>
<Button :loading="recordLoading" type="primary" @click="searchGrantRecords">
查询
</Button>
</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: 1810 }"
@change="handleGrantTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'user'">
<div class="user-cell">
<div
v-if="record.userAvatar"
class="user-avatar"
:style="avatarBackgroundStyle(record.userAvatar)"
></div>
<div class="user-main">
<div class="user-name">{{ record.userNickname || '-' }}</div>
<div class="sub-text">
{{ record.account || record.userId || '-' }}
<span v-if="record.countryCode"> · {{ record.countryCode }}</span>
</div>
</div>
</div>
</template>
<template v-else-if="column.key === 'payPlatform'">
<div>{{ record.payPlatform || '-' }}</div>
<div class="sub-text">{{ record.paymentMethod || '-' }}</div>
</template>
<template v-else-if="column.key === 'rewardGroup'">
<div class="reward-group-name">
{{ record.rewardGroupName || '-' }} (ID: {{ record.rewardGroupId || '-' }})
</div>
<RewardRow v-if="record.rewardItems?.length" :list="record.rewardItems" />
</template>
<template v-else-if="column.key === 'status'">
<Tag :color="grantStatusMeta(record.status).color">
{{ grantStatusMeta(record.status).text }}
</Tag>
</template>
<template v-else-if="column.key === 'sentAt'">
{{ record.sentAt || '-' }}
</template>
<template v-else-if="column.key === 'lastError'">
<span class="error-text">{{ record.lastError || '-' }}</span>
</template>
</template>
</Table>
</TabPane>
</Tabs>
</Card>
<ActivityResourceGroupSelectDrawer
:open="groupPickerOpen"
:sys-origin="selectedSysOrigin"
@close="groupPickerOpen = false"
@select="handleRewardGroupSelect"
/>
</Spin>
</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;
}
.reward-group-cell {
min-width: 0;
}
.reward-group-name {
color: rgb(15 23 42);
font-size: 13px;
margin-top: 8px;
}
.user-cell {
align-items: center;
display: flex;
gap: 10px;
min-width: 0;
}
.user-avatar {
background-color: rgb(226 232 240);
background-position: center;
background-size: cover;
border-radius: 50%;
flex: 0 0 auto;
height: 36px;
width: 36px;
}
.user-main {
min-width: 0;
}
.user-name {
color: rgb(15 23 42);
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.error-text {
color: rgb(185 28 28);
}
.actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 16px;
}
</style>