yumi-admin/apps/src/views/resident-activity/sign-in-reward-config.vue
2026-04-21 20:42:21 +08:00

575 lines
16 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 {
getResidentSignInRewardConfig,
pageResidentSignInRewardRecords,
saveResidentSignInRewardConfig,
} from '#/api/legacy/resident-activity';
import ActivityResourceGroupSelectDrawer from '#/views/props/components/activity-resource-group-select-drawer.vue';
import UserProfileLink from '#/views/operate/components/user-profile-link.vue';
import { getAllowedSysOrigins } from '#/views/system/shared';
import {
Button,
Card,
DatePicker,
Input,
Modal,
Pagination,
Select,
Space,
Spin,
Switch,
Table,
Tag,
message,
} from 'antdv-next';
defineOptions({ name: 'ResidentSignInRewardConfigPage' });
function createDay(dayIndex: number) {
return {
dayIndex,
rewardGroupId: null as null | number | string,
rewardGroupName: '',
};
}
function createEmptyForm(sysOrigin = '') {
return {
configured: false,
cycleDays: 7,
enabled: false,
id: 0,
sysOrigin,
timezone: 'Asia/Riyadh',
updateTime: '',
days: Array.from({ length: 7 }, (_, index) => createDay(index + 1)),
};
}
const accessStore = useAccessStore();
const timezoneOptions = [
{ label: '沙特阿拉伯 (Asia/Riyadh)', value: 'Asia/Riyadh' },
{ label: '阿联酋 (Asia/Dubai)', value: 'Asia/Dubai' },
{ label: 'UTC', value: 'UTC' },
{ label: '中国 (Asia/Shanghai)', value: 'Asia/Shanghai' },
{ label: '巴西 (America/Sao_Paulo)', value: 'America/Sao_Paulo' },
];
const sysOriginOptions = computed(() => {
const options = getAllowedSysOrigins(accessStore.accessCodes || []);
return options.length > 0 ? options : getAllowedSysOrigins([]);
});
const loading = ref(false);
const saving = ref(false);
const recordLoading = ref(false);
const recordTotal = ref(0);
const groupPickerOpen = ref(false);
const pickerDayIndex = ref<number>(1);
const recordList = ref<Array<Record<string, any>>>([]);
const dayColumns = [
{ dataIndex: 'dayIndex', key: 'dayIndex', title: '签到日', width: 120 },
{ dataIndex: 'rewardGroup', key: 'rewardGroup', title: '奖励组', width: 420 },
];
const recordColumns = [
{ dataIndex: 'user', key: 'user', title: '签到用户', width: 280 },
{ dataIndex: 'claimDate', key: 'claimDate', title: '签到日期', width: 120 },
{ dataIndex: 'dayIndex', key: 'dayIndex', title: '循环日', width: 100 },
{ dataIndex: 'streakCount', key: 'streakCount', title: '连续天数', width: 100 },
{ dataIndex: 'rewardPayload', key: 'rewardPayload', title: '奖励内容', width: 220 },
{ dataIndex: 'status', key: 'status', title: '状态', width: 120 },
{ dataIndex: 'createTime', key: 'createTime', title: '签到时间', width: 180 },
{ dataIndex: 'errorMessage', key: 'errorMessage', title: '备注 / 失败原因', width: 260 },
];
const form = reactive<Record<string, any>>(createEmptyForm());
const recordQuery = reactive<Record<string, any>>({
claimDate: '',
cursor: 1,
limit: 20,
status: '',
userId: '',
});
const recordSummary = reactive<Record<string, any>>({
date: '',
signCount: 0,
successCount: 0,
timezone: '',
});
const statusMeta = computed(() => {
if (!form.configured) {
return {
color: 'processing',
description: '当前未配置app 调签到奖励接口时会返回不可用。',
text: '未配置',
};
}
if (form.enabled) {
return {
color: 'success',
description: '当前已启用,用户每天签到一次,按 7 天配置循环领奖。',
text: '已启用',
};
}
return {
color: 'default',
description: '当前已关闭app 可读配置但不可签到领奖。',
text: '已关闭',
};
});
function normalizeDays(days: Array<Record<string, any>> | undefined) {
const dayMap = new Map<number, Record<string, any>>();
for (const item of days || []) {
const dayIndex = Number(item.dayIndex || 0);
if (dayIndex >= 1 && dayIndex <= 7) {
dayMap.set(dayIndex, {
dayIndex,
rewardGroupId: item.rewardGroupId ?? null,
rewardGroupName: String(item.rewardGroupName || ''),
});
}
}
return Array.from({ length: 7 }, (_, index) => {
const dayIndex = index + 1;
return dayMap.get(dayIndex) || createDay(dayIndex);
});
}
function applyConfig(result: Record<string, any> | null | undefined) {
const value = result ?? {};
const next = createEmptyForm(
String(value.sysOrigin || form.sysOrigin || sysOriginOptions.value[0]?.value || ''),
);
Object.assign(form, next, {
configured: Boolean(value.configured),
cycleDays: Number(value.cycleDays || 7),
enabled: Boolean(value.enabled),
id: Number(value.id || 0),
sysOrigin: String(value.sysOrigin || next.sysOrigin || ''),
timezone: String(value.timezone || 'Asia/Riyadh'),
updateTime: String(value.updateTime || ''),
});
form.days = normalizeDays(value.days);
}
function syncRecordSummary(result: Record<string, any> | null | undefined) {
const value = result ?? {};
recordSummary.date = String(value.date || recordSummary.date || '');
recordSummary.signCount = Number(value.signCount || 0);
recordSummary.successCount = Number(value.successCount || 0);
recordSummary.timezone = String(value.timezone || recordSummary.timezone || '');
}
async function loadConfig() {
if (!form.sysOrigin) {
return;
}
loading.value = true;
try {
const result = await getResidentSignInRewardConfig(form.sysOrigin);
applyConfig(result);
} finally {
loading.value = false;
}
}
async function loadRecords(reset = false) {
if (!form.sysOrigin) {
recordList.value = [];
recordTotal.value = 0;
return;
}
if (reset) {
recordQuery.cursor = 1;
}
recordLoading.value = true;
try {
const result = await pageResidentSignInRewardRecords({
...recordQuery,
sysOrigin: form.sysOrigin,
});
recordList.value = Array.isArray(result.records) ? result.records : [];
recordTotal.value = Number(result.total || 0);
syncRecordSummary(result);
if (!recordQuery.claimDate && result.date) {
recordQuery.claimDate = String(result.date);
}
} finally {
recordLoading.value = false;
}
}
async function loadData() {
await Promise.all([loadConfig(), loadRecords(true)]);
}
watch(
sysOriginOptions,
(options) => {
if (!form.sysOrigin) {
form.sysOrigin = String(options[0]?.value || '');
}
},
{ immediate: true },
);
watch(
() => form.sysOrigin,
(value) => {
if (value) {
void loadData();
}
},
{ immediate: true },
);
function openRewardPicker(dayIndex: number) {
pickerDayIndex.value = dayIndex;
groupPickerOpen.value = true;
}
function clearRewardGroup(dayIndex: number) {
const target = form.days.find((item: Record<string, any>) => item.dayIndex === dayIndex);
if (!target) {
return;
}
target.rewardGroupId = null;
target.rewardGroupName = '';
}
function handleRewardGroupSelect(row: Record<string, any>) {
const target = form.days.find(
(item: Record<string, any>) => item.dayIndex === pickerDayIndex.value,
);
if (!target) {
return;
}
target.rewardGroupId = row.id;
target.rewardGroupName = row.name;
groupPickerOpen.value = false;
}
async function submitForm() {
saving.value = true;
try {
await saveResidentSignInRewardConfig({
enabled: Boolean(form.enabled),
sysOrigin: form.sysOrigin,
timezone: String(form.timezone || 'Asia/Riyadh').trim(),
days: (form.days || []).map((item: Record<string, any>) => ({
dayIndex: Number(item.dayIndex || 0),
rewardGroupId: item.rewardGroupId ?? null,
rewardGroupName: String(item.rewardGroupName || ''),
})),
});
message.success('保存成功');
await loadConfig();
} finally {
saving.value = false;
}
}
function handleSave() {
Modal.confirm({
async onOk() {
await submitForm();
},
title: '确认保存当前系统的签到奖励配置?',
content: '保存后 app 会立即读取新的 7 天循环签到奖励配置。',
});
}
function recordStatusColor(status: string) {
switch (String(status || '').toUpperCase()) {
case 'SUCCESS':
return 'success';
case 'FAILED':
return 'error';
default:
return 'processing';
}
}
function handleRecordSearch() {
void loadRecords(true);
}
function handleRecordPageChange(page: number, pageSize: number) {
recordQuery.cursor = page;
recordQuery.limit = pageSize;
void loadRecords();
}
</script>
<template>
<Page auto-content-height>
<Spin :spinning="loading">
<Card :bordered="false" title="签到奖励配置">
<Space class="toolbar" wrap>
<Select
v-model:value="form.sysOrigin"
:options="sysOriginOptions"
option-filter-prop="label"
option-label-prop="label"
placeholder="请选择系统"
show-search
style="width: 180px"
/>
<Tag :color="statusMeta.color">
{{ statusMeta.text }}
</Tag>
<span class="status-desc">{{ statusMeta.description }}</span>
</Space>
<div class="section">
<div class="field-label">启用签到奖励</div>
<Switch v-model:checked="form.enabled" />
</div>
<div class="section">
<div class="field-label">签到时区</div>
<Select
v-model:value="form.timezone"
:disabled="saving"
:options="timezoneOptions"
option-filter-prop="label"
option-label-prop="label"
placeholder="请选择时区"
show-search
style="width: 260px"
/>
<span class="field-desc">默认沙特阿拉伯时区按所选时区切自然日</span>
</div>
<div class="section">
<div class="field-label">循环规则</div>
<span class="field-desc">固定 7 天循环用户连续签到满 7 天后从第 1 天奖励重新开始</span>
</div>
<Table
:columns="dayColumns"
:data-source="form.days"
:pagination="false"
row-key="dayIndex"
:scroll="{ x: 760 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dayIndex'">
{{ record.dayIndex }}
</template>
<template v-else-if="column.key === 'rewardGroup'">
<Space wrap>
<Button :disabled="!form.enabled" @click="openRewardPicker(record.dayIndex)">
{{ record.rewardGroupId ? '更换奖励组' : '选择奖励组' }}
</Button>
<Button
v-if="record.rewardGroupId"
:disabled="!form.enabled"
danger
type="link"
@click="clearRewardGroup(record.dayIndex)"
>
清空
</Button>
<span v-if="record.rewardGroupId" class="reward-group">
{{ record.rewardGroupName || '未命名奖励组' }} (ID: {{ record.rewardGroupId }})
</span>
<span v-else class="reward-group empty">未选择奖励组</span>
</Space>
</template>
</template>
</Table>
<div class="tips">
<div>启用后 7 天都必须配置奖励组</div>
<div>签到状态按用户连续签到计算中断一天后下次从第 1 天开始</div>
<div>奖励组明细由 app 侧接口实时读取后台这里保存的是奖励组引用</div>
</div>
<Space class="actions" wrap>
<Button :loading="saving" type="primary" @click="handleSave">
保存配置
</Button>
<span v-if="form.updateTime" class="field-desc">最后更新时间{{ form.updateTime }}</span>
</Space>
</Card>
<Card :bordered="false" class="record-card" title="签到记录">
<div class="toolbar">
<Space wrap>
<DatePicker
v-model:value="recordQuery.claimDate"
placeholder="签到日期"
style="width: 160px"
value-format="YYYY-MM-DD"
/>
<Input
v-model:value="recordQuery.userId"
allow-clear
placeholder="用户ID"
style="width: 160px"
/>
<Select
v-model:value="recordQuery.status"
allow-clear
:options="[
{ label: '成功', value: 'SUCCESS' },
{ label: '失败', value: 'FAILED' },
{ label: '处理中', value: 'PENDING' },
]"
placeholder="状态"
style="width: 140px"
/>
<Button :loading="recordLoading" type="primary" @click="handleRecordSearch">
查询
</Button>
</Space>
</div>
<div class="summary">
<Tag color="processing">
统计日期{{ recordSummary.date || '-' }}
</Tag>
<Tag color="purple">
时区{{ recordSummary.timezone || '-' }}
</Tag>
<Tag color="orange">
当日签到数{{ recordSummary.signCount }}
</Tag>
<Tag color="success">
发奖成功数{{ recordSummary.successCount }}
</Tag>
</div>
<Table
:columns="recordColumns"
:data-source="recordList"
:loading="recordLoading"
:pagination="false"
row-key="id"
:scroll="{ x: 1400 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'user'">
<UserProfileLink
:profile="{
account: record.account,
countryCode: record.countryCode,
countryName: record.countryName,
id: record.userId,
userAvatar: record.userAvatar,
userNickname: record.userNickname,
}"
/>
</template>
<template v-else-if="column.key === 'dayIndex'">
{{ record.dayIndex || 0 }}
</template>
<template v-else-if="column.key === 'rewardPayload'">
<div>奖励组{{ record.rewardGroupName || record.rewardGroupId || '-' }}</div>
</template>
<template v-else-if="column.key === 'status'">
<Tag :color="recordStatusColor(record.status)">
{{ record.status || '-' }}
</Tag>
</template>
<template v-else-if="column.key === 'errorMessage'">
{{ record.errorMessage || '-' }}
</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>
<ActivityResourceGroupSelectDrawer
:open="groupPickerOpen"
:sys-origin="form.sysOrigin"
@close="groupPickerOpen = false"
@select="handleRewardGroupSelect"
/>
</Spin>
</Page>
</template>
<style scoped>
.toolbar {
margin-bottom: 16px;
}
.status-desc {
color: rgb(100 116 139);
}
.section {
align-items: center;
display: flex;
gap: 16px;
margin-bottom: 20px;
}
.field-label {
color: rgb(15 23 42);
flex: 0 0 120px;
font-weight: 600;
}
.field-desc {
color: rgb(100 116 139);
}
.reward-group {
color: rgb(15 23 42);
}
.reward-group.empty {
color: rgb(148 163 184);
}
.tips {
background: rgb(248 250 252);
border-radius: 8px;
color: rgb(71 85 105);
line-height: 1.8;
margin-top: 16px;
padding: 16px;
}
.actions {
margin-top: 20px;
}
.record-card {
margin-top: 16px;
}
.summary {
margin-bottom: 16px;
}
.pager {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>