1183 lines
33 KiB
Vue
1183 lines
33 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,
|
||
Drawer,
|
||
Empty,
|
||
Input,
|
||
InputNumber,
|
||
message,
|
||
Modal,
|
||
Pagination,
|
||
Select,
|
||
Space,
|
||
Spin,
|
||
Switch,
|
||
Table,
|
||
TabPane,
|
||
Tabs,
|
||
Tag,
|
||
Tooltip,
|
||
} from 'antdv-next';
|
||
|
||
import { listSysOriginTypeList } from '#/api/legacy/props';
|
||
import {
|
||
getResidentVipConfig,
|
||
pageResidentVipOrders,
|
||
pageResidentVipUserStates,
|
||
saveResidentVipConfig,
|
||
} from '#/api/legacy/resident-activity';
|
||
import { getAllowedSysOrigins } from '#/views/system/shared';
|
||
|
||
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 },
|
||
{ dataIndex: 'resources', key: 'resources', title: '素材', width: 560 },
|
||
];
|
||
|
||
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: '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: 90 },
|
||
];
|
||
|
||
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 activeTab = ref<'orders' | 'states'>('states');
|
||
const vipConfigDrawerOpen = ref(false);
|
||
const resourceDrawerOpen = ref(false);
|
||
const activeResourceLevel = ref<null | Record<string, any>>(null);
|
||
const activeResourceFieldKey = ref<ResourceField['key']>('longBadge');
|
||
const resourceKeyword = ref('');
|
||
|
||
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 activeResourceField = computed<ResourceField>(
|
||
() => findResourceField(activeResourceFieldKey.value) || resourceFields[0],
|
||
);
|
||
|
||
const activeResourceList = computed(() => {
|
||
const field = activeResourceField.value;
|
||
const keyword = resourceKeyword.value.trim().toLowerCase();
|
||
const rows = resourceRows[field.key] || [];
|
||
if (!keyword) {
|
||
return rows;
|
||
}
|
||
return rows.filter((item) => {
|
||
return [
|
||
resourceOptionId(item),
|
||
resourceOptionName(item),
|
||
String(item.code || ''),
|
||
].some((value) => value.toLowerCase().includes(keyword));
|
||
});
|
||
});
|
||
|
||
const activeResource = computed(() => {
|
||
const record = activeResourceLevel.value;
|
||
if (!record) {
|
||
return createResource();
|
||
}
|
||
return normalizeResource(record[activeResourceField.value.key]);
|
||
});
|
||
|
||
const activeResourceTitle = computed(() => {
|
||
const level = activeResourceLevel.value?.level || '-';
|
||
return `VIP ${level} 素材配置`;
|
||
});
|
||
|
||
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: null | Record<string, any> | 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: null | Record<string, any> | 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 buildResourceSavePayload(value: null | Record<string, any> | 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 loadAllResourceOptions() {
|
||
await Promise.all(resourceFields.map((field) => loadResourceOptions(field)));
|
||
}
|
||
|
||
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: null | Record<string, any> | 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 'EXPIRED': {
|
||
return 'default';
|
||
}
|
||
case 'FAILED': {
|
||
return 'error';
|
||
}
|
||
default: {
|
||
return 'processing';
|
||
}
|
||
}
|
||
}
|
||
|
||
function orderTypeText(type: string) {
|
||
const match = orderTypeOptions.find((item) => item.value === String(type || '').toUpperCase());
|
||
return match?.label || type || '-';
|
||
}
|
||
|
||
function errorMessageText(record: Record<string, any>) {
|
||
return String(record.errorMessage || '').trim();
|
||
}
|
||
|
||
function selectedResourceSummaries(record: Record<string, any>) {
|
||
return resourceFields
|
||
.map((field) => {
|
||
const resource = normalizeResource(record[field.key]);
|
||
const name = String(resource.name || resource.resourceId || '').trim();
|
||
if (!name) {
|
||
return null;
|
||
}
|
||
return {
|
||
cover: resource.cover,
|
||
key: field.key,
|
||
name,
|
||
title: field.title,
|
||
};
|
||
})
|
||
.filter(Boolean) as Array<{
|
||
cover: string;
|
||
key: string;
|
||
name: string;
|
||
title: string;
|
||
}>;
|
||
}
|
||
|
||
function selectedResourceCount(record: Record<string, any>) {
|
||
return selectedResourceSummaries(record).length;
|
||
}
|
||
|
||
function resourceFieldSelected(record: Record<string, any>, field: ResourceField) {
|
||
return Boolean(normalizeResource(record[field.key]).resourceId);
|
||
}
|
||
|
||
function resourceOptionId(item: Record<string, any>) {
|
||
return String(item.id ?? item.resourceId ?? item.sourceId ?? '').trim();
|
||
}
|
||
|
||
function resourceOptionName(item: Record<string, any>) {
|
||
const id = resourceOptionId(item);
|
||
return String(
|
||
item.name ||
|
||
item.resourceName ||
|
||
item.sourceName ||
|
||
item.badgeName ||
|
||
item.title ||
|
||
(id ? `资源 ${id}` : ''),
|
||
).trim();
|
||
}
|
||
|
||
function resourceOptionCover(item: Record<string, any>) {
|
||
return String(
|
||
item.cover ||
|
||
item.coverUrl ||
|
||
item.resourceCover ||
|
||
item.resourceCoverUrl ||
|
||
item.selectUrl ||
|
||
item.icon ||
|
||
item.sourceUrl ||
|
||
item.resourceUrl ||
|
||
item.animationUrl ||
|
||
'',
|
||
).trim();
|
||
}
|
||
|
||
function resourceOptionMeta(item: Record<string, any>) {
|
||
return [resourceOptionId(item) ? `ID ${resourceOptionId(item)}` : '', item.code || '']
|
||
.filter(Boolean)
|
||
.join(' / ');
|
||
}
|
||
|
||
function openResourceDrawer(record: Record<string, any>) {
|
||
activeResourceLevel.value = record;
|
||
activeResourceFieldKey.value =
|
||
resourceFields.find((field) => !resourceFieldSelected(record, field))?.key ||
|
||
resourceFields[0].key;
|
||
resourceKeyword.value = '';
|
||
resourceDrawerOpen.value = true;
|
||
void loadAllResourceOptions();
|
||
}
|
||
|
||
function closeResourceDrawer() {
|
||
resourceDrawerOpen.value = false;
|
||
activeResourceLevel.value = null;
|
||
}
|
||
|
||
function selectResourceField(field: ResourceField) {
|
||
activeResourceFieldKey.value = field.key;
|
||
resourceKeyword.value = '';
|
||
void loadResourceOptions(field);
|
||
}
|
||
|
||
function selectResource(item: Record<string, any>) {
|
||
const record = activeResourceLevel.value;
|
||
if (!record) {
|
||
return;
|
||
}
|
||
record[activeResourceField.value.key] = normalizePropsSourceResource(item);
|
||
}
|
||
|
||
function clearActiveResource() {
|
||
const record = activeResourceLevel.value;
|
||
if (!record) {
|
||
return;
|
||
}
|
||
record[activeResourceField.value.key] = createResource();
|
||
}
|
||
|
||
function isActiveResource(item: Record<string, any>) {
|
||
return String(activeResource.value.resourceId) === resourceOptionId(item);
|
||
}
|
||
|
||
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管理">
|
||
<div class="tabs-head">
|
||
<Tabs v-model:active-key="activeTab" class="vip-tabs">
|
||
<TabPane key="states" tab="用户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: 760 }"
|
||
>
|
||
<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>
|
||
</Table>
|
||
|
||
<div v-if="stateTotal > 0" class="pager">
|
||
<Pagination
|
||
:current="stateQuery.cursor"
|
||
:page-size="stateQuery.limit"
|
||
:total="stateTotal"
|
||
show-size-changer
|
||
@change="handleStatePageChange"
|
||
@show-size-change="handleStatePageChange"
|
||
/>
|
||
</div>
|
||
</TabPane>
|
||
|
||
<TabPane key="orders" tab="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'">
|
||
<Tooltip v-if="errorMessageText(record)" :title="errorMessageText(record)">
|
||
<span class="failure-icon">!</span>
|
||
</Tooltip>
|
||
<span v-else>-</span>
|
||
</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"
|
||
@show-size-change="handleOrderPageChange"
|
||
/>
|
||
</div>
|
||
</TabPane>
|
||
</Tabs>
|
||
|
||
<Button class="config-entry" @click="vipConfigDrawerOpen = true">
|
||
VIP配置
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
|
||
<Drawer
|
||
:open="vipConfigDrawerOpen"
|
||
destroy-on-close
|
||
title="VIP配置"
|
||
width="1180"
|
||
@close="vipConfigDrawerOpen = false"
|
||
>
|
||
<Table
|
||
:columns="levelColumns"
|
||
:data-source="form.levels"
|
||
:pagination="false"
|
||
row-key="level"
|
||
:scroll="{ x: 1050 }"
|
||
>
|
||
<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="column.key === 'resources'">
|
||
<div class="resource-cell">
|
||
<div class="resource-cell-main">
|
||
<div v-if="selectedResourceSummaries(record).length > 0" class="resource-chip-list">
|
||
<span
|
||
v-for="item in selectedResourceSummaries(record)"
|
||
:key="item.key"
|
||
class="resource-chip"
|
||
>
|
||
<img v-if="item.cover" :src="item.cover" alt="" class="resource-chip-cover" />
|
||
<span class="resource-chip-text">{{ item.title }}: {{ item.name }}</span>
|
||
</span>
|
||
</div>
|
||
<span v-else class="empty-resource">未选择素材</span>
|
||
<div class="resource-cell-count">
|
||
已选 {{ selectedResourceCount(record) }} / {{ resourceFields.length }}
|
||
</div>
|
||
</div>
|
||
<Button size="small" type="primary" @click="openResourceDrawer(record)">
|
||
配置素材
|
||
</Button>
|
||
</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>
|
||
</Drawer>
|
||
|
||
<Drawer
|
||
:open="resourceDrawerOpen"
|
||
destroy-on-close
|
||
:title="activeResourceTitle"
|
||
width="960"
|
||
@close="closeResourceDrawer"
|
||
>
|
||
<div class="resource-drawer">
|
||
<aside class="resource-field-list">
|
||
<button
|
||
v-for="field in resourceFields"
|
||
:key="field.key"
|
||
class="resource-field-item"
|
||
:class="{
|
||
'is-active': activeResourceField.key === field.key,
|
||
'is-selected': activeResourceLevel && resourceFieldSelected(activeResourceLevel, field),
|
||
}"
|
||
type="button"
|
||
@click="selectResourceField(field)"
|
||
>
|
||
<span>{{ field.title }}</span>
|
||
<Tag
|
||
v-if="activeResourceLevel && resourceFieldSelected(activeResourceLevel, field)"
|
||
color="success"
|
||
>
|
||
已选
|
||
</Tag>
|
||
</button>
|
||
</aside>
|
||
|
||
<section class="resource-picker">
|
||
<div class="resource-picker-head">
|
||
<div>
|
||
<div class="resource-picker-title">{{ activeResourceField.title }}</div>
|
||
<div class="resource-picker-subtitle">
|
||
当前:{{ activeResource.name || activeResource.resourceId || '未选择' }}
|
||
</div>
|
||
</div>
|
||
<Space>
|
||
<Input
|
||
v-model:value="resourceKeyword"
|
||
allow-clear
|
||
placeholder="资源名称 / ID"
|
||
style="width: 240px"
|
||
/>
|
||
<Button
|
||
:disabled="!activeResource.resourceId"
|
||
size="small"
|
||
@click="clearActiveResource"
|
||
>
|
||
清空
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
|
||
<Spin :spinning="resourceLoading[activeResourceField.key]">
|
||
<div v-if="activeResourceList.length > 0" class="resource-grid">
|
||
<button
|
||
v-for="item in activeResourceList"
|
||
:key="`${activeResourceField.key}-${resourceOptionId(item)}`"
|
||
class="resource-card"
|
||
:class="{ 'is-active': isActiveResource(item) }"
|
||
type="button"
|
||
@click="selectResource(item)"
|
||
>
|
||
<div class="resource-cover-wrap">
|
||
<img
|
||
v-if="resourceOptionCover(item)"
|
||
:src="resourceOptionCover(item)"
|
||
alt=""
|
||
class="resource-cover"
|
||
/>
|
||
<span v-else class="resource-cover-empty">无图</span>
|
||
</div>
|
||
<div class="resource-name">{{ resourceOptionName(item) || '-' }}</div>
|
||
<div class="resource-meta">{{ resourceOptionMeta(item) || '-' }}</div>
|
||
</button>
|
||
</div>
|
||
<Empty v-else description="暂无可选资源" />
|
||
</Spin>
|
||
</section>
|
||
</div>
|
||
</Drawer>
|
||
</Spin>
|
||
</Page>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.toolbar {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.tabs-head {
|
||
position: relative;
|
||
}
|
||
|
||
.vip-tabs {
|
||
width: 100%;
|
||
}
|
||
|
||
.config-entry {
|
||
position: absolute;
|
||
right: 0;
|
||
top: 0;
|
||
z-index: 1;
|
||
}
|
||
|
||
.field-desc {
|
||
color: rgb(100 116 139);
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.resource-cell {
|
||
align-items: center;
|
||
display: flex;
|
||
gap: 12px;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.resource-cell-main {
|
||
min-width: 0;
|
||
}
|
||
|
||
.resource-chip-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
max-height: 58px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.resource-chip {
|
||
align-items: center;
|
||
background: rgb(248 250 252);
|
||
border: 1px solid rgb(226 232 240);
|
||
border-radius: 6px;
|
||
color: rgb(51 65 85);
|
||
display: inline-flex;
|
||
gap: 5px;
|
||
max-width: 180px;
|
||
padding: 3px 6px;
|
||
}
|
||
|
||
.resource-chip-cover {
|
||
border-radius: 4px;
|
||
height: 18px;
|
||
object-fit: cover;
|
||
width: 18px;
|
||
}
|
||
|
||
.resource-chip-text {
|
||
font-size: 12px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.resource-cell-count,
|
||
.empty-resource {
|
||
color: rgb(100 116 139);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.resource-cell-count {
|
||
margin-top: 6px;
|
||
}
|
||
|
||
.failure-icon {
|
||
align-items: center;
|
||
background: rgb(254 226 226);
|
||
border: 1px solid rgb(248 113 113);
|
||
border-radius: 50%;
|
||
color: rgb(220 38 38);
|
||
cursor: help;
|
||
display: inline-flex;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
height: 20px;
|
||
justify-content: center;
|
||
width: 20px;
|
||
}
|
||
|
||
.resource-drawer {
|
||
align-items: start;
|
||
display: grid;
|
||
gap: 20px;
|
||
grid-template-columns: 180px minmax(0, 1fr);
|
||
}
|
||
|
||
.resource-field-list {
|
||
align-self: start;
|
||
border-right: 1px solid rgb(226 232 240);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
padding-right: 16px;
|
||
}
|
||
|
||
.resource-field-item {
|
||
align-items: center;
|
||
background: #fff;
|
||
border: 1px solid rgb(226 232 240);
|
||
border-radius: 8px;
|
||
color: rgb(51 65 85);
|
||
cursor: pointer;
|
||
display: flex;
|
||
flex: 0 0 auto;
|
||
justify-content: space-between;
|
||
min-height: 40px;
|
||
padding: 0 10px;
|
||
text-align: left;
|
||
}
|
||
|
||
.resource-field-item.is-active {
|
||
background: rgb(239 246 255);
|
||
border-color: rgb(37 99 235);
|
||
color: rgb(29 78 216);
|
||
}
|
||
|
||
.resource-field-item.is-selected:not(.is-active) {
|
||
border-color: rgb(34 197 94);
|
||
}
|
||
|
||
.resource-picker {
|
||
max-height: calc(100vh - 160px);
|
||
min-width: 0;
|
||
overflow-y: auto;
|
||
padding-right: 4px;
|
||
}
|
||
|
||
.resource-picker-head {
|
||
align-items: center;
|
||
display: flex;
|
||
gap: 16px;
|
||
justify-content: space-between;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.resource-picker-title {
|
||
color: rgb(15 23 42);
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.resource-picker-subtitle {
|
||
color: rgb(100 116 139);
|
||
font-size: 12px;
|
||
margin-top: 4px;
|
||
max-width: 360px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.resource-grid {
|
||
display: grid;
|
||
gap: 14px;
|
||
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
|
||
}
|
||
|
||
.resource-card {
|
||
background: #fff;
|
||
border: 1px solid rgb(226 232 240);
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
min-height: 160px;
|
||
padding: 12px;
|
||
text-align: center;
|
||
transition:
|
||
border-color 0.2s ease,
|
||
box-shadow 0.2s ease,
|
||
transform 0.2s ease;
|
||
}
|
||
|
||
.resource-card:hover,
|
||
.resource-card.is-active {
|
||
border-color: rgb(37 99 235);
|
||
box-shadow: 0 12px 24px rgb(37 99 235 / 12%);
|
||
}
|
||
|
||
.resource-card:hover {
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
.resource-cover-wrap {
|
||
align-items: center;
|
||
display: flex;
|
||
height: 88px;
|
||
justify-content: center;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.resource-cover {
|
||
border-radius: 8px;
|
||
max-height: 88px;
|
||
max-width: 104px;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.resource-cover-empty {
|
||
align-items: center;
|
||
background: rgb(241 245 249);
|
||
border-radius: 8px;
|
||
color: rgb(100 116 139);
|
||
display: inline-flex;
|
||
height: 72px;
|
||
justify-content: center;
|
||
width: 72px;
|
||
}
|
||
|
||
.resource-name {
|
||
color: rgb(15 23 42);
|
||
font-weight: 600;
|
||
line-height: 1.4;
|
||
min-height: 40px;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.resource-meta {
|
||
color: rgb(100 116 139);
|
||
font-size: 12px;
|
||
margin-top: 6px;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.pager {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-top: 16px;
|
||
}
|
||
</style>
|