575 lines
16 KiB
Vue
575 lines
16 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 {
|
||
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>
|