vip修改

This commit is contained in:
zhx 2026-05-18 19:58:36 +08:00
parent 15fbfa95de
commit 2dcd19a3ff

View File

@ -4,20 +4,14 @@ import { computed, reactive, ref, watch } from 'vue';
import { Page } from '@vben/common-ui'; import { Page } from '@vben/common-ui';
import { useAccessStore } from '@vben/stores'; 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 { import {
Button, Button,
Card, Card,
Drawer,
Empty,
Input, Input,
InputNumber, InputNumber,
message,
Modal, Modal,
Pagination, Pagination,
Select, Select,
@ -25,10 +19,21 @@ import {
Spin, Spin,
Switch, Switch,
Table, Table,
TabPane,
Tabs,
Tag, Tag,
message, Tooltip,
} from 'antdv-next'; } 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' }); defineOptions({ name: 'ResidentVipConfigPage' });
const resourceFields = [ const resourceFields = [
@ -66,20 +71,13 @@ const levelColumns = [
{ dataIndex: 'enabled', key: 'enabled', title: '启用', width: 90 }, { dataIndex: 'enabled', key: 'enabled', title: '启用', width: 90 },
{ dataIndex: 'displayName', key: 'displayName', title: '名称', width: 160 }, { dataIndex: 'displayName', key: 'displayName', title: '名称', width: 160 },
{ dataIndex: 'priceGold', key: 'priceGold', title: '30天价格', width: 150 }, { dataIndex: 'priceGold', key: 'priceGold', title: '30天价格', width: 150 },
...resourceFields.map((item) => ({ { dataIndex: 'resources', key: 'resources', title: '素材', width: 560 },
dataIndex: item.key,
key: item.key,
title: item.title,
width: 260,
})),
]; ];
const stateColumns = [ const stateColumns = [
{ dataIndex: 'userId', key: 'userId', title: '用户ID', width: 160 }, { dataIndex: 'userId', key: 'userId', title: '用户ID', width: 160 },
{ dataIndex: 'status', key: 'status', title: '状态', width: 100 }, { dataIndex: 'status', key: 'status', title: '状态', width: 100 },
{ dataIndex: 'level', key: 'level', 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: 'expireAt', key: 'expireAt', title: '到期时间', width: 180 },
{ dataIndex: 'updateTime', key: 'updateTime', title: '更新时间', width: 180 }, { dataIndex: 'updateTime', key: 'updateTime', title: '更新时间', width: 180 },
]; ];
@ -93,7 +91,7 @@ const orderColumns = [
{ dataIndex: 'status', key: 'status', title: '状态', width: 100 }, { dataIndex: 'status', key: 'status', title: '状态', width: 100 },
{ dataIndex: 'expireAt', key: 'expireAt', title: '权益到期', width: 180 }, { dataIndex: 'expireAt', key: 'expireAt', title: '权益到期', width: 180 },
{ dataIndex: 'createTime', key: 'createTime', title: '创建时间', width: 180 }, { dataIndex: 'createTime', key: 'createTime', title: '创建时间', width: 180 },
{ dataIndex: 'errorMessage', key: 'errorMessage', title: '失败原因', width: 280 }, { dataIndex: 'errorMessage', key: 'errorMessage', title: '失败原因', width: 90 },
]; ];
const accessStore = useAccessStore(); const accessStore = useAccessStore();
@ -112,6 +110,12 @@ const stateList = ref<Array<Record<string, any>>>([]);
const orderList = ref<Array<Record<string, any>>>([]); const orderList = ref<Array<Record<string, any>>>([]);
const resourceRows = reactive<Record<string, Array<Record<string, any>>>>({}); const resourceRows = reactive<Record<string, Array<Record<string, any>>>>({});
const resourceLoading = reactive<Record<string, boolean>>({}); 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>>({ const form = reactive<Record<string, any>>({
configured: false, configured: false,
@ -135,22 +139,37 @@ const orderQuery = reactive<Record<string, any>>({
userId: '', userId: '',
}); });
const statusMeta = computed(() => { const activeResourceField = computed<ResourceField>(
if (!form.configured) { () => findResourceField(activeResourceFieldKey.value) || resourceFields[0],
return { );
color: 'processing',
description: '当前系统还没有保存 VIP 配置。', const activeResourceList = computed(() => {
text: '未配置', const field = activeResourceField.value;
}; const keyword = resourceKeyword.value.trim().toLowerCase();
const rows = resourceRows[field.key] || [];
if (!keyword) {
return rows;
} }
const enabledCount = (form.levels || []).filter((item: Record<string, any>) => return rows.filter((item) => {
Boolean(item.enabled), return [
).length; resourceOptionId(item),
return { resourceOptionName(item),
color: enabledCount > 0 ? 'success' : 'default', String(item.code || ''),
description: `已配置 5 个等级,当前启用 ${enabledCount} 个等级。`, ].some((value) => value.toLowerCase().includes(keyword));
text: enabledCount > 0 ? '已启用' : '已关闭', });
}; });
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() { function createResource() {
@ -182,7 +201,7 @@ function createLevel(level: number) {
}; };
} }
function normalizeResource(value: Record<string, any> | null | undefined) { function normalizeResource(value: null | Record<string, any> | undefined) {
const resourceId = String(value?.resourceId ?? '').trim(); const resourceId = String(value?.resourceId ?? '').trim();
const url = String(value?.url || value?.sourceUrl || value?.resourceUrl || '').trim(); const url = String(value?.url || value?.sourceUrl || value?.resourceUrl || '').trim();
const cover = String(value?.cover || value?.coverUrl || value?.resourceCover || '').trim(); const cover = String(value?.cover || value?.coverUrl || value?.resourceCover || '').trim();
@ -197,7 +216,7 @@ function normalizeResource(value: Record<string, any> | null | undefined) {
}; };
} }
function normalizePropsSourceResource(value: Record<string, any> | null | undefined) { function normalizePropsSourceResource(value: null | Record<string, any> | undefined) {
const resourceId = String(value?.id ?? value?.resourceId ?? '').trim(); const resourceId = String(value?.id ?? value?.resourceId ?? '').trim();
const cover = String( const cover = String(
value?.cover || value?.coverUrl || value?.resourceCover || value?.selectUrl || '', value?.cover || value?.coverUrl || value?.resourceCover || value?.selectUrl || '',
@ -232,50 +251,7 @@ function resetResourceRows() {
} }
} }
function resourceSelectValue(record: Record<string, any>, key: string) { function buildResourceSavePayload(value: null | Record<string, any> | undefined) {
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); const resource = normalizeResource(value);
return { return {
resourceId: resource.resourceId || '0', resourceId: resource.resourceId || '0',
@ -328,37 +304,10 @@ function parseJsonObject(value: unknown) {
} }
} }
async function loadResourceOptionsByKey(key: string) {
const field = findResourceField(key);
if (field) {
await loadResourceOptions(field);
}
}
async function loadAllResourceOptions() { async function loadAllResourceOptions() {
await Promise.all(resourceFields.map((field) => loadResourceOptions(field))); 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) { function normalizeLevels(levels: Array<Record<string, any>> | undefined) {
const levelMap = new Map<number, Record<string, any>>(); const levelMap = new Map<number, Record<string, any>>();
for (const item of levels || []) { for (const item of levels || []) {
@ -375,7 +324,7 @@ function normalizeLevels(levels: Array<Record<string, any>> | undefined) {
displayName: String(item.displayName || `VIP ${level}`), displayName: String(item.displayName || `VIP ${level}`),
enabled: Boolean(item.enabled), enabled: Boolean(item.enabled),
priceGold: Number(item.priceGold || 0), priceGold: Number(item.priceGold || 0),
longBadge: normalizeResource(item.longBadge || item['badge']), longBadge: normalizeResource(item.longBadge || item.badge),
shortBadge: normalizeResource(item.shortBadge), shortBadge: normalizeResource(item.shortBadge),
avatarFrame: normalizeResource(item.avatarFrame), avatarFrame: normalizeResource(item.avatarFrame),
entryEffect: normalizeResource(item.entryEffect), entryEffect: normalizeResource(item.entryEffect),
@ -387,7 +336,7 @@ function normalizeLevels(levels: Array<Record<string, any>> | undefined) {
}); });
} }
function applyConfig(result: Record<string, any> | null | undefined) { function applyConfig(result: null | Record<string, any> | undefined) {
const value = result ?? {}; const value = result ?? {};
form.configured = Boolean(value.configured); form.configured = Boolean(value.configured);
form.sysOrigin = String(value.sysOrigin || form.sysOrigin || sysOriginOptions.value[0]?.value || ''); form.sysOrigin = String(value.sysOrigin || form.sysOrigin || sysOriginOptions.value[0]?.value || '');
@ -523,15 +472,19 @@ function handleOrderPageChange(page: number, pageSize: number) {
function statusColor(status: string) { function statusColor(status: string) {
switch (String(status || '').toUpperCase()) { switch (String(status || '').toUpperCase()) {
case 'ACTIVE': case 'ACTIVE':
case 'SUCCESS': case 'SUCCESS': {
return 'success'; return 'success';
case 'FAILED': }
return 'error'; case 'EXPIRED': {
case 'EXPIRED':
return 'default'; return 'default';
default: }
case 'FAILED': {
return 'error';
}
default: {
return 'processing'; return 'processing';
} }
}
} }
function orderTypeText(type: string) { function orderTypeText(type: string) {
@ -539,14 +492,117 @@ function orderTypeText(type: string) {
return match?.label || type || '-'; return match?.label || type || '-';
} }
function resourceSummary(record: Record<string, any>) { function errorMessageText(record: Record<string, any>) {
return String(record.errorMessage || '').trim();
}
function selectedResourceSummaries(record: Record<string, any>) {
return resourceFields return resourceFields
.map((field) => { .map((field) => {
const resource = record[field.key] || {}; const resource = normalizeResource(record[field.key]);
const text = String(resource.name || resource.resourceId || '').trim(); const name = String(resource.name || resource.resourceId || '').trim();
return text ? `${field.title}: ${text}` : ''; if (!name) {
return null;
}
return {
cover: resource.cover,
key: field.key,
name,
title: field.title,
};
}) })
.filter(Boolean); .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( watch(
@ -576,89 +632,10 @@ watch(
<template> <template>
<Page auto-content-height> <Page auto-content-height>
<Spin :spinning="loading"> <Spin :spinning="loading">
<Card :bordered="false" title="VIP配置"> <Card :bordered="false" title="VIP管理">
<Space class="toolbar" wrap> <div class="tabs-head">
<Select <Tabs v-model:active-key="activeTab" class="vip-tabs">
v-model:value="form.sysOrigin" <TabPane key="states" tab="用户VIP状态">
: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> <Space class="toolbar" wrap>
<Input <Input
v-model:value="stateQuery.userId" v-model:value="stateQuery.userId"
@ -684,7 +661,7 @@ watch(
:loading="stateLoading" :loading="stateLoading"
:pagination="false" :pagination="false"
row-key="userId" row-key="userId"
:scroll="{ x: 1220 }" :scroll="{ x: 760 }"
> >
<template #bodyCell="{ column, record }"> <template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'"> <template v-if="column.key === 'status'">
@ -696,14 +673,6 @@ watch(
<Tag v-if="record.active" color="gold">VIP {{ record.level }}</Tag> <Tag v-if="record.active" color="gold">VIP {{ record.level }}</Tag>
<span v-else>-</span> <span v-else>-</span>
</template> </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> </template>
</Table> </Table>
@ -714,12 +683,12 @@ watch(
:total="stateTotal" :total="stateTotal"
show-size-changer show-size-changer
@change="handleStatePageChange" @change="handleStatePageChange"
@showSizeChange="handleStatePageChange" @show-size-change="handleStatePageChange"
/> />
</div> </div>
</Card> </TabPane>
<Card :bordered="false" class="record-card" title="VIP订单记录"> <TabPane key="orders" tab="VIP订单记录">
<Space class="toolbar" wrap> <Space class="toolbar" wrap>
<Input <Input
v-model:value="orderQuery.userId" v-model:value="orderQuery.userId"
@ -767,7 +736,10 @@ watch(
</Tag> </Tag>
</template> </template>
<template v-else-if="column.key === 'errorMessage'"> <template v-else-if="column.key === 'errorMessage'">
{{ record.errorMessage || '-' }} <Tooltip v-if="errorMessageText(record)" :title="errorMessageText(record)">
<span class="failure-icon">!</span>
</Tooltip>
<span v-else>-</span>
</template> </template>
</template> </template>
</Table> </Table>
@ -779,10 +751,175 @@ watch(
:total="orderTotal" :total="orderTotal"
show-size-changer show-size-changer
@change="handleOrderPageChange" @change="handleOrderPageChange"
@showSizeChange="handleOrderPageChange" @show-size-change="handleOrderPageChange"
/> />
</div> </div>
</TabPane>
</Tabs>
<Button class="config-entry" @click="vipConfigDrawerOpen = true">
VIP配置
</Button>
</div>
</Card> </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> </Spin>
</Page> </Page>
</template> </template>
@ -792,25 +929,25 @@ watch(
margin-bottom: 16px; margin-bottom: 16px;
} }
.status-desc, .tabs-head {
position: relative;
}
.vip-tabs {
width: 100%;
}
.config-entry {
position: absolute;
right: 0;
top: 0;
z-index: 1;
}
.field-desc { .field-desc {
color: rgb(100 116 139); 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 { .tips {
background: rgb(248 250 252); background: rgb(248 250 252);
border-radius: 8px; border-radius: 8px;
@ -824,13 +961,217 @@ watch(
margin-top: 20px; margin-top: 20px;
} }
.record-card { .resource-cell {
margin-top: 16px; align-items: center;
display: flex;
gap: 12px;
justify-content: space-between;
} }
.resource-summary { .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); color: rgb(51 65 85);
line-height: 1.8; 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 { .pager {