842 lines
25 KiB
Vue
842 lines
25 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 {
|
|
getResidentVipConfig,
|
|
pageResidentVipOrders,
|
|
pageResidentVipUserStates,
|
|
saveResidentVipConfig,
|
|
} from '#/api/legacy/resident-activity';
|
|
import { listSysOriginTypeList } from '#/api/legacy/props';
|
|
import { getAllowedSysOrigins } from '#/views/system/shared';
|
|
|
|
import {
|
|
Button,
|
|
Card,
|
|
Input,
|
|
InputNumber,
|
|
Modal,
|
|
Pagination,
|
|
Select,
|
|
Space,
|
|
Spin,
|
|
Switch,
|
|
Table,
|
|
Tag,
|
|
message,
|
|
} from 'antdv-next';
|
|
|
|
defineOptions({ name: 'ResidentVipConfigPage' });
|
|
|
|
const resourceFields = [
|
|
{ key: 'longBadge', title: '长徽章素材', type: 'BADGE', badgeDisplayTypes: ['LONG'] },
|
|
{ key: 'shortBadge', title: '短徽章素材', type: 'BADGE', badgeDisplayTypes: ['SHORT'] },
|
|
{ key: 'avatarFrame', title: '头像框', type: 'AVATAR_FRAME' },
|
|
{ key: 'entryEffect', title: '进场特效', type: 'RIDE' },
|
|
{ key: 'chatBubble', title: '聊天气泡', type: 'CHAT_BUBBLE' },
|
|
{ key: 'floatPicture', title: '飘窗', type: 'FLOAT_PICTURE' },
|
|
{ key: 'backgroundCard', title: '背景卡素材', type: 'DATA_CARD' },
|
|
{ key: 'effectImage', title: '动效图', type: 'VIP_EFFECT_IMAGE' },
|
|
] as const;
|
|
|
|
type ResourceField = (typeof resourceFields)[number];
|
|
|
|
const statusOptions = [
|
|
{ label: '生效中', value: 'ACTIVE' },
|
|
{ label: '已过期', value: 'EXPIRED' },
|
|
];
|
|
|
|
const orderStatusOptions = [
|
|
{ label: '成功', value: 'SUCCESS' },
|
|
{ label: '失败', value: 'FAILED' },
|
|
{ label: '处理中', value: 'INIT' },
|
|
];
|
|
|
|
const orderTypeOptions = [
|
|
{ label: '购买', value: 'PURCHASE' },
|
|
{ label: '续费', value: 'RENEW' },
|
|
{ label: '升级', value: 'UPGRADE' },
|
|
];
|
|
|
|
const levelColumns = [
|
|
{ dataIndex: 'level', key: 'level', title: '等级', width: 90 },
|
|
{ dataIndex: 'enabled', key: 'enabled', title: '启用', width: 90 },
|
|
{ dataIndex: 'displayName', key: 'displayName', title: '名称', width: 160 },
|
|
{ dataIndex: 'priceGold', key: 'priceGold', title: '30天价格', width: 150 },
|
|
...resourceFields.map((item) => ({
|
|
dataIndex: item.key,
|
|
key: item.key,
|
|
title: item.title,
|
|
width: 260,
|
|
})),
|
|
];
|
|
|
|
const stateColumns = [
|
|
{ dataIndex: 'userId', key: 'userId', title: '用户ID', width: 160 },
|
|
{ dataIndex: 'status', key: 'status', title: '状态', width: 100 },
|
|
{ dataIndex: 'level', key: 'level', title: '等级', width: 100 },
|
|
{ dataIndex: 'displayName', key: 'displayName', title: '名称', width: 140 },
|
|
{ dataIndex: 'resources', key: 'resources', title: '当前资源', width: 360 },
|
|
{ dataIndex: 'expireAt', key: 'expireAt', title: '到期时间', width: 180 },
|
|
{ dataIndex: 'updateTime', key: 'updateTime', title: '更新时间', width: 180 },
|
|
];
|
|
|
|
const orderColumns = [
|
|
{ dataIndex: 'userId', key: 'userId', title: '用户ID', width: 150 },
|
|
{ dataIndex: 'orderType', key: 'orderType', title: '订单类型', width: 110 },
|
|
{ dataIndex: 'levelChange', key: 'levelChange', title: '等级变化', width: 130 },
|
|
{ dataIndex: 'paidGold', key: 'paidGold', title: '支付金币', width: 120 },
|
|
{ dataIndex: 'originalPriceGold', key: 'originalPriceGold', title: '目标原价', width: 120 },
|
|
{ dataIndex: 'status', key: 'status', title: '状态', width: 100 },
|
|
{ dataIndex: 'expireAt', key: 'expireAt', title: '权益到期', width: 180 },
|
|
{ dataIndex: 'createTime', key: 'createTime', title: '创建时间', width: 180 },
|
|
{ dataIndex: 'errorMessage', key: 'errorMessage', title: '失败原因', width: 280 },
|
|
];
|
|
|
|
const accessStore = useAccessStore();
|
|
const sysOriginOptions = computed(() => {
|
|
const options = getAllowedSysOrigins(accessStore.accessCodes || []);
|
|
return options.length > 0 ? options : getAllowedSysOrigins([]);
|
|
});
|
|
|
|
const loading = ref(false);
|
|
const saving = ref(false);
|
|
const stateLoading = ref(false);
|
|
const orderLoading = ref(false);
|
|
const stateTotal = ref(0);
|
|
const orderTotal = ref(0);
|
|
const stateList = ref<Array<Record<string, any>>>([]);
|
|
const orderList = ref<Array<Record<string, any>>>([]);
|
|
const resourceRows = reactive<Record<string, Array<Record<string, any>>>>({});
|
|
const resourceLoading = reactive<Record<string, boolean>>({});
|
|
|
|
const form = reactive<Record<string, any>>({
|
|
configured: false,
|
|
levels: Array.from({ length: 5 }, (_, index) => createLevel(index + 1)),
|
|
sysOrigin: '',
|
|
updateTime: '',
|
|
});
|
|
|
|
const stateQuery = reactive<Record<string, any>>({
|
|
cursor: 1,
|
|
limit: 20,
|
|
status: '',
|
|
userId: '',
|
|
});
|
|
|
|
const orderQuery = reactive<Record<string, any>>({
|
|
cursor: 1,
|
|
limit: 20,
|
|
orderType: '',
|
|
status: '',
|
|
userId: '',
|
|
});
|
|
|
|
const statusMeta = computed(() => {
|
|
if (!form.configured) {
|
|
return {
|
|
color: 'processing',
|
|
description: '当前系统还没有保存 VIP 配置。',
|
|
text: '未配置',
|
|
};
|
|
}
|
|
const enabledCount = (form.levels || []).filter((item: Record<string, any>) =>
|
|
Boolean(item.enabled),
|
|
).length;
|
|
return {
|
|
color: enabledCount > 0 ? 'success' : 'default',
|
|
description: `已配置 5 个等级,当前启用 ${enabledCount} 个等级。`,
|
|
text: enabledCount > 0 ? '已启用' : '已关闭',
|
|
};
|
|
});
|
|
|
|
function createResource() {
|
|
return {
|
|
cover: '',
|
|
coverUrl: '',
|
|
name: '',
|
|
resourceId: '',
|
|
url: '',
|
|
};
|
|
}
|
|
|
|
function createLevel(level: number) {
|
|
return {
|
|
avatarFrame: createResource(),
|
|
backgroundCard: createResource(),
|
|
chatBubble: createResource(),
|
|
displayName: `VIP ${level}`,
|
|
durationDays: 30,
|
|
enabled: false,
|
|
entryEffect: createResource(),
|
|
effectImage: createResource(),
|
|
floatPicture: createResource(),
|
|
level,
|
|
levelCode: `VIP${level}`,
|
|
longBadge: createResource(),
|
|
priceGold: 0,
|
|
shortBadge: createResource(),
|
|
};
|
|
}
|
|
|
|
function normalizeResource(value: Record<string, any> | null | undefined) {
|
|
const resourceId = String(value?.resourceId ?? '').trim();
|
|
const url = String(value?.url || value?.sourceUrl || value?.resourceUrl || '').trim();
|
|
const cover = String(value?.cover || value?.coverUrl || value?.resourceCover || '').trim();
|
|
return {
|
|
cover,
|
|
coverUrl: cover,
|
|
name: String(value?.name || ''),
|
|
resourceId: resourceId === '0' ? '' : resourceId,
|
|
resourceUrl: url,
|
|
sourceUrl: url,
|
|
url,
|
|
};
|
|
}
|
|
|
|
function normalizePropsSourceResource(value: Record<string, any> | null | undefined) {
|
|
const resourceId = String(value?.id ?? value?.resourceId ?? '').trim();
|
|
const cover = String(
|
|
value?.cover || value?.coverUrl || value?.resourceCover || value?.selectUrl || '',
|
|
).trim();
|
|
const url = String(
|
|
value?.sourceUrl ||
|
|
value?.resourceUrl ||
|
|
value?.url ||
|
|
value?.animationUrl ||
|
|
cover ||
|
|
'',
|
|
).trim();
|
|
return {
|
|
cover,
|
|
coverUrl: cover,
|
|
name: String(value?.name || ''),
|
|
resourceId,
|
|
resourceUrl: url,
|
|
sourceUrl: url,
|
|
url,
|
|
};
|
|
}
|
|
|
|
function findResourceField(key: string) {
|
|
return resourceFields.find((item) => item.key === key);
|
|
}
|
|
|
|
function resetResourceRows() {
|
|
for (const field of resourceFields) {
|
|
resourceRows[field.key] = [];
|
|
resourceLoading[field.key] = false;
|
|
}
|
|
}
|
|
|
|
function resourceSelectValue(record: Record<string, any>, key: string) {
|
|
const resource = normalizeResource(record[key]);
|
|
return resource.resourceId || undefined;
|
|
}
|
|
|
|
function resourceSelectOptions(record: Record<string, any>, key: string) {
|
|
const list = resourceRows[key] || [];
|
|
const options = list.map((item) => {
|
|
const code = String(item.code || '').trim();
|
|
const name = String(item.name || '').trim();
|
|
const id = String(item.id || '').trim();
|
|
return {
|
|
label: [id, name, code ? `(${code})` : ''].filter(Boolean).join(' / '),
|
|
value: id as any,
|
|
};
|
|
});
|
|
const current = normalizeResource(record[key]);
|
|
if (
|
|
current.resourceId &&
|
|
!options.some((item) => String(item.value) === String(current.resourceId))
|
|
) {
|
|
options.unshift({
|
|
label: `${current.resourceId} / ${current.name || '已保存资源'}`,
|
|
value: current.resourceId as any,
|
|
});
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function resourceMeta(record: Record<string, any>, key: string) {
|
|
const resource = normalizeResource(record[key]);
|
|
if (!resource.resourceId) {
|
|
return '';
|
|
}
|
|
return [
|
|
resource.name,
|
|
resource.cover ? `封面: ${resource.cover}` : '',
|
|
resource.url ? `资源: ${resource.url}` : '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' / ');
|
|
}
|
|
|
|
function buildResourceSavePayload(value: Record<string, any> | null | undefined) {
|
|
const resource = normalizeResource(value);
|
|
return {
|
|
resourceId: resource.resourceId || '0',
|
|
};
|
|
}
|
|
|
|
async function loadResourceOptions(field: ResourceField) {
|
|
if (!form.sysOrigin || resourceLoading[field.key]) {
|
|
return;
|
|
}
|
|
resourceLoading[field.key] = true;
|
|
try {
|
|
resourceRows[field.key] = filterResourceRowsByField(
|
|
await listSysOriginTypeList(form.sysOrigin, field.type),
|
|
field,
|
|
);
|
|
} finally {
|
|
resourceLoading[field.key] = false;
|
|
}
|
|
}
|
|
|
|
function filterResourceRowsByField(rows: Array<Record<string, any>>, field: ResourceField) {
|
|
if (!('badgeDisplayTypes' in field) || !field.badgeDisplayTypes?.length) {
|
|
return rows;
|
|
}
|
|
const allowed = new Set(field.badgeDisplayTypes.map((item) => String(item).toUpperCase()));
|
|
return rows.filter((item) => allowed.has(resourceBadgeDisplayType(item)));
|
|
}
|
|
|
|
function resourceBadgeDisplayType(value: Record<string, any>) {
|
|
const expand = parseJsonObject(value.expand);
|
|
const displayType = String(expand.badgeDisplayType || expand.displayType || '').toUpperCase();
|
|
if (displayType === 'LONG' || displayType === 'SHORT') {
|
|
return displayType;
|
|
}
|
|
return String(expand.badgeType || 'ACTIVITY').toUpperCase() === 'VIP' ? 'LONG' : 'SHORT';
|
|
}
|
|
|
|
function parseJsonObject(value: unknown) {
|
|
if (typeof value !== 'string' || !value.trim()) {
|
|
return {} as Record<string, any>;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
? (parsed as Record<string, any>)
|
|
: ({} as Record<string, any>);
|
|
} catch {
|
|
return {} as Record<string, any>;
|
|
}
|
|
}
|
|
|
|
async function loadResourceOptionsByKey(key: string) {
|
|
const field = findResourceField(key);
|
|
if (field) {
|
|
await loadResourceOptions(field);
|
|
}
|
|
}
|
|
|
|
async function loadAllResourceOptions() {
|
|
await Promise.all(resourceFields.map((field) => loadResourceOptions(field)));
|
|
}
|
|
|
|
function handleResourceChange(record: Record<string, any>, key: string, value?: number | string) {
|
|
const field = findResourceField(key);
|
|
if (!field) {
|
|
return;
|
|
}
|
|
if (!value) {
|
|
record[field.key] = createResource();
|
|
return;
|
|
}
|
|
const selected = (resourceRows[field.key] || []).find(
|
|
(item) => String(item.id) === String(value),
|
|
);
|
|
record[field.key] = selected
|
|
? normalizePropsSourceResource(selected)
|
|
: {
|
|
...normalizeResource(record[field.key]),
|
|
resourceId: String(value),
|
|
};
|
|
}
|
|
|
|
function normalizeLevels(levels: Array<Record<string, any>> | undefined) {
|
|
const levelMap = new Map<number, Record<string, any>>();
|
|
for (const item of levels || []) {
|
|
const level = Number(item.level || 0);
|
|
if (level >= 1 && level <= 5) {
|
|
levelMap.set(level, item);
|
|
}
|
|
}
|
|
return Array.from({ length: 5 }, (_, index) => {
|
|
const level = index + 1;
|
|
const item = (levelMap.get(level) || createLevel(level)) as Record<string, any>;
|
|
return {
|
|
...createLevel(level),
|
|
displayName: String(item.displayName || `VIP ${level}`),
|
|
enabled: Boolean(item.enabled),
|
|
priceGold: Number(item.priceGold || 0),
|
|
longBadge: normalizeResource(item.longBadge || item['badge']),
|
|
shortBadge: normalizeResource(item.shortBadge),
|
|
avatarFrame: normalizeResource(item.avatarFrame),
|
|
entryEffect: normalizeResource(item.entryEffect),
|
|
chatBubble: normalizeResource(item.chatBubble),
|
|
floatPicture: normalizeResource(item.floatPicture),
|
|
backgroundCard: normalizeResource(item.backgroundCard),
|
|
effectImage: normalizeResource(item.effectImage),
|
|
};
|
|
});
|
|
}
|
|
|
|
function applyConfig(result: Record<string, any> | null | undefined) {
|
|
const value = result ?? {};
|
|
form.configured = Boolean(value.configured);
|
|
form.sysOrigin = String(value.sysOrigin || form.sysOrigin || sysOriginOptions.value[0]?.value || '');
|
|
form.updateTime = String(value.updateTime || '');
|
|
form.levels = normalizeLevels(value.levels);
|
|
}
|
|
|
|
function buildSavePayload() {
|
|
return {
|
|
sysOrigin: form.sysOrigin,
|
|
levels: (form.levels || []).map((item: Record<string, any>) => ({
|
|
avatarFrame: buildResourceSavePayload(item.avatarFrame),
|
|
badge: buildResourceSavePayload(item.longBadge),
|
|
backgroundCard: buildResourceSavePayload(item.backgroundCard),
|
|
chatBubble: buildResourceSavePayload(item.chatBubble),
|
|
displayName: String(item.displayName || '').trim(),
|
|
durationDays: 30,
|
|
enabled: Boolean(item.enabled),
|
|
entryEffect: buildResourceSavePayload(item.entryEffect),
|
|
effectImage: buildResourceSavePayload(item.effectImage),
|
|
floatPicture: buildResourceSavePayload(item.floatPicture),
|
|
level: Number(item.level || 0),
|
|
levelCode: `VIP${Number(item.level || 0)}`,
|
|
longBadge: buildResourceSavePayload(item.longBadge),
|
|
priceGold: Number(item.priceGold || 0),
|
|
shortBadge: buildResourceSavePayload(item.shortBadge),
|
|
})),
|
|
};
|
|
}
|
|
|
|
async function loadConfig() {
|
|
if (!form.sysOrigin) {
|
|
return;
|
|
}
|
|
loading.value = true;
|
|
try {
|
|
const result = await getResidentVipConfig(form.sysOrigin);
|
|
applyConfig(result);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function loadStates(reset = false) {
|
|
if (!form.sysOrigin) {
|
|
stateList.value = [];
|
|
stateTotal.value = 0;
|
|
return;
|
|
}
|
|
if (reset) {
|
|
stateQuery.cursor = 1;
|
|
}
|
|
stateLoading.value = true;
|
|
try {
|
|
const result = await pageResidentVipUserStates({
|
|
...stateQuery,
|
|
sysOrigin: form.sysOrigin,
|
|
});
|
|
stateList.value = Array.isArray(result.records) ? result.records : [];
|
|
stateTotal.value = Number(result.total || 0);
|
|
} finally {
|
|
stateLoading.value = false;
|
|
}
|
|
}
|
|
|
|
async function loadOrders(reset = false) {
|
|
if (!form.sysOrigin) {
|
|
orderList.value = [];
|
|
orderTotal.value = 0;
|
|
return;
|
|
}
|
|
if (reset) {
|
|
orderQuery.cursor = 1;
|
|
}
|
|
orderLoading.value = true;
|
|
try {
|
|
const result = await pageResidentVipOrders({
|
|
...orderQuery,
|
|
sysOrigin: form.sysOrigin,
|
|
});
|
|
orderList.value = Array.isArray(result.records) ? result.records : [];
|
|
orderTotal.value = Number(result.total || 0);
|
|
} finally {
|
|
orderLoading.value = false;
|
|
}
|
|
}
|
|
|
|
async function loadData() {
|
|
await Promise.all([loadConfig(), loadStates(true), loadOrders(true), loadAllResourceOptions()]);
|
|
}
|
|
|
|
async function submitForm() {
|
|
saving.value = true;
|
|
try {
|
|
await saveResidentVipConfig(buildSavePayload());
|
|
message.success('保存成功');
|
|
await loadConfig();
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
function handleSave() {
|
|
Modal.confirm({
|
|
async onOk() {
|
|
await submitForm();
|
|
},
|
|
title: '确认保存当前系统的 VIP 配置?',
|
|
content: '保存后 app 会立即按新的等级价格和资源快照售卖 VIP。',
|
|
});
|
|
}
|
|
|
|
function handleStateSearch() {
|
|
void loadStates(true);
|
|
}
|
|
|
|
function handleOrderSearch() {
|
|
void loadOrders(true);
|
|
}
|
|
|
|
function handleStatePageChange(page: number, pageSize: number) {
|
|
stateQuery.cursor = page;
|
|
stateQuery.limit = pageSize;
|
|
void loadStates();
|
|
}
|
|
|
|
function handleOrderPageChange(page: number, pageSize: number) {
|
|
orderQuery.cursor = page;
|
|
orderQuery.limit = pageSize;
|
|
void loadOrders();
|
|
}
|
|
|
|
function statusColor(status: string) {
|
|
switch (String(status || '').toUpperCase()) {
|
|
case 'ACTIVE':
|
|
case 'SUCCESS':
|
|
return 'success';
|
|
case 'FAILED':
|
|
return 'error';
|
|
case 'EXPIRED':
|
|
return 'default';
|
|
default:
|
|
return 'processing';
|
|
}
|
|
}
|
|
|
|
function orderTypeText(type: string) {
|
|
const match = orderTypeOptions.find((item) => item.value === String(type || '').toUpperCase());
|
|
return match?.label || type || '-';
|
|
}
|
|
|
|
function resourceSummary(record: Record<string, any>) {
|
|
return resourceFields
|
|
.map((field) => {
|
|
const resource = record[field.key] || {};
|
|
const text = String(resource.name || resource.resourceId || '').trim();
|
|
return text ? `${field.title}: ${text}` : '';
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
watch(
|
|
sysOriginOptions,
|
|
(options) => {
|
|
if (!form.sysOrigin) {
|
|
form.sysOrigin = String(options[0]?.value || '');
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
watch(
|
|
() => form.sysOrigin,
|
|
(value, oldValue) => {
|
|
if (value) {
|
|
if (value !== oldValue) {
|
|
resetResourceRows();
|
|
}
|
|
void loadData();
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
</script>
|
|
|
|
<template>
|
|
<Page auto-content-height>
|
|
<Spin :spinning="loading">
|
|
<Card :bordered="false" title="VIP配置">
|
|
<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>
|
|
|
|
<Table
|
|
:columns="levelColumns"
|
|
:data-source="form.levels"
|
|
:pagination="false"
|
|
row-key="level"
|
|
:scroll="{ x: 2600 }"
|
|
>
|
|
<template #bodyCell="{ column, record }">
|
|
<template v-if="column.key === 'level'">
|
|
<Tag color="gold">VIP {{ record.level }}</Tag>
|
|
</template>
|
|
<template v-else-if="column.key === 'enabled'">
|
|
<Switch v-model:checked="record.enabled" />
|
|
</template>
|
|
<template v-else-if="column.key === 'displayName'">
|
|
<Input v-model:value="record.displayName" placeholder="等级名称" />
|
|
</template>
|
|
<template v-else-if="column.key === 'priceGold'">
|
|
<InputNumber
|
|
v-model:value="record.priceGold"
|
|
:min="0"
|
|
:precision="0"
|
|
addon-after="金币"
|
|
style="width: 130px"
|
|
/>
|
|
</template>
|
|
<template v-else-if="resourceFields.some((item) => item.key === String(column.key))">
|
|
<div class="resource-editor">
|
|
<Select
|
|
:value="resourceSelectValue(record, String(column.key))"
|
|
allow-clear
|
|
:loading="resourceLoading[String(column.key)]"
|
|
:not-found-content="resourceLoading[String(column.key)] ? undefined : '暂无资源'"
|
|
option-filter-prop="label"
|
|
option-label-prop="label"
|
|
:options="resourceSelectOptions(record, String(column.key))"
|
|
placeholder="请选择资源"
|
|
show-search
|
|
style="width: 100%"
|
|
@change="(value) => handleResourceChange(record, String(column.key), value)"
|
|
@focus="loadResourceOptionsByKey(String(column.key))"
|
|
/>
|
|
<div v-if="resourceMeta(record, String(column.key))" class="resource-meta">
|
|
{{ resourceMeta(record, String(column.key)) }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</template>
|
|
</Table>
|
|
|
|
<div class="tips">
|
|
<div>固定 5 个等级,每次购买 30 天;同等级再次购买会续 30 天。</div>
|
|
<div>资源从道具资源配置选择,保存时会记录当前资源快照。</div>
|
|
<div>用户只能升级,升级时支付目标等级和当前等级的差价,到期后资源自动失效。</div>
|
|
<div>升级成功后会把长徽章、短徽章、头像框、进场特效、聊天气泡、飘窗和背景卡替换成新等级资源;动效图只用于 VIP 页面展示。</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="用户VIP状态">
|
|
<Space class="toolbar" wrap>
|
|
<Input
|
|
v-model:value="stateQuery.userId"
|
|
allow-clear
|
|
placeholder="用户ID"
|
|
style="width: 160px"
|
|
/>
|
|
<Select
|
|
v-model:value="stateQuery.status"
|
|
allow-clear
|
|
:options="statusOptions"
|
|
placeholder="状态"
|
|
style="width: 140px"
|
|
/>
|
|
<Button :loading="stateLoading" type="primary" @click="handleStateSearch">
|
|
查询
|
|
</Button>
|
|
</Space>
|
|
|
|
<Table
|
|
:columns="stateColumns"
|
|
:data-source="stateList"
|
|
:loading="stateLoading"
|
|
:pagination="false"
|
|
row-key="userId"
|
|
:scroll="{ x: 1220 }"
|
|
>
|
|
<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 === 'level'">
|
|
<Tag v-if="record.active" color="gold">VIP {{ record.level }}</Tag>
|
|
<span v-else>-</span>
|
|
</template>
|
|
<template v-else-if="column.key === 'resources'">
|
|
<div v-if="resourceSummary(record).length > 0" class="resource-summary">
|
|
<div v-for="item in resourceSummary(record)" :key="item">
|
|
{{ item }}
|
|
</div>
|
|
</div>
|
|
<span v-else>-</span>
|
|
</template>
|
|
</template>
|
|
</Table>
|
|
|
|
<div v-if="stateTotal > 0" class="pager">
|
|
<Pagination
|
|
:current="stateQuery.cursor"
|
|
:page-size="stateQuery.limit"
|
|
:total="stateTotal"
|
|
show-size-changer
|
|
@change="handleStatePageChange"
|
|
@showSizeChange="handleStatePageChange"
|
|
/>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card :bordered="false" class="record-card" title="VIP订单记录">
|
|
<Space class="toolbar" wrap>
|
|
<Input
|
|
v-model:value="orderQuery.userId"
|
|
allow-clear
|
|
placeholder="用户ID"
|
|
style="width: 160px"
|
|
/>
|
|
<Select
|
|
v-model:value="orderQuery.orderType"
|
|
allow-clear
|
|
:options="orderTypeOptions"
|
|
placeholder="订单类型"
|
|
style="width: 140px"
|
|
/>
|
|
<Select
|
|
v-model:value="orderQuery.status"
|
|
allow-clear
|
|
:options="orderStatusOptions"
|
|
placeholder="状态"
|
|
style="width: 140px"
|
|
/>
|
|
<Button :loading="orderLoading" type="primary" @click="handleOrderSearch">
|
|
查询
|
|
</Button>
|
|
</Space>
|
|
|
|
<Table
|
|
:columns="orderColumns"
|
|
:data-source="orderList"
|
|
:loading="orderLoading"
|
|
:pagination="false"
|
|
row-key="id"
|
|
:scroll="{ x: 1370 }"
|
|
>
|
|
<template #bodyCell="{ column, record }">
|
|
<template v-if="column.key === 'orderType'">
|
|
{{ orderTypeText(record.orderType) }}
|
|
</template>
|
|
<template v-else-if="column.key === 'levelChange'">
|
|
VIP {{ record.fromLevel || 0 }} → VIP {{ record.toLevel || 0 }}
|
|
</template>
|
|
<template v-else-if="column.key === 'status'">
|
|
<Tag :color="statusColor(record.status)">
|
|
{{ record.status || '-' }}
|
|
</Tag>
|
|
</template>
|
|
<template v-else-if="column.key === 'errorMessage'">
|
|
{{ record.errorMessage || '-' }}
|
|
</template>
|
|
</template>
|
|
</Table>
|
|
|
|
<div v-if="orderTotal > 0" class="pager">
|
|
<Pagination
|
|
:current="orderQuery.cursor"
|
|
:page-size="orderQuery.limit"
|
|
:total="orderTotal"
|
|
show-size-changer
|
|
@change="handleOrderPageChange"
|
|
@showSizeChange="handleOrderPageChange"
|
|
/>
|
|
</div>
|
|
</Card>
|
|
</Spin>
|
|
</Page>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.toolbar {
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.status-desc,
|
|
.field-desc {
|
|
color: rgb(100 116 139);
|
|
}
|
|
|
|
.resource-editor {
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
|
|
.resource-meta {
|
|
color: rgb(100 116 139);
|
|
font-size: 12px;
|
|
line-height: 1.4;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.resource-summary {
|
|
color: rgb(51 65 85);
|
|
line-height: 1.8;
|
|
}
|
|
|
|
.pager {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
margin-top: 16px;
|
|
}
|
|
</style>
|