1287 lines
36 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 { 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>