495 lines
14 KiB
Vue
495 lines
14 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 {
|
|
Button,
|
|
Card,
|
|
Input,
|
|
InputNumber,
|
|
Modal,
|
|
Select,
|
|
Space,
|
|
Spin,
|
|
Switch,
|
|
Table,
|
|
Tabs,
|
|
TabPane,
|
|
Tag,
|
|
message,
|
|
} from 'antdv-next';
|
|
|
|
import {
|
|
getVoiceRoomRedPacketConfig,
|
|
pageVoiceRoomRedPacketRecords,
|
|
retryVoiceRoomRedPacketRefund,
|
|
saveVoiceRoomRedPacketConfig,
|
|
} from '#/api/legacy/resident-activity';
|
|
import { getAllowedSysOrigins } from '#/views/system/shared';
|
|
|
|
defineOptions({ name: 'ResidentVoiceRoomRedPacketPage' });
|
|
|
|
const accessStore = useAccessStore();
|
|
const sysOriginOptions = computed(() => {
|
|
const options = getAllowedSysOrigins(accessStore.accessCodes || []);
|
|
return options.length > 0 ? options : getAllowedSysOrigins([]);
|
|
});
|
|
|
|
const activeTab = ref('config');
|
|
const loading = ref(false);
|
|
const recordLoading = ref(false);
|
|
const saving = ref(false);
|
|
const selectedSysOrigin = ref('');
|
|
|
|
const form = reactive<Record<string, any>>({
|
|
amountOptionsText: '15000,30000,70000,100000',
|
|
configured: false,
|
|
countOptionsText: '5,10,20,50',
|
|
dailySendLimit: 10,
|
|
delaySeconds: 300,
|
|
enabled: false,
|
|
refundAfterSeconds: 7200,
|
|
sysOrigin: '',
|
|
timezone: 'Asia/Riyadh',
|
|
updateTime: '',
|
|
});
|
|
|
|
const recordQuery = reactive<Record<string, any>>({
|
|
cursor: 1,
|
|
limit: 20,
|
|
status: '',
|
|
userId: '',
|
|
});
|
|
|
|
const recordPage = reactive<Record<string, any>>({
|
|
records: [],
|
|
total: 0,
|
|
});
|
|
|
|
const statusOptions = [
|
|
{ label: '全部', value: '' },
|
|
{ label: '初始化', value: 'INIT' },
|
|
{ label: '生效中', value: 'ACTIVE' },
|
|
{ label: '已抢完', value: 'FINISHED' },
|
|
{ label: '已过期', value: 'EXPIRED' },
|
|
{ label: '已退款', value: 'REFUNDED' },
|
|
{ label: '退款失败', value: 'REFUND_FAILED' },
|
|
{ label: '失败', value: 'FAILED' },
|
|
];
|
|
|
|
const recordColumns = [
|
|
{ dataIndex: 'packetNo', key: 'packetNo', title: '红包号', width: 190 },
|
|
{ dataIndex: 'status', key: 'status', title: '状态', width: 110 },
|
|
{ dataIndex: 'packetMode', key: 'packetMode', title: '形式', width: 100 },
|
|
{ dataIndex: 'senderUserId', key: 'senderUserId', title: '发送人', width: 140 },
|
|
{ dataIndex: 'roomId', key: 'roomId', title: '房间ID', width: 160 },
|
|
{ dataIndex: 'amount', key: 'amount', title: '金额 / 剩余', width: 180 },
|
|
{ dataIndex: 'count', key: 'count', title: '数量 / 剩余', width: 150 },
|
|
{ dataIndex: 'refundStatus', key: 'refundStatus', title: '退款', width: 120 },
|
|
{ dataIndex: 'createTime', key: 'createTime', title: '发送时间', width: 180 },
|
|
{ dataIndex: 'actions', key: 'actions', title: '操作', width: 120 },
|
|
];
|
|
|
|
const statusMeta = computed(() => {
|
|
if (!form.configured) {
|
|
return { color: 'processing', text: '未配置' };
|
|
}
|
|
if (form.enabled) {
|
|
return { color: 'success', text: '已启用' };
|
|
}
|
|
return { color: 'default', text: '已关闭' };
|
|
});
|
|
|
|
function parseNumberList(value: string, fieldName: string) {
|
|
const values = String(value || '')
|
|
.split(',')
|
|
.map((item) => Number(String(item).trim()))
|
|
.filter((item) => Number.isFinite(item));
|
|
if (values.length === 0) {
|
|
message.warning(`请配置${fieldName}`);
|
|
return [];
|
|
}
|
|
if (values.some((item) => item <= 0 || !Number.isInteger(item))) {
|
|
message.warning(`${fieldName}必须是正整数,多个值用英文逗号分隔`);
|
|
return [];
|
|
}
|
|
return Array.from(new Set(values));
|
|
}
|
|
|
|
function listText(values: unknown, fallback: string) {
|
|
if (Array.isArray(values) && values.length > 0) {
|
|
return values.map((item) => String(item)).join(',');
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function normalizeConfig(result: null | Record<string, any> | undefined) {
|
|
const value = result || {};
|
|
form.amountOptionsText = listText(value.amountOptions, '15000,30000,70000,100000');
|
|
form.configured = Boolean(value.configured);
|
|
form.countOptionsText = listText(value.countOptions, '5,10,20,50');
|
|
form.dailySendLimit = Number(value.dailySendLimit || 10);
|
|
form.delaySeconds = Number(value.delaySeconds || 300);
|
|
form.enabled = Boolean(value.enabled);
|
|
form.refundAfterSeconds = Number(value.refundAfterSeconds || 7200);
|
|
form.sysOrigin = String(value.sysOrigin || selectedSysOrigin.value || '');
|
|
form.timezone = String(value.timezone || 'Asia/Riyadh');
|
|
form.updateTime = String(value.updateTime || '');
|
|
}
|
|
|
|
async function loadConfig() {
|
|
if (!selectedSysOrigin.value) {
|
|
return;
|
|
}
|
|
loading.value = true;
|
|
try {
|
|
const result = await getVoiceRoomRedPacketConfig(selectedSysOrigin.value);
|
|
normalizeConfig(result);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function submitForm() {
|
|
const amountOptions = parseNumberList(form.amountOptionsText, '红包金额');
|
|
const countOptions = parseNumberList(form.countOptionsText, '红包数量');
|
|
if (amountOptions.length === 0 || countOptions.length === 0) {
|
|
return;
|
|
}
|
|
if (Number(form.refundAfterSeconds || 0) <= Number(form.delaySeconds || 0)) {
|
|
message.warning('退款时间必须大于延时领取时间');
|
|
return;
|
|
}
|
|
saving.value = true;
|
|
try {
|
|
await saveVoiceRoomRedPacketConfig({
|
|
amountOptions,
|
|
countOptions,
|
|
dailySendLimit: Number(form.dailySendLimit || 10),
|
|
delaySeconds: Number(form.delaySeconds || 300),
|
|
enabled: Boolean(form.enabled),
|
|
refundAfterSeconds: Number(form.refundAfterSeconds || 7200),
|
|
sysOrigin: selectedSysOrigin.value,
|
|
timezone: String(form.timezone || '').trim() || 'Asia/Riyadh',
|
|
});
|
|
message.success('保存成功');
|
|
await loadConfig();
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
function confirmSave() {
|
|
Modal.confirm({
|
|
async onOk() {
|
|
await submitForm();
|
|
},
|
|
title: '确认保存当前系统的语音房红包配置?',
|
|
});
|
|
}
|
|
|
|
async function loadRecords(reset = false) {
|
|
if (!selectedSysOrigin.value) {
|
|
return;
|
|
}
|
|
if (reset) {
|
|
recordQuery.cursor = 1;
|
|
}
|
|
recordLoading.value = true;
|
|
try {
|
|
const result = await pageVoiceRoomRedPacketRecords({
|
|
...recordQuery,
|
|
sysOrigin: selectedSysOrigin.value,
|
|
});
|
|
recordPage.records = Array.isArray(result.records) ? result.records : [];
|
|
recordPage.total = Number(result.total || 0);
|
|
} finally {
|
|
recordLoading.value = false;
|
|
}
|
|
}
|
|
|
|
function searchRecords() {
|
|
void loadRecords(true);
|
|
}
|
|
|
|
function handleRecordTableChange(pagination: Record<string, any>) {
|
|
recordQuery.cursor = Number(pagination.current || 1);
|
|
recordQuery.limit = Number(pagination.pageSize || 20);
|
|
void loadRecords();
|
|
}
|
|
|
|
async function handleRetryRefund(record: Record<string, any>) {
|
|
const packetNo = String(record.packetNo || '');
|
|
if (!packetNo) {
|
|
return;
|
|
}
|
|
await retryVoiceRoomRedPacketRefund(packetNo);
|
|
message.success('已提交重试');
|
|
await loadRecords();
|
|
}
|
|
|
|
function statusColor(status: string) {
|
|
switch (String(status || '').toUpperCase()) {
|
|
case 'ACTIVE':
|
|
return 'success';
|
|
case 'FINISHED':
|
|
case 'REFUNDED':
|
|
return 'blue';
|
|
case 'EXPIRED':
|
|
return 'warning';
|
|
case 'FAILED':
|
|
case 'REFUND_FAILED':
|
|
return 'error';
|
|
default:
|
|
return 'default';
|
|
}
|
|
}
|
|
|
|
function refundColor(status: string) {
|
|
switch (String(status || '').toUpperCase()) {
|
|
case 'SUCCESS':
|
|
return 'success';
|
|
case 'FAILED':
|
|
return 'error';
|
|
case 'PENDING':
|
|
return 'processing';
|
|
default:
|
|
return 'default';
|
|
}
|
|
}
|
|
|
|
watch(
|
|
sysOriginOptions,
|
|
(options) => {
|
|
if (!selectedSysOrigin.value) {
|
|
selectedSysOrigin.value = String(options[0]?.value || '');
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
watch(
|
|
selectedSysOrigin,
|
|
(value) => {
|
|
if (value) {
|
|
void loadConfig();
|
|
if (activeTab.value === 'records') {
|
|
void loadRecords(true);
|
|
}
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
watch(activeTab, (value) => {
|
|
if (value === 'records') {
|
|
void loadRecords(true);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<Page title="红包">
|
|
<div class="voice-room-red-packet-page">
|
|
<Card>
|
|
<Space wrap>
|
|
<span>系统</span>
|
|
<Select
|
|
v-model:value="selectedSysOrigin"
|
|
:options="sysOriginOptions"
|
|
style="width: 180px"
|
|
/>
|
|
<Tag :color="statusMeta.color">{{ statusMeta.text }}</Tag>
|
|
<span v-if="form.updateTime" class="muted-text">
|
|
更新时间:{{ form.updateTime }}
|
|
</span>
|
|
</Space>
|
|
</Card>
|
|
|
|
<Tabs v-model:active-key="activeTab">
|
|
<TabPane key="config" tab="配置">
|
|
<Spin :spinning="loading">
|
|
<Card>
|
|
<div class="config-grid">
|
|
<label class="field-row">
|
|
<span>启用红包</span>
|
|
<Switch v-model:checked="form.enabled" />
|
|
</label>
|
|
<label class="field-row">
|
|
<span>每日发送次数</span>
|
|
<InputNumber
|
|
v-model:value="form.dailySendLimit"
|
|
:min="1"
|
|
:precision="0"
|
|
class="field-control"
|
|
/>
|
|
</label>
|
|
<label class="field-row">
|
|
<span>延时领取秒数</span>
|
|
<InputNumber
|
|
v-model:value="form.delaySeconds"
|
|
:min="0"
|
|
:precision="0"
|
|
class="field-control"
|
|
/>
|
|
</label>
|
|
<label class="field-row">
|
|
<span>退款等待秒数</span>
|
|
<InputNumber
|
|
v-model:value="form.refundAfterSeconds"
|
|
:min="1"
|
|
:precision="0"
|
|
class="field-control"
|
|
/>
|
|
</label>
|
|
<label class="field-row">
|
|
<span>活动时区</span>
|
|
<Input v-model:value="form.timezone" class="field-control" />
|
|
</label>
|
|
<label class="field-row field-wide">
|
|
<span>红包金额</span>
|
|
<Input
|
|
v-model:value="form.amountOptionsText"
|
|
class="field-control"
|
|
placeholder="15000,30000,70000,100000"
|
|
/>
|
|
</label>
|
|
<label class="field-row field-wide">
|
|
<span>红包数量</span>
|
|
<Input
|
|
v-model:value="form.countOptionsText"
|
|
class="field-control"
|
|
placeholder="5,10,20,50"
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div class="actions">
|
|
<Space>
|
|
<Button @click="loadConfig">刷新</Button>
|
|
<Button type="primary" :loading="saving" @click="confirmSave">
|
|
保存配置
|
|
</Button>
|
|
</Space>
|
|
</div>
|
|
</Card>
|
|
</Spin>
|
|
</TabPane>
|
|
|
|
<TabPane key="records" tab="发包记录">
|
|
<Card>
|
|
<Space wrap class="record-toolbar">
|
|
<Select
|
|
v-model:value="recordQuery.status"
|
|
:options="statusOptions"
|
|
style="width: 140px"
|
|
/>
|
|
<Input
|
|
v-model:value="recordQuery.userId"
|
|
allow-clear
|
|
placeholder="发送人ID"
|
|
style="width: 180px"
|
|
/>
|
|
<Button type="primary" @click="searchRecords">查询</Button>
|
|
<Button @click="loadRecords()">刷新</Button>
|
|
</Space>
|
|
|
|
<Table
|
|
:columns="recordColumns"
|
|
:data-source="recordPage.records"
|
|
:loading="recordLoading"
|
|
:pagination="{
|
|
current: recordQuery.cursor,
|
|
pageSize: recordQuery.limit,
|
|
showSizeChanger: true,
|
|
total: recordPage.total,
|
|
}"
|
|
:scroll="{ x: 1360 }"
|
|
row-key="packetNo"
|
|
@change="handleRecordTableChange"
|
|
>
|
|
<template #bodyCell="{ column, record }">
|
|
<template v-if="column.key === 'status'">
|
|
<Tag :color="statusColor(record.status)">
|
|
{{ record.status || '-' }}
|
|
</Tag>
|
|
</template>
|
|
<template v-else-if="column.key === 'amount'">
|
|
{{ record.totalAmount || 0 }} / {{ record.remainAmount || 0 }}
|
|
</template>
|
|
<template v-else-if="column.key === 'count'">
|
|
{{ record.totalCount || 0 }} / {{ record.remainCount || 0 }}
|
|
</template>
|
|
<template v-else-if="column.key === 'refundStatus'">
|
|
<Tag :color="refundColor(record.refundStatus)">
|
|
{{ record.refundStatus || 'NONE' }}
|
|
</Tag>
|
|
</template>
|
|
<template v-else-if="column.key === 'actions'">
|
|
<Button
|
|
v-if="record.refundStatus === 'FAILED'"
|
|
size="small"
|
|
@click="handleRetryRefund(record)"
|
|
>
|
|
重试退款
|
|
</Button>
|
|
</template>
|
|
</template>
|
|
</Table>
|
|
</Card>
|
|
</TabPane>
|
|
</Tabs>
|
|
</div>
|
|
</Page>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.voice-room-red-packet-page {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
.muted-text {
|
|
color: rgb(0 0 0 / 45%);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.config-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(260px, 1fr));
|
|
gap: 18px 24px;
|
|
max-width: 920px;
|
|
}
|
|
|
|
.field-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
}
|
|
|
|
.field-row > span {
|
|
flex: 0 0 120px;
|
|
color: rgb(0 0 0 / 65%);
|
|
}
|
|
|
|
.field-control {
|
|
flex: 1;
|
|
}
|
|
|
|
.field-wide {
|
|
grid-column: span 2;
|
|
}
|
|
|
|
.actions {
|
|
margin-top: 24px;
|
|
}
|
|
|
|
.record-toolbar {
|
|
margin-bottom: 16px;
|
|
}
|
|
</style>
|