fix: use drawer resource picker for rocket config

This commit is contained in:
zhx 2026-05-15 10:46:18 +08:00
parent 20c00c14d5
commit 5bbb158f57
2 changed files with 661 additions and 200 deletions

View File

@ -0,0 +1,442 @@
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue';
import { listBadgePictureBySysOrigin } from '#/api/legacy/badge';
import { listGiftBySysOrigin } from '#/api/legacy/gift';
import {
listNotFamilyBySysOriginType,
pagePropsSource,
} from '#/api/legacy/props';
import {
Button,
Drawer,
Empty,
Input,
Pagination,
Select,
Space,
Spin,
} from 'antdv-next';
defineOptions({ name: 'ResidentActivityResourceSelectDrawer' });
type ResourceItem = {
amount?: number | string;
cover: string;
id: number | string;
name: string;
sourceUrl: string;
type: string;
};
const props = withDefaults(
defineProps<{
defaultType?: string;
fixedType?: boolean;
open: boolean;
sysOrigin: string;
title?: string;
}>(),
{
defaultType: 'ALL_PROPS',
fixedType: false,
title: '选择资源',
},
);
const emit = defineEmits<{
close: [];
select: [item: ResourceItem];
}>();
const typeOptions = [
{ label: '全部资源', value: 'ALL_PROPS' },
{ label: '自定义', value: 'CUSTOMIZE' },
{ label: '头像框', value: 'AVATAR_FRAME' },
{ label: '座驾', value: 'RIDE' },
{ label: '聊天气泡', value: 'CHAT_BUBBLE' },
{ label: '贵族 VIP', value: 'NOBLE_VIP' },
{ label: '主题背景', value: 'THEME' },
{ label: '飘窗', value: 'FLOAT_PICTURE' },
{ label: '礼物', value: 'GIFT' },
{ label: '徽章', value: 'BADGE' },
].map((item) => ({ ...item, value: item.value as any }));
const listOnlyTypes = new Set(['BADGE', 'GIFT', 'NOBLE_VIP']);
const loading = ref(false);
const list = ref<ResourceItem[]>([]);
const total = ref(0);
const query = reactive({
cursor: 1,
keyword: '',
limit: 24,
type: 'ALL_PROPS',
});
const localRows = ref<ResourceItem[]>([]);
const filteredLocalRows = computed(() => {
const keyword = query.keyword.trim().toLowerCase();
if (!keyword) {
return localRows.value;
}
return localRows.value.filter(
(item) =>
String(item.id).toLowerCase().includes(keyword) ||
item.name.toLowerCase().includes(keyword),
);
});
function firstPresent(record: Record<string, any>, keys: string[]) {
for (const key of keys) {
const value = record?.[key];
if (value !== undefined && value !== null && String(value).trim() !== '') {
return value;
}
}
return '';
}
function normalizeResource(
item: Record<string, any>,
type: string,
options: {
coverKeys?: string[];
idKeys?: string[];
nameKeys?: string[];
sourceUrlKeys?: string[];
} = {},
): ResourceItem {
const id = firstPresent(
item,
options.idKeys || ['id', 'resourceId', 'sourceId', 'propsId'],
);
return {
amount: firstPresent(item, ['amount', 'giftCandy', 'price']),
cover: String(
firstPresent(
item,
options.coverKeys || [
'cover',
'coverUrl',
'resourceCover',
'resourceCoverUrl',
'giftPhoto',
'selectUrl',
'icon',
],
),
),
id: id as number | string,
name: String(
firstPresent(
item,
options.nameKeys || [
'name',
'resourceName',
'sourceName',
'giftName',
'badgeName',
'title',
'remark',
],
) || (id ? `资源 ${id}` : ''),
),
sourceUrl: String(
firstPresent(
item,
options.sourceUrlKeys || [
'sourceUrl',
'resourceUrl',
'url',
'giftSourceUrl',
'animationUrl',
],
),
),
type,
};
}
function applyLocalPage() {
const rows = filteredLocalRows.value;
total.value = rows.length;
const start = (query.cursor - 1) * query.limit;
list.value = rows.slice(start, start + query.limit);
}
async function loadListType(type: string) {
if (type === 'GIFT') {
const result = await listGiftBySysOrigin(props.sysOrigin);
localRows.value = (result || []).map((item) =>
normalizeResource(item, type, {
coverKeys: ['giftPhoto', 'cover'],
idKeys: ['id', 'giftId'],
nameKeys: ['giftName', 'name'],
sourceUrlKeys: ['giftSourceUrl', 'sourceUrl'],
}),
);
applyLocalPage();
return;
}
if (type === 'BADGE') {
const result = await listBadgePictureBySysOrigin(props.sysOrigin, 'ACTIVITY');
localRows.value = (result || []).map((item) =>
normalizeResource(item, type, {
coverKeys: ['selectUrl', 'cover'],
idKeys: ['badgeConfigId', 'id'],
nameKeys: ['badgeName', 'name'],
sourceUrlKeys: ['animationUrl', 'sourceUrl'],
}),
);
applyLocalPage();
return;
}
if (type === 'NOBLE_VIP') {
const result = await listNotFamilyBySysOriginType(props.sysOrigin, type);
localRows.value = (result || []).map((item) =>
normalizeResource(item, type),
);
applyLocalPage();
}
}
async function loadData(reset = false) {
if (!props.open || !props.sysOrigin) {
return;
}
if (reset) {
query.cursor = 1;
}
loading.value = true;
try {
if (listOnlyTypes.has(query.type)) {
await loadListType(query.type);
return;
}
const keyword = query.keyword.trim();
const params: Record<string, any> = {
cursor: query.cursor,
del: false,
limit: query.limit,
sysOrigin: props.sysOrigin,
};
if (query.type && query.type !== 'ALL_PROPS') {
params.type = query.type;
}
if (keyword) {
if (/^\d+$/.test(keyword)) {
params.id = keyword;
} else {
params.name = keyword;
}
}
const result = await pagePropsSource(params);
list.value = (result.records || []).map((item) =>
normalizeResource(item, String(item.type || query.type || '')),
);
total.value = result.total || 0;
} finally {
loading.value = false;
}
}
function handleTypeChange(value: string) {
query.type = value || 'ALL_PROPS';
query.keyword = '';
localRows.value = [];
void loadData(true);
}
function handleSearch() {
void loadData(true);
}
function handlePageChange(page: number, pageSize: number) {
query.cursor = page;
query.limit = pageSize;
if (listOnlyTypes.has(query.type)) {
applyLocalPage();
return;
}
void loadData();
}
function handleSelect(item: ResourceItem) {
emit('select', item);
}
watch(
() => props.open,
(open) => {
if (!open) {
return;
}
query.type = props.defaultType || 'ALL_PROPS';
query.keyword = '';
query.cursor = 1;
localRows.value = [];
void loadData(true);
},
{ immediate: true },
);
watch(
() => props.defaultType,
(value) => {
if (!props.open || !value) {
return;
}
query.type = value;
query.keyword = '';
localRows.value = [];
void loadData(true);
},
);
watch(
() => props.sysOrigin,
() => {
localRows.value = [];
if (props.open) {
void loadData(true);
}
},
);
</script>
<template>
<Drawer
:open="open"
destroy-on-close
:title="title"
width="920"
@close="emit('close')"
>
<Space class="toolbar" wrap>
<Select
v-if="!fixedType"
v-model:value="query.type"
:options="typeOptions"
option-label-prop="label"
style="width: 180px"
@change="handleTypeChange"
/>
<Input
v-model:value="query.keyword"
allow-clear
placeholder="资源名称 / ID"
style="width: 260px"
@press-enter="handleSearch"
/>
<Button :loading="loading" type="primary" @click="handleSearch">
搜索
</Button>
</Space>
<Spin :spinning="loading">
<div v-if="list.length > 0" class="resource-grid">
<button
v-for="item in list"
:key="`${item.type}-${item.id}`"
class="resource-card"
type="button"
@click="handleSelect(item)"
>
<div class="resource-cover-wrap">
<img
:src="item.cover || 'https://dummyimage.com/88x88/e2e8f0/64748b&text=+'"
alt=""
class="resource-cover"
/>
</div>
<div class="resource-name">{{ item.name || '-' }}</div>
<div class="resource-meta">ID {{ item.id }}</div>
<div class="resource-type">{{ item.type || '-' }}</div>
</button>
</div>
<Empty v-else description="暂无可选资源" />
</Spin>
<div class="pager">
<Pagination
:current="query.cursor"
:page-size="query.limit"
:total="total"
show-size-changer
@change="handlePageChange"
@showSizeChange="handlePageChange"
/>
</div>
</Drawer>
</template>
<style scoped>
.toolbar {
margin-bottom: 16px;
}
.resource-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
}
.resource-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
cursor: pointer;
padding: 12px;
text-align: center;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
}
.resource-card:hover {
border-color: #2563eb;
box-shadow: 0 12px 24px rgb(37 99 235 / 12%);
transform: translateY(-2px);
}
.resource-cover-wrap {
align-items: center;
display: flex;
height: 88px;
justify-content: center;
margin-bottom: 10px;
}
.resource-cover {
border-radius: 8px;
height: 88px;
object-fit: cover;
width: 88px;
}
.resource-name {
font-weight: 600;
line-height: 1.4;
min-height: 40px;
word-break: break-all;
}
.resource-meta,
.resource-type {
color: #64748b;
font-size: 12px;
margin-top: 6px;
word-break: break-all;
}
.pager {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>

View File

@ -29,14 +29,10 @@ import {
saveVoiceRoomRocketLevelConfigs,
saveVoiceRoomRocketRewardConfigs,
} from '#/api/legacy/resident-activity';
import { listBadgePictureBySysOrigin } from '#/api/legacy/badge';
import { listGiftBySysOrigin } from '#/api/legacy/gift';
import {
listNotFamilyBySysOriginType,
listSysOriginTypeList,
} from '#/api/legacy/props';
import { getAllowedSysOrigins } from '#/views/system/shared';
import ResourceSelectDrawer from './components/resource-select-drawer.vue';
defineOptions({ name: 'ResidentVoiceRoomRocketPage' });
const RIYADH_TIMEZONE = 'Asia/Riyadh';
@ -95,12 +91,6 @@ const rewardLoading = ref(false);
const configSaving = ref(false);
const levelSaving = ref(false);
const rewardSaving = ref(false);
const rocketResourceLoading = ref(false);
const rocketResourceRows = ref<ResourceOption[]>([]);
const rewardResourceLoading = reactive<Record<string, boolean>>({});
const rewardResourceRows = reactive<Record<string, ResourceOption[]>>({});
const ROCKET_RESOURCE_TYPE = 'CUSTOMIZE';
const configForm = reactive({
broadcastDurationSeconds: 7,
@ -120,6 +110,23 @@ const configForm = reactive({
const levelRows = ref<LevelRow[]>([]);
const rewardRows = ref<RewardRow[]>([]);
const resourcePickerOpen = ref(false);
const resourcePickerTitle = ref('选择资源');
const resourcePickerDefaultType = ref('ALL_PROPS');
const resourcePickerFixedType = ref(false);
const resourcePickerTarget = ref<
| {
field: 'rocketAnimationUrl' | 'rocketIconUrl';
kind: 'level';
row: Record<string, any>;
}
| {
kind: 'reward';
row: Record<string, any>;
}
| null
>(null);
const sceneOptions = [
{ label: '贡献第一', value: 'TOP1' },
{ label: '点火人', value: 'IGNITE' },
@ -303,93 +310,6 @@ function normalizeRewardRows(rows: Array<Record<string, any>> | undefined) {
}));
}
function normalizePropsResource(item: Record<string, any>, type: string): ResourceOption {
return {
amount: item.amount,
cover: String(item.cover || item.img || item.icon || ''),
id: item.id,
name: String(item.name || item.title || item.remark || item.id || ''),
remark: item.remark,
sourceUrl: String(item.sourceUrl || item.url || item.cover || ''),
type,
};
}
function normalizeGiftResource(item: Record<string, any>): ResourceOption {
return {
amount: item.giftCandy,
cover: String(item.giftPhoto || ''),
id: item.id,
name: String(item.giftName || item.id || ''),
sourceUrl: String(item.giftSourceUrl || item.giftPhoto || ''),
type: 'GIFT',
};
}
function normalizeBadgeResource(item: Record<string, any>): ResourceOption {
return {
cover: String(item.selectUrl || item.cover || ''),
id: item.badgeConfigId || item.id,
name: String(item.badgeName || item.name || item.id || ''),
sourceUrl: String(item.animationUrl || item.selectUrl || item.cover || ''),
type: 'BADGE',
};
}
function optionLabel(item: ResourceOption) {
return `${item.id} / ${item.name}`.trim();
}
function toResourceSelectOptions(rows: ResourceOption[]) {
return rows
.filter((item) => item.id !== undefined && item.id !== null && item.id !== '')
.map((item) => ({
label: optionLabel(item),
value: String(item.id),
}));
}
function toRocketResourceSelectOptions(
record: Record<string, any>,
key: 'rocketAnimationUrl' | 'rocketIconUrl',
) {
const options = rocketResourceRows.value
.map((item) => {
const value =
key === 'rocketIconUrl'
? String(item.cover || item.sourceUrl || '')
: String(item.sourceUrl || item.cover || '');
return {
label: optionLabel(item),
value,
};
})
.filter((item) => item.value);
const current = String(record[key] || '');
if (current && !options.some((item) => item.value === current)) {
options.unshift({ label: current, value: current });
}
return options;
}
async function loadRocketResources() {
if (!selectedSysOrigin.value || rocketResourceLoading.value) {
return;
}
rocketResourceLoading.value = true;
try {
const result = await listSysOriginTypeList(
selectedSysOrigin.value,
ROCKET_RESOURCE_TYPE,
);
rocketResourceRows.value = (result || []).map((item) =>
normalizePropsResource(item, ROCKET_RESOURCE_TYPE),
);
} finally {
rocketResourceLoading.value = false;
}
}
function rewardResourceType(rewardType: string) {
const value = String(rewardType || '').trim();
const mapping: Record<string, string> = {
@ -403,57 +323,65 @@ function rewardResourceType(rewardType: string) {
return mapping[value] || '';
}
function rewardTypeText(rewardType: string) {
return (
rewardTypeOptions.find((item) => item.value === rewardType)?.label ||
rewardType ||
'资源'
);
}
function isRewardResourceDisabled(row: Record<string, any>) {
return row.rewardType === 'GOLD' || row.rewardType === 'NONE';
}
async function loadRewardResources(rewardType: string) {
const type = String(rewardType || '').trim();
const sourceType = rewardResourceType(type);
if (
!selectedSysOrigin.value ||
!sourceType ||
(rewardResourceRows[type] || []).length > 0
) {
return;
}
rewardResourceLoading[type] = true;
try {
if (type === 'GIFT') {
const result = await listGiftBySysOrigin(selectedSysOrigin.value);
rewardResourceRows[type] = (result || []).map(normalizeGiftResource);
return;
}
if (type === 'BADGE') {
const result = await listBadgePictureBySysOrigin(
selectedSysOrigin.value,
'ACTIVITY',
);
rewardResourceRows[type] = (result || []).map(normalizeBadgeResource);
return;
}
const loader =
type === 'VIP_CARD' ? listNotFamilyBySysOriginType : listSysOriginTypeList;
const result = await loader(selectedSysOrigin.value, sourceType);
rewardResourceRows[type] = (result || []).map((item) =>
normalizePropsResource(item, sourceType),
);
} finally {
rewardResourceLoading[type] = false;
}
function openLevelResourcePicker(
row: Record<string, any>,
field: 'rocketAnimationUrl' | 'rocketIconUrl',
) {
resourcePickerTarget.value = {
field,
kind: 'level',
row,
};
resourcePickerTitle.value =
field === 'rocketIconUrl' ? '选择火箭图标资源' : '选择发射动画资源';
resourcePickerDefaultType.value = 'ALL_PROPS';
resourcePickerFixedType.value = false;
resourcePickerOpen.value = true;
}
function rewardResourceOptions(row: Record<string, any>) {
const rows = rewardResourceRows[row.rewardType] || [];
const options = toResourceSelectOptions(rows);
const current = String(row.rewardItemId || '').trim();
if (current && !options.some((item) => item.value === current)) {
options.unshift({
label: `${current} / ${row.rewardName || '已配置资源'}`,
value: current,
});
function openRewardResourcePicker(row: Record<string, any>) {
if (isRewardResourceDisabled(row)) {
return;
}
const sourceType = rewardResourceType(row.rewardType);
if (!sourceType) {
return;
}
resourcePickerTarget.value = {
kind: 'reward',
row,
};
resourcePickerTitle.value = `选择${rewardTypeText(row.rewardType)}资源`;
resourcePickerDefaultType.value = sourceType;
resourcePickerFixedType.value = true;
resourcePickerOpen.value = true;
}
function clearLevelResource(
row: Record<string, any>,
field: 'rocketAnimationUrl' | 'rocketIconUrl',
) {
row[field] = '';
}
function clearRewardResource(row: Record<string, any>) {
row.rewardItemId = null;
row.rewardCover = '';
if (row.rewardName !== '金币' && row.rewardName !== '轮空') {
row.rewardName = '';
}
return options;
}
function handleRewardSceneChange(row: Record<string, any>) {
@ -463,21 +391,32 @@ function handleRewardSceneChange(row: Record<string, any>) {
}
}
function handleRewardResourceChange(
row: Record<string, any>,
value?: null | number | string,
) {
const selected =
(rewardResourceRows[row.rewardType] || []).find(
(item) => String(item.id) === String(value),
) || null;
if (!selected) {
row.rewardItemId = value || null;
function handleResourceSelect(item: ResourceOption) {
const target = resourcePickerTarget.value;
if (!target) {
return;
}
row.rewardItemId = selected.id;
row.rewardName = selected.name;
row.rewardCover = selected.cover || selected.sourceUrl || '';
if (target.kind === 'level') {
const value =
target.field === 'rocketIconUrl'
? String(item.cover || item.sourceUrl || '')
: String(item.sourceUrl || item.cover || '');
target.row[target.field] = value;
} else {
target.row.rewardItemId = item.id;
target.row.rewardName = item.name || String(item.id || '');
target.row.rewardCover = item.cover || item.sourceUrl || '';
}
resourcePickerOpen.value = false;
resourcePickerTarget.value = null;
}
function resourceUrlName(url: string) {
const text = String(url || '').trim();
if (!text) {
return '未选择';
}
return text;
}
async function loadConfig() {
@ -523,7 +462,7 @@ async function loadRewards() {
}
async function loadAll() {
await Promise.all([loadConfig(), loadLevels(), loadRewards(), loadRocketResources()]);
await Promise.all([loadConfig(), loadLevels(), loadRewards()]);
}
async function saveConfig() {
@ -630,7 +569,6 @@ function handleRewardTypeChange(row: Record<string, any>) {
if (row.rewardName === '金币' || row.rewardName === '轮空') {
row.rewardName = '';
}
void loadRewardResources(row.rewardType);
}
function validateRewards() {
@ -714,10 +652,6 @@ watch(
watch(
selectedSysOrigin,
() => {
rocketResourceRows.value = [];
Object.keys(rewardResourceRows).forEach((key) => {
delete rewardResourceRows[key];
});
void loadAll();
},
{ immediate: true },
@ -865,30 +799,62 @@ watch(rewardLevel, () => {
/>
</template>
<template v-else-if="column.key === 'rocketIconUrl'">
<Select
v-model:value="record.rocketIconUrl"
:loading="rocketResourceLoading"
:options="toRocketResourceSelectOptions(record, 'rocketIconUrl')"
allow-clear
class="table-resource-select"
option-filter-prop="label"
show-search
@focus="loadRocketResources"
/>
<div class="resource-cell">
<div class="resource-cell__preview">
<img
v-if="record.rocketIconUrl"
:src="record.rocketIconUrl"
alt=""
/>
<span>{{ resourceUrlName(record.rocketIconUrl) }}</span>
</div>
<Space>
<Button
size="small"
type="primary"
@click="openLevelResourcePicker(record, 'rocketIconUrl')"
>
{{ record.rocketIconUrl ? '更换资源' : '选择资源' }}
</Button>
<Button
v-if="record.rocketIconUrl"
size="small"
@click="clearLevelResource(record, 'rocketIconUrl')"
>
清除
</Button>
</Space>
</div>
</template>
<template v-else-if="column.key === 'rocketAnimationUrl'">
<Select
v-model:value="record.rocketAnimationUrl"
:loading="rocketResourceLoading"
:options="
toRocketResourceSelectOptions(record, 'rocketAnimationUrl')
"
allow-clear
class="table-resource-select"
option-filter-prop="label"
show-search
@focus="loadRocketResources"
/>
<div class="resource-cell">
<div class="resource-cell__preview">
<img
v-if="record.rocketAnimationUrl"
:src="record.rocketAnimationUrl"
alt=""
/>
<span>{{ resourceUrlName(record.rocketAnimationUrl) }}</span>
</div>
<Space>
<Button
size="small"
type="primary"
@click="
openLevelResourcePicker(record, 'rocketAnimationUrl')
"
>
{{ record.rocketAnimationUrl ? '更换资源' : '选择资源' }}
</Button>
<Button
v-if="record.rocketAnimationUrl"
size="small"
@click="clearLevelResource(record, 'rocketAnimationUrl')"
>
清除
</Button>
</Space>
</div>
</template>
<template v-else-if="column.key === 'shakeThresholdPercent'">
<Input
@ -966,18 +932,32 @@ watch(rewardLevel, () => {
/>
</template>
<template v-else-if="column.key === 'rewardItemId'">
<Select
v-model:value="record.rewardItemId"
:disabled="isRewardResourceDisabled(record)"
:loading="rewardResourceLoading[record.rewardType]"
:options="rewardResourceOptions(record)"
allow-clear
class="table-resource-select"
option-filter-prop="label"
show-search
@change="(value) => handleRewardResourceChange(record, value)"
@focus="() => loadRewardResources(record.rewardType)"
/>
<div class="resource-cell">
<div class="resource-cell__summary">
<span v-if="isRewardResourceDisabled(record)">-</span>
<span v-else-if="record.rewardItemId">
{{ record.rewardName || '已选资源' }} / ID:
{{ record.rewardItemId }}
</span>
<span v-else>未选择</span>
</div>
<Space v-if="!isRewardResourceDisabled(record)">
<Button
size="small"
type="primary"
@click="openRewardResourcePicker(record)"
>
{{ record.rewardItemId ? '更换资源' : '选择资源' }}
</Button>
<Button
v-if="record.rewardItemId"
size="small"
@click="clearRewardResource(record)"
>
清除
</Button>
</Space>
</div>
</template>
<template v-else-if="column.key === 'rewardName'">
<Input
@ -1058,6 +1038,16 @@ watch(rewardLevel, () => {
</Card>
</TabPane>
</Tabs>
<ResourceSelectDrawer
:default-type="resourcePickerDefaultType"
:fixed-type="resourcePickerFixedType"
:open="resourcePickerOpen"
:sys-origin="selectedSysOrigin"
:title="resourcePickerTitle"
@close="resourcePickerOpen = false"
@select="handleResourceSelect"
/>
</div>
</Page>
</template>
@ -1121,14 +1111,43 @@ watch(rewardLevel, () => {
width: 140px;
}
.table-resource-select {
width: 220px;
}
.table-number {
width: 110px;
}
.resource-cell {
display: grid;
gap: 8px;
min-width: 220px;
}
.resource-cell__preview {
align-items: center;
color: #6b7280;
display: flex;
font-size: 12px;
gap: 8px;
min-height: 36px;
}
.resource-cell__preview img {
border-radius: 4px;
flex: 0 0 auto;
height: 32px;
object-fit: cover;
width: 32px;
}
.resource-cell__preview span,
.resource-cell__summary {
color: #6b7280;
font-size: 12px;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cover-preview {
display: flex;
align-items: center;