1287 lines
36 KiB
Vue
1287 lines
36 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 { listGiftBySysOrigin } from '#/api/legacy/gift';
|
||
import {
|
||
getSpecifiedGiftWeeklyRankCurrentRank,
|
||
getSpecifiedGiftWeeklyRankConfig,
|
||
pageSpecifiedGiftWeeklyRankConfigs,
|
||
pageSpecifiedGiftWeeklyRankRewardRecords,
|
||
pageSpecifiedGiftWeeklyRankSnapshots,
|
||
retrySpecifiedGiftWeeklyRankRewardRecord,
|
||
saveSpecifiedGiftWeeklyRankConfig,
|
||
} from '#/api/legacy/resident-activity';
|
||
import ActivityResourceGroupSelectDrawer from '#/views/props/components/activity-resource-group-select-drawer.vue';
|
||
import { getAllowedSysOrigins } from '#/views/system/shared';
|
||
|
||
import {
|
||
Button,
|
||
Card,
|
||
DatePicker,
|
||
Image,
|
||
Input,
|
||
Modal,
|
||
Pagination,
|
||
Select,
|
||
Space,
|
||
Spin,
|
||
Switch,
|
||
Table,
|
||
Tag,
|
||
message,
|
||
} from 'antdv-next';
|
||
|
||
defineOptions({ name: 'ResidentSpecifiedGiftWeeklyRankPage' });
|
||
|
||
function createGiftSlot(sort: number) {
|
||
return {
|
||
giftCandy: 0,
|
||
giftId: undefined,
|
||
giftName: '',
|
||
giftPhoto: '',
|
||
sort,
|
||
};
|
||
}
|
||
|
||
function createRewardSlot(rank: number) {
|
||
return {
|
||
items: [] as Array<Record<string, any>>,
|
||
rank,
|
||
rewardGroupId: null,
|
||
rewardGroupName: '',
|
||
};
|
||
}
|
||
|
||
function createEmptyForm(sysOrigin = '') {
|
||
return {
|
||
configured: false,
|
||
editable: true,
|
||
enabled: true,
|
||
endAt: '',
|
||
giftConfigs: [createGiftSlot(1), createGiftSlot(2), createGiftSlot(3)],
|
||
id: 0,
|
||
rewardConfigs: [createRewardSlot(1), createRewardSlot(2), createRewardSlot(3)],
|
||
startAt: '',
|
||
status: 'NOT_STARTED',
|
||
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 configLoading = ref(false);
|
||
const configTotal = ref(0);
|
||
const configList = ref<Array<Record<string, any>>>([]);
|
||
|
||
const editorOpen = ref(false);
|
||
const editorLoading = ref(false);
|
||
const saving = ref(false);
|
||
const editorGiftLoading = ref(false);
|
||
const editorReadonly = ref(false);
|
||
const editorTitle = ref('新建周配置');
|
||
const groupPickerOpen = ref(false);
|
||
const rangeDate = ref<[string, string] | null>(null);
|
||
|
||
const giftOptions = ref<Array<Record<string, any>>>([]);
|
||
const pickerTarget = reactive({ rank: 1 });
|
||
|
||
const snapshotLoading = ref(false);
|
||
const snapshotTotal = ref(0);
|
||
const snapshotList = ref<Array<Record<string, any>>>([]);
|
||
|
||
const rewardLoading = ref(false);
|
||
const rewardTotal = ref(0);
|
||
const rewardList = ref<Array<Record<string, any>>>([]);
|
||
|
||
const rankLoading = ref(false);
|
||
const rankList = ref<Array<Record<string, any>>>([]);
|
||
const rankMeta = reactive<{
|
||
activityStatus: string;
|
||
configId: string;
|
||
cycleKey: string;
|
||
giftConfigs: Array<Record<string, any>>;
|
||
periodEndAt: string;
|
||
periodStartAt: string;
|
||
timezone: string;
|
||
}>({
|
||
activityStatus: '',
|
||
configId: '',
|
||
cycleKey: '',
|
||
giftConfigs: [],
|
||
periodEndAt: '',
|
||
periodStartAt: '',
|
||
timezone: 'Asia/Riyadh',
|
||
});
|
||
|
||
const form = reactive<Record<string, any>>(createEmptyForm());
|
||
|
||
const configQuery = reactive({
|
||
cursor: 1,
|
||
limit: 10,
|
||
});
|
||
|
||
const snapshotQuery = reactive({
|
||
configId: undefined as number | string | undefined,
|
||
cursor: 1,
|
||
cycleKey: '',
|
||
limit: 10,
|
||
});
|
||
|
||
const rewardQuery = reactive({
|
||
configId: undefined as number | string | undefined,
|
||
cursor: 1,
|
||
cycleKey: '',
|
||
limit: 10,
|
||
status: '',
|
||
});
|
||
|
||
const rankQuery = reactive({
|
||
limit: 20,
|
||
});
|
||
|
||
const rewardStatusOptions = [
|
||
{ label: '成功', value: 'SUCCESS' },
|
||
{ label: '失败', value: 'FAILED' },
|
||
{ label: '待发放', value: 'PENDING' },
|
||
];
|
||
|
||
const configStatusTextMap: Record<string, string> = {
|
||
DISABLED: '已关闭',
|
||
ENDED: '已结束',
|
||
NOT_STARTED: '未开始',
|
||
ONGOING: '进行中',
|
||
};
|
||
|
||
const configColumns = [
|
||
{ dataIndex: 'period', key: 'period', title: '活动周期', width: 320 },
|
||
{ dataIndex: 'status', key: 'status', title: '状态', width: 120 },
|
||
{ dataIndex: 'gifts', key: 'gifts', title: '指定礼物', width: 280 },
|
||
{ dataIndex: 'rewards', key: 'rewards', title: '奖励组', width: 280 },
|
||
{ dataIndex: 'updateTime', key: 'updateTime', title: '更新时间', width: 180 },
|
||
{ dataIndex: 'actions', key: 'actions', title: '操作', width: 160 },
|
||
];
|
||
|
||
const snapshotColumns = [
|
||
{ dataIndex: 'cycleKey', key: 'cycleKey', title: '周期', width: 140 },
|
||
{ dataIndex: 'rank', key: 'rank', title: '名次', width: 90 },
|
||
{ dataIndex: 'user', key: 'user', title: '用户', width: 280 },
|
||
{ dataIndex: 'scoreGold', key: 'scoreGold', title: '金币积分', width: 140 },
|
||
{ dataIndex: 'period', key: 'period', title: '结算区间', width: 320 },
|
||
];
|
||
|
||
const rankColumns = [
|
||
{ dataIndex: 'rank', key: 'rank', title: '名次', width: 90 },
|
||
{ dataIndex: 'user', key: 'user', title: '用户', width: 280 },
|
||
{ dataIndex: 'countryName', key: 'countryName', title: '国家', width: 150 },
|
||
{ dataIndex: 'scoreGold', key: 'scoreGold', title: '当前金币积分', width: 160 },
|
||
];
|
||
|
||
const rewardColumns = [
|
||
{ dataIndex: 'cycleKey', key: 'cycleKey', title: '周期', width: 140 },
|
||
{ dataIndex: 'rank', key: 'rank', title: '名次', width: 90 },
|
||
{ dataIndex: 'user', key: 'user', title: '用户', width: 280 },
|
||
{ dataIndex: 'rewardGroupName', key: 'rewardGroupName', title: '奖励组', width: 220 },
|
||
{ dataIndex: 'scoreGold', key: 'scoreGold', title: '金币积分', width: 140 },
|
||
{ dataIndex: 'status', key: 'status', title: '状态', width: 120 },
|
||
{ dataIndex: 'retryCount', key: 'retryCount', title: '补发次数', width: 110 },
|
||
{ dataIndex: 'sentAt', key: 'sentAt', title: '发奖时间', width: 180 },
|
||
{ dataIndex: 'lastError', key: 'lastError', title: '失败原因', width: 260 },
|
||
{ dataIndex: 'actions', key: 'actions', title: '操作', width: 120 },
|
||
];
|
||
|
||
const configFilterOptions = computed(() =>
|
||
configList.value.map((item) => ({
|
||
label: `${item.startAt || '-'} ~ ${item.endAt || '-'}`,
|
||
value: item.id,
|
||
})),
|
||
);
|
||
|
||
const editorStatusText = computed(() => configStatusTextMap[String(form.status || '')] || '未配置');
|
||
|
||
function statusColor(status: string) {
|
||
switch (status) {
|
||
case 'ONGOING':
|
||
return 'success';
|
||
case 'NOT_STARTED':
|
||
return 'processing';
|
||
case 'ENDED':
|
||
return 'default';
|
||
case 'FAILED':
|
||
return 'error';
|
||
case 'SUCCESS':
|
||
return 'success';
|
||
case 'PENDING':
|
||
return 'processing';
|
||
default:
|
||
return 'warning';
|
||
}
|
||
}
|
||
|
||
function avatarBackgroundStyle(url?: string) {
|
||
const avatarUrl = String(url || '').trim();
|
||
return avatarUrl ? { backgroundImage: `url(${JSON.stringify(avatarUrl)})` } : {};
|
||
}
|
||
|
||
function assignForm(next: Record<string, any>) {
|
||
Object.assign(form, createEmptyForm(selectedSysOrigin.value), next);
|
||
}
|
||
|
||
function normalizeConfig(result: Record<string, any> | null | undefined) {
|
||
const value = result ?? {};
|
||
assignForm({
|
||
configured: Boolean(value.configured),
|
||
editable: value.editable !== false,
|
||
enabled: Boolean(value.enabled),
|
||
endAt: String(value.endAt || ''),
|
||
id: Number(value.id || 0),
|
||
startAt: String(value.startAt || ''),
|
||
status: String(value.status || 'NOT_STARTED'),
|
||
sysOrigin: String(value.sysOrigin || selectedSysOrigin.value || ''),
|
||
timezone: String(value.timezone || 'Asia/Riyadh'),
|
||
updateTime: String(value.updateTime || ''),
|
||
});
|
||
rangeDate.value = form.startAt && form.endAt ? [form.startAt, form.endAt] : null;
|
||
|
||
const giftRows = Array.isArray(value.giftConfigs) ? [...value.giftConfigs] : [];
|
||
giftRows.sort((left, right) => Number(left.sort || 0) - Number(right.sort || 0));
|
||
form.giftConfigs = [1, 2, 3].map((sort) => {
|
||
const row = giftRows.find((item) => Number(item.sort || 0) === sort) || {};
|
||
return {
|
||
giftCandy: Number(row.giftCandy || 0),
|
||
giftId: row.giftId ?? undefined,
|
||
giftName: String(row.giftName || ''),
|
||
giftPhoto: String(row.giftPhoto || ''),
|
||
sort,
|
||
};
|
||
});
|
||
|
||
const rewardRows = Array.isArray(value.rewardConfigs) ? [...value.rewardConfigs] : [];
|
||
rewardRows.sort((left, right) => Number(left.rank || 0) - Number(right.rank || 0));
|
||
form.rewardConfigs = [1, 2, 3].map((rank) => {
|
||
const row = rewardRows.find((item) => Number(item.rank || 0) === rank) || {};
|
||
return {
|
||
items: Array.isArray(row.items) ? row.items : [],
|
||
rank,
|
||
rewardGroupId: row.rewardGroupId ?? null,
|
||
rewardGroupName: String(row.rewardGroupName || ''),
|
||
};
|
||
});
|
||
}
|
||
|
||
function resetEditorForCreate() {
|
||
assignForm(createEmptyForm(selectedSysOrigin.value));
|
||
rangeDate.value = null;
|
||
editorReadonly.value = false;
|
||
editorTitle.value = '新建周配置';
|
||
}
|
||
|
||
async function loadGiftOptions(sysOrigin: string) {
|
||
if (!sysOrigin) {
|
||
giftOptions.value = [];
|
||
return;
|
||
}
|
||
editorGiftLoading.value = true;
|
||
try {
|
||
giftOptions.value = (await listGiftBySysOrigin(sysOrigin)) || [];
|
||
} finally {
|
||
editorGiftLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function loadConfigList(reset = false) {
|
||
if (!selectedSysOrigin.value) {
|
||
configList.value = [];
|
||
configTotal.value = 0;
|
||
return;
|
||
}
|
||
if (reset) {
|
||
configQuery.cursor = 1;
|
||
}
|
||
configLoading.value = true;
|
||
try {
|
||
const result = await pageSpecifiedGiftWeeklyRankConfigs({
|
||
...configQuery,
|
||
sysOrigin: selectedSysOrigin.value,
|
||
});
|
||
configList.value = Array.isArray(result.records) ? result.records : [];
|
||
configTotal.value = Number(result.total || 0);
|
||
} finally {
|
||
configLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function loadConfigDetail(id: number | string) {
|
||
if (!id) {
|
||
return;
|
||
}
|
||
editorLoading.value = true;
|
||
try {
|
||
const result = await getSpecifiedGiftWeeklyRankConfig({
|
||
id,
|
||
sysOrigin: selectedSysOrigin.value,
|
||
});
|
||
normalizeConfig(result);
|
||
await loadGiftOptions(form.sysOrigin);
|
||
} finally {
|
||
editorLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function loadCurrentRank() {
|
||
if (!selectedSysOrigin.value) {
|
||
rankList.value = [];
|
||
Object.assign(rankMeta, {
|
||
activityStatus: '',
|
||
configId: '',
|
||
cycleKey: '',
|
||
giftConfigs: [],
|
||
periodEndAt: '',
|
||
periodStartAt: '',
|
||
timezone: 'Asia/Riyadh',
|
||
});
|
||
return;
|
||
}
|
||
rankLoading.value = true;
|
||
try {
|
||
const result = await getSpecifiedGiftWeeklyRankCurrentRank({
|
||
limit: rankQuery.limit,
|
||
sysOrigin: selectedSysOrigin.value,
|
||
});
|
||
rankList.value = Array.isArray(result.records) ? result.records : [];
|
||
rankMeta.activityStatus = String(result.activityStatus || '');
|
||
rankMeta.configId = String(result.configId || '');
|
||
rankMeta.cycleKey = String(result.cycleKey || '');
|
||
rankMeta.giftConfigs = Array.isArray(result.giftConfigs) ? result.giftConfigs : [];
|
||
rankMeta.periodEndAt = String(result.periodEndAt || '');
|
||
rankMeta.periodStartAt = String(result.periodStartAt || '');
|
||
rankMeta.timezone = String(result.timezone || 'Asia/Riyadh');
|
||
} finally {
|
||
rankLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function loadSnapshots(reset = false) {
|
||
if (!selectedSysOrigin.value) {
|
||
snapshotList.value = [];
|
||
snapshotTotal.value = 0;
|
||
return;
|
||
}
|
||
if (reset) {
|
||
snapshotQuery.cursor = 1;
|
||
}
|
||
snapshotLoading.value = true;
|
||
try {
|
||
const result = await pageSpecifiedGiftWeeklyRankSnapshots({
|
||
...snapshotQuery,
|
||
sysOrigin: selectedSysOrigin.value,
|
||
});
|
||
snapshotList.value = Array.isArray(result.records) ? result.records : [];
|
||
snapshotTotal.value = Number(result.total || 0);
|
||
} finally {
|
||
snapshotLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function loadRewardRecords(reset = false) {
|
||
if (!selectedSysOrigin.value) {
|
||
rewardList.value = [];
|
||
rewardTotal.value = 0;
|
||
return;
|
||
}
|
||
if (reset) {
|
||
rewardQuery.cursor = 1;
|
||
}
|
||
rewardLoading.value = true;
|
||
try {
|
||
const result = await pageSpecifiedGiftWeeklyRankRewardRecords({
|
||
...rewardQuery,
|
||
sysOrigin: selectedSysOrigin.value,
|
||
});
|
||
rewardList.value = Array.isArray(result.records) ? result.records : [];
|
||
rewardTotal.value = Number(result.total || 0);
|
||
} finally {
|
||
rewardLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function reloadPageData() {
|
||
await Promise.all([
|
||
loadConfigList(true),
|
||
loadCurrentRank(),
|
||
loadSnapshots(true),
|
||
loadRewardRecords(true),
|
||
]);
|
||
}
|
||
|
||
function syncGiftSelection(slot: Record<string, any>, giftId: number | string | undefined) {
|
||
const row = giftOptions.value.find((item) => String(item.id) === String(giftId || ''));
|
||
slot.giftId = giftId || undefined;
|
||
slot.giftName = String(row?.giftName || '');
|
||
slot.giftPhoto = String(row?.giftPhoto || '');
|
||
slot.giftCandy = Number(row?.giftCandy || 0);
|
||
}
|
||
|
||
function getGiftSelectOptions(currentValue: number | string | undefined) {
|
||
return giftOptions.value.map((item) => {
|
||
const selectedCount = form.giftConfigs.filter(
|
||
(slot: Record<string, any>) => String(slot.giftId || '') === String(item.id || ''),
|
||
).length;
|
||
const sameCurrent = String(currentValue || '') === String(item.id || '');
|
||
return {
|
||
disabled: selectedCount > 0 && !sameCurrent,
|
||
label: `${item.giftName || item.id} (${item.giftCandy || 0})`,
|
||
value: item.id as any,
|
||
};
|
||
});
|
||
}
|
||
|
||
function handleGiftChange(slot: Record<string, any>, value: any) {
|
||
const nextValue = Array.isArray(value) ? value[0] : value;
|
||
syncGiftSelection(slot, nextValue);
|
||
}
|
||
|
||
function handleEditorSysOriginChange(value: any) {
|
||
if (form.id) {
|
||
return;
|
||
}
|
||
form.sysOrigin = String(value || '');
|
||
form.giftConfigs = [createGiftSlot(1), createGiftSlot(2), createGiftSlot(3)];
|
||
void loadGiftOptions(form.sysOrigin);
|
||
}
|
||
|
||
function openRewardPicker(rank: number) {
|
||
if (editorReadonly.value) {
|
||
return;
|
||
}
|
||
pickerTarget.rank = rank;
|
||
groupPickerOpen.value = true;
|
||
}
|
||
|
||
function clearRewardGroup(rank: number) {
|
||
if (editorReadonly.value) {
|
||
return;
|
||
}
|
||
const target = form.rewardConfigs.find((item: Record<string, any>) => Number(item.rank) === rank);
|
||
if (!target) {
|
||
return;
|
||
}
|
||
target.rewardGroupId = null;
|
||
target.rewardGroupName = '';
|
||
target.items = [];
|
||
}
|
||
|
||
function handleRewardGroupSelect(row: Record<string, any>) {
|
||
const target = form.rewardConfigs.find(
|
||
(item: Record<string, any>) => Number(item.rank) === Number(pickerTarget.rank),
|
||
);
|
||
if (!target) {
|
||
groupPickerOpen.value = false;
|
||
return;
|
||
}
|
||
target.rewardGroupId = row.id;
|
||
target.rewardGroupName = row.name;
|
||
target.items = (row.rewardConfigList || []).map((item: Record<string, any>) => ({
|
||
content: item.content || '',
|
||
cover: item.cover || '',
|
||
id: item.id,
|
||
name: item.name || item.badgeName || item.content || item.type || '',
|
||
quantity: Number(item.quantity || 0),
|
||
remark: item.remark || '',
|
||
type: item.type || '',
|
||
}));
|
||
groupPickerOpen.value = false;
|
||
}
|
||
|
||
function validateBeforeSave() {
|
||
if (!form.startAt || !form.endAt) {
|
||
message.warning('请选择活动开始和结束时间');
|
||
return false;
|
||
}
|
||
const selectedGiftIds = form.giftConfigs
|
||
.map((item: Record<string, any>) => item.giftId)
|
||
.filter(Boolean);
|
||
if (selectedGiftIds.length !== 3 || new Set(selectedGiftIds.map(String)).size !== 3) {
|
||
message.warning('请完整且不重复地选择 3 个礼物');
|
||
return false;
|
||
}
|
||
const selectedRewardIds = form.rewardConfigs
|
||
.map((item: Record<string, any>) => item.rewardGroupId)
|
||
.filter(Boolean);
|
||
if (selectedRewardIds.length !== 3) {
|
||
message.warning('请为 Top1、Top2、Top3 都选择奖励组');
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
async function submitForm() {
|
||
if (editorReadonly.value || !validateBeforeSave()) {
|
||
return;
|
||
}
|
||
saving.value = true;
|
||
try {
|
||
await saveSpecifiedGiftWeeklyRankConfig({
|
||
enabled: Boolean(form.enabled),
|
||
endAt: form.endAt,
|
||
giftConfigs: (form.giftConfigs || []).map((item: Record<string, any>) => ({
|
||
giftCandy: Number(item.giftCandy || 0),
|
||
giftId: item.giftId || 0,
|
||
giftName: String(item.giftName || ''),
|
||
giftPhoto: String(item.giftPhoto || ''),
|
||
sort: Number(item.sort || 0),
|
||
})),
|
||
id: form.id || 0,
|
||
rewardConfigs: (form.rewardConfigs || []).map((item: Record<string, any>) => ({
|
||
rank: Number(item.rank || 0),
|
||
rewardGroupId: item.rewardGroupId || 0,
|
||
rewardGroupName: String(item.rewardGroupName || ''),
|
||
})),
|
||
startAt: form.startAt,
|
||
sysOrigin: String(form.sysOrigin || '').trim(),
|
||
timezone: String(form.timezone || '').trim(),
|
||
});
|
||
message.success('保存成功');
|
||
editorOpen.value = false;
|
||
await reloadPageData();
|
||
} finally {
|
||
saving.value = false;
|
||
}
|
||
}
|
||
|
||
function openCreateModal() {
|
||
resetEditorForCreate();
|
||
editorOpen.value = true;
|
||
void loadGiftOptions(form.sysOrigin);
|
||
}
|
||
|
||
async function openConfigModal(record: Record<string, any>, readOnly = !record.editable) {
|
||
editorTitle.value = readOnly ? '查看周配置' : '编辑周配置';
|
||
editorReadonly.value = readOnly;
|
||
editorOpen.value = true;
|
||
await loadConfigDetail(record.id || 0);
|
||
}
|
||
|
||
function handleConfigPageChange(page: number, pageSize: number) {
|
||
configQuery.cursor = page;
|
||
configQuery.limit = pageSize;
|
||
void loadConfigList();
|
||
}
|
||
|
||
function handleSnapshotPageChange(page: number, pageSize: number) {
|
||
snapshotQuery.cursor = page;
|
||
snapshotQuery.limit = pageSize;
|
||
void loadSnapshots();
|
||
}
|
||
|
||
function handleRewardPageChange(page: number, pageSize: number) {
|
||
rewardQuery.cursor = page;
|
||
rewardQuery.limit = pageSize;
|
||
void loadRewardRecords();
|
||
}
|
||
|
||
async function handleRetry(record: Record<string, any>) {
|
||
Modal.confirm({
|
||
async onOk() {
|
||
await retrySpecifiedGiftWeeklyRankRewardRecord(record.id);
|
||
message.success('补发任务已执行');
|
||
await loadRewardRecords(false);
|
||
},
|
||
title: `确认补发 ${record.userNickname || record.userId} 的奖励?`,
|
||
});
|
||
}
|
||
|
||
watch(
|
||
sysOriginOptions,
|
||
(options) => {
|
||
if (!selectedSysOrigin.value) {
|
||
selectedSysOrigin.value = String(options[0]?.value || '');
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
|
||
watch(
|
||
selectedSysOrigin,
|
||
(value) => {
|
||
if (!value) {
|
||
return;
|
||
}
|
||
resetEditorForCreate();
|
||
void reloadPageData();
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
|
||
watch(
|
||
rangeDate,
|
||
(value) => {
|
||
form.startAt = String(value?.[0] || '');
|
||
form.endAt = String(value?.[1] || '');
|
||
},
|
||
{ deep: true },
|
||
);
|
||
|
||
watch(
|
||
configList,
|
||
(list) => {
|
||
const configIds = new Set(list.map((item) => String(item.id || '')));
|
||
if (snapshotQuery.configId && !configIds.has(String(snapshotQuery.configId))) {
|
||
snapshotQuery.configId = undefined;
|
||
}
|
||
if (rewardQuery.configId && !configIds.has(String(rewardQuery.configId))) {
|
||
rewardQuery.configId = undefined;
|
||
}
|
||
},
|
||
{ deep: true },
|
||
);
|
||
</script>
|
||
|
||
<template>
|
||
<Page auto-content-height>
|
||
<Card :bordered="false" title="活动配置">
|
||
<Space class="toolbar" wrap>
|
||
<Select
|
||
v-model:value="selectedSysOrigin"
|
||
:options="sysOriginOptions"
|
||
option-filter-prop="label"
|
||
option-label-prop="label"
|
||
placeholder="请选择系统"
|
||
show-search
|
||
style="width: 180px"
|
||
/>
|
||
<Tag color="processing">当前周无配置可补建,已开始配置只读</Tag>
|
||
<Button type="primary" @click="openCreateModal">新建周配置</Button>
|
||
</Space>
|
||
|
||
<Table
|
||
:columns="configColumns"
|
||
:data-source="configList"
|
||
:loading="configLoading"
|
||
:pagination="false"
|
||
row-key="id"
|
||
:scroll="{ x: 1280 }"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key === 'period'">
|
||
<div class="period-cell">
|
||
<div>{{ record.startAt || '-' }} ~ {{ record.endAt || '-' }}</div>
|
||
<div class="sub-text">{{ record.timezone || 'Asia/Riyadh' }}</div>
|
||
</div>
|
||
</template>
|
||
<template v-else-if="column.key === 'status'">
|
||
<Space direction="vertical" size="small">
|
||
<Tag :color="statusColor(record.status)">
|
||
{{ configStatusTextMap[String(record.status || '')] || record.status || '-' }}
|
||
</Tag>
|
||
<span class="sub-text">{{ record.editable ? '可编辑' : '只读' }}</span>
|
||
</Space>
|
||
</template>
|
||
<template v-else-if="column.key === 'gifts'">
|
||
<div class="tag-stack">
|
||
<Tag
|
||
v-for="item in record.giftConfigs || []"
|
||
:key="`${record.id}-${item.giftId}`"
|
||
color="gold"
|
||
>
|
||
{{ item.giftName || item.giftId }}
|
||
</Tag>
|
||
</div>
|
||
</template>
|
||
<template v-else-if="column.key === 'rewards'">
|
||
<div class="tag-stack">
|
||
<Tag
|
||
v-for="item in record.rewardConfigs || []"
|
||
:key="`${record.id}-${item.rank}`"
|
||
color="green"
|
||
>
|
||
Top{{ item.rank }} {{ item.rewardGroupName || item.rewardGroupId || '-' }}
|
||
</Tag>
|
||
</div>
|
||
</template>
|
||
<template v-else-if="column.key === 'actions'">
|
||
<Space>
|
||
<Button
|
||
size="small"
|
||
type="primary"
|
||
@click="openConfigModal(record, !record.editable)"
|
||
>
|
||
{{ record.editable ? '编辑' : '查看' }}
|
||
</Button>
|
||
</Space>
|
||
</template>
|
||
</template>
|
||
</Table>
|
||
|
||
<div v-if="configTotal > 0" class="pager">
|
||
<Pagination
|
||
:current="configQuery.cursor"
|
||
:page-size="configQuery.limit"
|
||
:total="configTotal"
|
||
show-size-changer
|
||
@change="handleConfigPageChange"
|
||
@showSizeChange="handleConfigPageChange"
|
||
/>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card :bordered="false" class="mt-16" title="当前排行榜">
|
||
<Space class="toolbar" wrap>
|
||
<Tag :color="statusColor(rankMeta.activityStatus)">
|
||
{{ configStatusTextMap[rankMeta.activityStatus] || rankMeta.activityStatus || '未配置' }}
|
||
</Tag>
|
||
<Tag color="processing">{{ rankMeta.cycleKey || '-' }}</Tag>
|
||
<Tag>{{ rankMeta.timezone || '-' }}</Tag>
|
||
<span class="sub-text">
|
||
{{ rankMeta.periodStartAt || '-' }} ~ {{ rankMeta.periodEndAt || '-' }}
|
||
</span>
|
||
<div class="tag-stack">
|
||
<Tag
|
||
v-for="item in rankMeta.giftConfigs"
|
||
:key="`${rankMeta.configId}-${item.giftId}`"
|
||
color="gold"
|
||
>
|
||
{{ item.giftName || item.giftId }}
|
||
</Tag>
|
||
</div>
|
||
<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="loadCurrentRank"
|
||
/>
|
||
<Button :loading="rankLoading" type="primary" @click="loadCurrentRank">
|
||
刷新排行
|
||
</Button>
|
||
</Space>
|
||
|
||
<Table
|
||
:columns="rankColumns"
|
||
:data-source="rankList"
|
||
:loading="rankLoading"
|
||
:pagination="false"
|
||
row-key="userId"
|
||
:scroll="{ x: 680 }"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key === 'user'">
|
||
<div class="user-cell">
|
||
<div
|
||
v-if="record.userAvatar"
|
||
class="avatar"
|
||
:style="avatarBackgroundStyle(record.userAvatar)"
|
||
/>
|
||
<div v-else class="avatar placeholder">U</div>
|
||
<div class="user-meta">
|
||
<div>{{ 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 === 'countryName'">
|
||
{{ record.countryName || record.countryCode || '-' }}
|
||
</template>
|
||
</template>
|
||
</Table>
|
||
</Card>
|
||
|
||
<Card :bordered="false" class="mt-16" title="上周快照">
|
||
<Space class="toolbar" wrap>
|
||
<Select
|
||
v-model:value="snapshotQuery.configId"
|
||
allow-clear
|
||
:options="configFilterOptions"
|
||
placeholder="按活动周期筛选"
|
||
style="width: 260px"
|
||
/>
|
||
<Input
|
||
v-model:value="snapshotQuery.cycleKey"
|
||
allow-clear
|
||
placeholder="周期Key,例如 20260414"
|
||
style="width: 220px"
|
||
/>
|
||
<Button :loading="snapshotLoading" type="primary" @click="loadSnapshots(true)">
|
||
搜索
|
||
</Button>
|
||
</Space>
|
||
|
||
<Table
|
||
:columns="snapshotColumns"
|
||
:data-source="snapshotList"
|
||
:loading="snapshotLoading"
|
||
:pagination="false"
|
||
row-key="id"
|
||
:scroll="{ x: 980 }"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key === 'user'">
|
||
<div class="user-cell">
|
||
<div
|
||
v-if="record.userAvatar"
|
||
class="avatar"
|
||
:style="avatarBackgroundStyle(record.userAvatar)"
|
||
/>
|
||
<div v-else class="avatar placeholder">U</div>
|
||
<div class="user-meta">
|
||
<div>{{ 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 === 'period'">
|
||
{{ record.periodStartAt || '-' }} ~ {{ record.periodEndAt || '-' }}
|
||
</template>
|
||
</template>
|
||
</Table>
|
||
|
||
<div v-if="snapshotTotal > 0" class="pager">
|
||
<Pagination
|
||
:current="snapshotQuery.cursor"
|
||
:page-size="snapshotQuery.limit"
|
||
:total="snapshotTotal"
|
||
show-size-changer
|
||
@change="handleSnapshotPageChange"
|
||
@showSizeChange="handleSnapshotPageChange"
|
||
/>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card :bordered="false" class="mt-16" title="发奖记录">
|
||
<Space class="toolbar" wrap>
|
||
<Select
|
||
v-model:value="rewardQuery.configId"
|
||
allow-clear
|
||
:options="configFilterOptions"
|
||
placeholder="按活动周期筛选"
|
||
style="width: 260px"
|
||
/>
|
||
<Input
|
||
v-model:value="rewardQuery.cycleKey"
|
||
allow-clear
|
||
placeholder="周期Key,例如 20260414"
|
||
style="width: 220px"
|
||
/>
|
||
<Select
|
||
v-model:value="rewardQuery.status"
|
||
allow-clear
|
||
:options="rewardStatusOptions"
|
||
placeholder="请选择状态"
|
||
style="width: 140px"
|
||
/>
|
||
<Button :loading="rewardLoading" type="primary" @click="loadRewardRecords(true)">
|
||
搜索
|
||
</Button>
|
||
</Space>
|
||
|
||
<Table
|
||
:columns="rewardColumns"
|
||
:data-source="rewardList"
|
||
:loading="rewardLoading"
|
||
:pagination="false"
|
||
row-key="id"
|
||
:scroll="{ x: 1560 }"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key === 'user'">
|
||
<div class="user-cell">
|
||
<div
|
||
v-if="record.userAvatar"
|
||
class="avatar"
|
||
:style="avatarBackgroundStyle(record.userAvatar)"
|
||
/>
|
||
<div v-else class="avatar placeholder">U</div>
|
||
<div class="user-meta">
|
||
<div>{{ 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 === 'status'">
|
||
<Tag :color="statusColor(record.status)">
|
||
{{ record.status }}
|
||
</Tag>
|
||
</template>
|
||
<template v-else-if="column.key === 'lastError'">
|
||
<span class="error-text">{{ record.lastError || '-' }}</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'actions'">
|
||
<Button
|
||
:disabled="record.status !== 'FAILED'"
|
||
size="small"
|
||
type="primary"
|
||
@click="handleRetry(record)"
|
||
>
|
||
补发
|
||
</Button>
|
||
</template>
|
||
</template>
|
||
</Table>
|
||
|
||
<div v-if="rewardTotal > 0" class="pager">
|
||
<Pagination
|
||
:current="rewardQuery.cursor"
|
||
:page-size="rewardQuery.limit"
|
||
:total="rewardTotal"
|
||
show-size-changer
|
||
@change="handleRewardPageChange"
|
||
@showSizeChange="handleRewardPageChange"
|
||
/>
|
||
</div>
|
||
</Card>
|
||
|
||
<Modal
|
||
:destroy-on-close="false"
|
||
:footer="null"
|
||
:open="editorOpen"
|
||
:title="editorTitle"
|
||
width="960px"
|
||
@cancel="editorOpen = false"
|
||
>
|
||
<Spin :spinning="editorLoading || editorGiftLoading">
|
||
<div class="editor-top">
|
||
<Space wrap>
|
||
<Tag :color="statusColor(form.status)">{{ editorStatusText }}</Tag>
|
||
<Tag v-if="form.updateTime">更新于 {{ form.updateTime }}</Tag>
|
||
<Tag :color="editorReadonly ? 'default' : 'processing'">
|
||
{{ editorReadonly ? '当前为只读配置' : '当前为可编辑配置' }}
|
||
</Tag>
|
||
</Space>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<div class="field-label">系统</div>
|
||
<Select
|
||
:disabled="editorReadonly || Boolean(form.id)"
|
||
:options="sysOriginOptions"
|
||
:value="form.sysOrigin"
|
||
option-filter-prop="label"
|
||
option-label-prop="label"
|
||
show-search
|
||
style="width: 220px"
|
||
@update:value="handleEditorSysOriginChange"
|
||
/>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<div class="field-label">活动开关</div>
|
||
<Switch v-model:checked="form.enabled" :disabled="editorReadonly" />
|
||
</div>
|
||
|
||
<div class="section">
|
||
<div class="field-label">活动时间</div>
|
||
<DatePicker.RangePicker
|
||
v-model:value="rangeDate"
|
||
:disabled="editorReadonly"
|
||
show-time
|
||
style="width: 420px"
|
||
value-format="YYYY-MM-DD HH:mm:ss"
|
||
/>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<div class="field-label">活动时区</div>
|
||
<Input v-model:value="form.timezone" :disabled="editorReadonly" style="width: 240px" />
|
||
</div>
|
||
|
||
<div class="section block">
|
||
<div class="field-label">指定礼物</div>
|
||
<div class="slot-list">
|
||
<div
|
||
v-for="slot in form.giftConfigs"
|
||
:key="slot.sort"
|
||
class="slot-row"
|
||
>
|
||
<div class="slot-title">礼物 {{ slot.sort }}</div>
|
||
<Select
|
||
:disabled="editorReadonly"
|
||
:options="getGiftSelectOptions(slot.giftId)"
|
||
:value="slot.giftId"
|
||
placeholder="请选择礼物"
|
||
show-search
|
||
style="width: 320px"
|
||
@update:value="handleGiftChange(slot, $event)"
|
||
/>
|
||
<div class="slot-preview">
|
||
<Image
|
||
v-if="slot.giftPhoto"
|
||
:preview="false"
|
||
:src="slot.giftPhoto"
|
||
class="thumb"
|
||
/>
|
||
<div v-else class="thumb placeholder">G</div>
|
||
<div class="slot-meta">
|
||
<div class="slot-name">{{ slot.giftName || '未选择礼物' }}</div>
|
||
<div class="slot-sub">{{ slot.giftCandy || 0 }} 金币</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section block">
|
||
<div class="field-label">奖励组</div>
|
||
<div class="slot-list">
|
||
<div
|
||
v-for="slot in form.rewardConfigs"
|
||
:key="slot.rank"
|
||
class="reward-row"
|
||
>
|
||
<div class="reward-head">
|
||
<div class="slot-title">Top{{ slot.rank }}</div>
|
||
<Space wrap>
|
||
<Button
|
||
:disabled="editorReadonly"
|
||
type="primary"
|
||
@click="openRewardPicker(slot.rank)"
|
||
>
|
||
{{ slot.rewardGroupId ? '更换奖励组' : '选择奖励组' }}
|
||
</Button>
|
||
<Button
|
||
v-if="slot.rewardGroupId && !editorReadonly"
|
||
danger
|
||
type="link"
|
||
@click="clearRewardGroup(slot.rank)"
|
||
>
|
||
清空
|
||
</Button>
|
||
<span class="reward-name">
|
||
{{ slot.rewardGroupName || '未选择奖励组' }}
|
||
</span>
|
||
</Space>
|
||
</div>
|
||
|
||
<div class="reward-items">
|
||
<div
|
||
v-for="item in slot.items"
|
||
:key="`${slot.rank}-${item.id}`"
|
||
class="reward-item"
|
||
>
|
||
<Image
|
||
v-if="item.cover"
|
||
:preview="false"
|
||
:src="item.cover"
|
||
class="thumb"
|
||
/>
|
||
<div v-else class="thumb placeholder">R</div>
|
||
<div class="reward-item-meta">
|
||
<div class="reward-item-name">{{ item.name || item.content || item.type }}</div>
|
||
<div class="reward-item-sub">
|
||
{{ item.quantity ? `${item.quantity}天/个` : item.type || '-' }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="!slot.items?.length" class="empty-preview">
|
||
保存后会按奖励组详情回显预览
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<Space class="actions" wrap>
|
||
<Button @click="editorOpen = false">关闭</Button>
|
||
<Button v-if="!editorReadonly" :loading="saving" type="primary" @click="submitForm">
|
||
保存配置
|
||
</Button>
|
||
</Space>
|
||
</Spin>
|
||
</Modal>
|
||
|
||
<ActivityResourceGroupSelectDrawer
|
||
:open="groupPickerOpen"
|
||
:sys-origin="form.sysOrigin"
|
||
@close="groupPickerOpen = false"
|
||
@select="handleRewardGroupSelect"
|
||
/>
|
||
</Page>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.toolbar {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.editor-top {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.section {
|
||
align-items: center;
|
||
display: flex;
|
||
gap: 16px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.section.block {
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.field-label {
|
||
color: rgb(15 23 42);
|
||
flex: 0 0 120px;
|
||
font-weight: 600;
|
||
line-height: 32px;
|
||
}
|
||
|
||
.sub-text {
|
||
color: rgb(100 116 139);
|
||
}
|
||
|
||
.slot-list {
|
||
display: flex;
|
||
flex: 1;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.slot-row,
|
||
.reward-row {
|
||
border: 1px solid rgb(226 232 240);
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
}
|
||
|
||
.slot-title {
|
||
color: rgb(15 23 42);
|
||
font-weight: 600;
|
||
min-width: 68px;
|
||
}
|
||
|
||
.slot-row {
|
||
align-items: center;
|
||
display: flex;
|
||
gap: 16px;
|
||
}
|
||
|
||
.slot-preview,
|
||
.user-cell {
|
||
align-items: center;
|
||
display: flex;
|
||
gap: 12px;
|
||
}
|
||
|
||
.slot-meta,
|
||
.user-meta,
|
||
.reward-item-meta,
|
||
.period-cell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
}
|
||
|
||
.slot-name,
|
||
.reward-name,
|
||
.reward-item-name {
|
||
color: rgb(15 23 42);
|
||
}
|
||
|
||
.slot-sub,
|
||
.reward-item-sub {
|
||
color: rgb(100 116 139);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.thumb {
|
||
border-radius: 8px;
|
||
height: 48px;
|
||
object-fit: cover;
|
||
width: 48px;
|
||
}
|
||
|
||
.avatar {
|
||
background-position: center;
|
||
background-repeat: no-repeat;
|
||
background-size: cover;
|
||
border-radius: 999px;
|
||
flex: 0 0 40px;
|
||
height: 40px;
|
||
width: 40px;
|
||
}
|
||
|
||
.placeholder {
|
||
align-items: center;
|
||
background: rgb(241 245 249);
|
||
color: rgb(100 116 139);
|
||
display: flex;
|
||
font-weight: 600;
|
||
justify-content: center;
|
||
}
|
||
|
||
.reward-head {
|
||
align-items: center;
|
||
display: flex;
|
||
gap: 16px;
|
||
justify-content: space-between;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.reward-items {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
}
|
||
|
||
.reward-item {
|
||
align-items: center;
|
||
border: 1px solid rgb(226 232 240);
|
||
border-radius: 8px;
|
||
display: flex;
|
||
gap: 12px;
|
||
max-width: 320px;
|
||
min-width: 220px;
|
||
padding: 10px 12px;
|
||
}
|
||
|
||
.empty-preview {
|
||
color: rgb(148 163 184);
|
||
padding: 8px 0;
|
||
}
|
||
|
||
.actions {
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.pager {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.mt-16 {
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.error-text {
|
||
color: rgb(220 38 38);
|
||
}
|
||
|
||
.tag-stack {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
</style>
|