2026-05-18 19:58:36 +08:00

1183 lines
33 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 {
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>